什么是GC?
GC是Garbage Collection的缩写,表示垃圾回收。在程序工作过程中,会产生很多’垃圾’,这些垃圾是程序不用的内存空间。GC就是负责收走垃圾的,由于GC工作在JavaScript引擎内部,因此对前端开发者来说,GC在“一定程度上”是悄无声息工作的,是不可见的。
GC主要做什么?
- 找到内存空间中的垃圾
- 回收垃圾,让程序员能再次利用这部分空间
所有语言都有GC吗?
不是所有语言都有GC,相对来说,高级语言里面一般有GC,比如Java、JavaScript、Python,没有GC的话,需要程序员手动管理内存,比如C语言常见的malloc/free,其实就是memory allocation的缩写,还有C++里面的new/delete。
js垃圾回收机制
垃圾回收机制能做什么?
解决内存的泄露,垃圾回收机制会定期(周期性)找出那些不再用到的内存(变量),然后释放其内存。
常见的垃圾回收方法有哪些?
引用计数
是一种不太常⻅的垃圾回收机制,是最简单的垃圾收集算法。含义就是跟踪每个值被引用的次数,如果同一个值又被赋值给另一个变量那么该值 的引用次数就加一,相同的如果包含这个值引用的变量又取得另一个值,那么引用次数就减一。当这个值的引用次数变为0时候,说明没有办法访问这 个值了,因此就可以将其占用的内存空间收回。来看个例子
var a = new Object(); // 该对象的引用计数为1(a在引用)
var b = a; // 该对象的引用计数是2(a,b)
a = null; // count = 1
b = null; // count = 0
// GC来回收这个对象了
优点
- 可即刻回收垃圾,当被引用数值为0时,对象马上会把自己作为空闲空间连到空闲链表上,也就是说。在变成垃圾的时候就立刻被回收。
- 因为是即时回收,那么‘程序’不会暂停去单独使用很长一段时间的GC,那么最大暂停时间很短。
- 不用去遍历堆里面的所有活动对象和非活动对象
缺点
- 计数器需要占很大的位置,因为不能预估被引用的上限,打个比方,可能出现32位即2的32次方个对象同时引用一个对象,那么计数器就需要32位;
- 最大的劣势是无法解决循环引用无法回收的问题;
来看个例子:
function problem(){
let o1 = new Object();
let o2= new Object();
o1.someOtherObject = o2; //a引 用了 b
o2.someOtherObject = o1;// b引用了a,形成了一个环
}
problem()
该例子中o1和o2通过各自的属性相互引用,即这俩对象引用的次数都是2,在标记清除策略的实现中,由于函数执行完毕后,这俩对象都离开了作用域,因此这种互相引用不是问题,但在引用计数策略实现中,当函数执行完毕后他们将继续存在,引用次数一直都为2,如果这个函数被多次调用的话那么会产生大量的内存得不到回收。所以就有问题。该算法的循环引用的这个问题逐渐被标记清除法替代。
标记清除
标记清除法可以很好的解决上面的循环引用问题。
js中最常用的垃圾回收方法就是标记清除,当变量进入环境时,例如,在一个函数中声明一个变量,就将这个变量标记为"进入环境",从逻辑上讲,永远不能释放进入环境变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们,而当变量离开环境时,则将其标记为"离开环境"。
活动对象
能通过引用程序引用的对象就被称为活动对象(可以直接或间接从全局变量空间中引出的对象)
非活动对象
不能通过程序引用的对象称为非活动对象(这就是被清除的目标)
标记清除主要将GC分为两个阶段:
- 标记阶段:把所有活动对象做上标记;
- 清除阶段:把没有标记(即非活动对象)销毁;
在标记-清除算法中比较重要的两个概念一个是根,一个是可达。
什么样的称之为根?
- 有一组基本的固有可达值,由于一些原因无法删除。例如:
本地函数的局部变量和参数; - 当前嵌套调用链上的其他函数的变量和参数
- 全局变量;
- 还有一些其他的,内部的;
什么是可达的?
如果引用或引用链可以从根访问任何其他值,则认为该值是可达的。
标记阶段
根可以理解成全局作用域,GC从全局作用域的变量,沿作用域逐层往里遍历(深度遍历),当遍历到堆中对象时,说明该对象被引用着,则打上一个标记,继续递归遍历(因为肯定存在堆中对象引用另一个堆中对象),直到遍历到最后一个(最深的一层作用域)节点。
标记完成之后,就是这样的:
清除阶段
遍历,是遍历整个堆,回收没有打上标记的对象。
来看个例子:
function test() {
var a = 10 //标记"进入环境"
var b = 'hello' //标记"进入环境"
}
test() // 执行完毕,a和b被标记"离开环境",被回收
优点
- 实现简单,打标记分为打或不打两种可能,所以就一位二进制位就可以表示;
- 解决了循环引用问题
缺点
- 造成碎片化(有点类似磁盘的碎片化)
- 再分配时遍次数多,如果一直没有找到合适的内存块大小,那么会遍历空闲链表(保存堆中所有空闲地址空间的地址形成的链表)一直遍历到尾端;
总结:
- 离开作用域的值将被自动标记为可以回收的,因此在垃圾收集期间被删除;
- 标记清除是当前主流的垃圾回收算法,是给当前不适用或者适用的值加上标记,然后再使用这种算法,
- 引用计数目前js引擎都不再使用这种算法,但在ie中访问非原生js对象(列入dom)这种算法仍然会导致问题。
- 解除变量的引用不仅有助于消除循环引用现象,而且对垃圾收集也有好处,为了确保有效滴回收内存,应该及时解除不在使用的全局对象、全 局对象属性以及循环引用变量的引用。
引起内存泄露的原因
内存泄漏怎么造成的? 程序未能释放已经不再使用的内存
- 意外的全局变量
原因:全局变量,不会被回收。
解决:使用严格模式避免。
function fun() {
bar = "aaa";
}
// 还有this创建的全局变量
function fun(){
this.bar = "aaa"
}
// 等同于
function fun() { window.bar === "aaa" }
// 解决方法,在JavaScript文件中添加'use strict',开启严格模式,可以有效地避免上述问题
function foo(arg) {
"use strict" // 在foo函数作用域内开启严格模式
bar = "aaa"; // 报错:因为bar还没有被声明
}
- console.log
console.log: 向web开发控制台打印一条消息,常在开发时调试分析。有时在开发时,需要打印一些对象信息,但发布时却忘记去掉console.log语句,这可能造成内存泄露。
在传递给console.log的对象是不能被垃圾回收 ,因为在代码运行之后需要在开发工具能查看对象信息。所以最好不要在生产环境中console.log任何对象。
- 闭包
原因:闭包可以维持函数内局部变量,使其得不到释放。
解决:将事件处理函数定义在外部,解除闭包;不要把外层引用,赋予内部变量;不要随意引用外部函数;内部函数一定要注意外层环;尽量不用使用全局变量保存状态;
- 被遗忘的定时器或者回调
原因:定时器中有dom的引用,即使dom删除了,但是定时器还在,所以内存中还是有这个dom。
解决:手动删除定时器和dom
- dom
在js中,DOM操作是非常耗时的,由于js引擎独立于渲染引擎,而DOM是位于渲染引擎的,相互访问需要耗费一定内存资源,如Chrome浏览器中JavaScript/ECMAScript位于V8中而DOM位于WebCore,假如将JavaScript/ECMAScript、DOM分别想象成两座孤岛,两岛之间通过一座收费桥连接,过桥需要交纳一定“过桥费”。JavaScript/ECMAScript每次访问DOM时,都需要交纳“过桥费”。因此访问DOM次数越多,费用越高,⻚面性能就会受到很大影响。
为了减少DOM访问次数,一般情况下,当需要多次访问同一个DOM方法或属性时,会将DOM引用缓存到一个局部变量中。但如果在执行某些删除、更新操作后,可能会忘记释放掉代码中对应的DOM引用,这样会造成DOM内存泄露。
来看个例子:
var elements ={
button:document.getElementById("button"),
image:document.getElementById('image')
}
function doStuff(){
elements.image.src = 'http://example.com/a.png'
}
function removeImage(){
document.body.removeChild(document.getElementById('image'))
}
// 此时在全局元素对象中仍在引用button,垃圾收集无法将其回收造成内存泄露