JS垃圾回收机制

一直以来我对 JS 垃圾回收机制的了解仅仅停留在两个词儿上:标记清除和引用计数。除此之外我就啥不知道了。最近空下来阅读了不少相关文章,算是对 JS 的垃圾回收机制有了点粗浅的认识。这篇博客算是一个笔记,也是一个总结。而我阅读的文章也会在最后的“资源”一节列出,想更加深入了解 JS 垃圾回收机制的小伙伴可以点击查看。

什么是垃圾

要想理解 JS 的垃圾回收机制,首先得确定什么才是所谓的要被回收的垃圾。一般情况下,没有被引用的对象就是垃圾,是需要被清除的。下面是我总结的几大类型。

全局变量

所有全局的变量都不是垃圾,因为浏览器会认为你在任何情况下都有可能使用到全局变量。例如下面这种情况:

1
2
3
4
5
var str = 'hello world';

// 此处省略若干代码

console.log(str);

在顶部定义了一个全局变量 str,你是完全有可能在 n 行代码以后打印这个变量的。

局部变量

通常情况下,局部作用域变量在函数执行完毕后就是垃圾,再也用不到了:

1
2
3
4
5
6
7
function fn() {
var str = 'hello world';
console.log(str);
}

fn();
fn();

当开始执行 fn 时,才产生 str ,它的作用域就在 fn 中,fn 执行结束后,str 就是垃圾,会被销毁。你可能会说,我完全有可能调用两次 fn 啊,但这里要注意的一点是,当你再次执行 fn 时,产生的是一个全新的 str ,和刚才的 str 完全没关系。

单引用

一个单引用对象,没有引用就是垃圾:

1
2
3
4
5
var user = {
name: 'John'
};

user = null;

我在全局作用域下定义了变量 user ,指向一个对象,然而却在最后把变量 user 设置为 null。此时就没有谁在引用 {name: “John”} 这个对象了,所以它是垃圾,会被销毁。

多引用

如果一个对象有两个及以上的引用,它不一定会被回收:

1
2
3
4
5
6
7
var user = {
name: 'John'
};

var admin = user;

user = null;

即使我们删掉了其中一个引用,但 admin 仍然还引用着 object ,所以此 object 还不会被回收。

环引用

还记得文章开头说的下面这段话吗?

一般情况下,没有被引用的对象就是垃圾,是需要被清除的。

环引用就是个例外,当几个对象相互引用,但没有被其它任何人引用时,它们仍然是垃圾,需要被回收,我们考虑下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function marry(man, woman) {
woman.husband = man;
man.wife = woman;

return {
father: man,
mother: woman
};
}

var family = marry(
{
name: 'John'
},
{
name: 'Ann'
}
);

上述代码我们创建了一个家庭(family)对象,其中包含一个男人(man)对象和一个女人(woman)对象,并让女人的丈夫(husband)设为男人,男人的妻子(wife)设为女人。它们之间的关系可以用下图表示:

接着我们执行下面操作:

1
2
delete family.father;
delete family.mother.husband;

此刻删掉家庭对丈夫的引用,以及妻子对丈夫的引用,此时不再有任何人引用丈夫,所以此时丈夫是垃圾,需要被回收:

让我们回到丈夫还没有被家庭和妻子去除引用的时刻。然后执行下面代码:

1
family = null;

这个时候,我们直接删掉了全局(外界)对家庭这个对象的引用,则家庭和丈夫以及妻子就都“死掉”了,需要被回收。你可能会说,它们之间不是存在相互引用关系吗?但此刻它们已经不被外界所引用,所以它们会被回收。

如何实现垃圾回收

分析完什么是所谓的垃圾之后,我们就可以开始讨论如何实现垃圾回收了。也就是文章开头提到的「标记清除」和「引用计数」。

标记清除

简单来理解,标记清除机制会从全局作用域开始,把所有它遇到的变量都标记一遍,如果这些被标记的变量还引用了其他变量,就再标记,一直标记到再也没有新的对象为止,这个标记过程就结束了。标记过程结束后就开始进行清除过程,把所有刚才没有被标记过的对象都删掉。这就是标记清除算法:

听起来这种实现很美好不是吗?但是此方法有个缺点很伤,那就是在变量对象多了以后,造成遍历很慢的后果。而且我们会不定时对这些变量都要再标记一遍,然而我们知道 JS 是单线程的,标记期间 JS 代码的执行会被中断,而标记的时间又很久,就造成不好的影响,这就是它的缺点。

所以针对这个缺点,我们会对标记清除做一些改进:

分代收集(Generational collection)

类比我们手机网络的 2G,3G 和 4G,这里的 G 就是 Generational 的意思,而我们的对象可以大致分为两种:

  • 新一代(e.g. 一个函数中的临时变量)
  • 老一代(e.g. window 对象)

不同代的回收策略是不一样的,一般老一代的对象停留时间会很久,而这种停留时间越久的对象,后面用到它的时间也可能越久(这只是一种推测,不一定对,仅仅是一种策略)。所以可能在第一轮标记后,会隔一段时间才会去再看它,比如 3s 后。

而新一代的对象,比如一个函数中的临时变量,函数执行完就可回收,不用从头开始遍历,这样的临时对象就会被马上标记然后立即马上删除,这样的对象就有可能隔 1 毫秒就看它一次,没被标记就删除了它。

增量收集(Incremental collection)

举个例子,假如此刻有 10000 个需要遍历的对象,我不一次性遍历完,而是先遍历 1000 个,然后执行 JS,再遍历 1000 个,再执行 JS… 这样遍历 10 次就能遍历完,而且 JS 也不会有明显卡顿。

空闲收集(Idle-time collection)

这个优化即字面意思,由于 JS 不是一直在执行的,所以我等到 JS 执行完后空闲下来再开始遍历。

引用计数

每次对象被引用的时候就+1,再被引用时就再+1,有人不引用它时就-1,再不引用就又-1,当这个对象被减到 0 时就该被垃圾回收了。

此算法的缺点也有不少,但被大家所熟知的一个缺点就是循环引用无法被回收。在早期的 IE 版本里(ie4-ie6),对宿主对象(也就是 Document 对象)采用是引用计数的垃圾回收机制,一旦你在 JS 中没有正确使用闭包,就会导致内存泄漏,这也就是此算法的一个缺陷。循环引用会导致没法回收,这个循环引用只限定于有宿主对象参与的循环引用,而 js 对象之间即使形成循环引用,也不会产生内存泄漏,因为对 js 对象的回收算法不是计数的方式。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function closure() {
var oDiv = document.getElementById('oDiv'); //oDiv用完之后一直驻留在内存中
oDiv.onclick = function () {
alert(oDiv.innerHTML); //这里用oDiv导致内存泄露
};
}

// 以上代码创建了一个作为 div 元素事件处理程序的闭包,而这个闭包则又创建了一个循环引用。
// 由于匿名函数保存了一个对closure()的活动对象的引用,因此就会导致无法减少div 的引用数。
// 只要匿名函数存在,element 的引用数至少也是1,因此它所占用的内存就永远不会被回收

closure();
//最后应将oDiv解除引用来避免内存泄露
function closure() {
var oDiv = document.getElementById('oDiv');
var test = oDiv.innerHTML;
oDiv.onclick = function () {
alert(test);
};
oDiv = null;
}
// 解决办法: 把 oDiv.innerHTML 的一个副本保存在一个变量中,
// 从而消除闭包中该变量的循环引用,同时将 oDiv 变量设为null。

好了,有关 JS 的垃圾回收机制就写到这里,想要了解更多可以点击下面的资源链接~ 完。

资源