零散专题34 JavaScript的垃圾回收机制

JavaScript的垃圾回收机制学习笔记。

手动回收?

JavaScript在创建变量时自动进行了内存分配,并且在不使用时自动释放。释放的过程叫做垃圾回收。

JavaScript没有暴露任何垃圾回收期的接口,其内存管理是自动执行且不可见的,所以开发者没有办法手动进行强制的垃圾回收,也没有办法干预内存管理。

在多数情况下,开发者也没有必要手动解除对象的引用,只要简单地把变量放在他们应该的地方(局部变量),垃圾就能正确被回收

V8的内存分配

当声明变量并赋值时,V8就会在堆内存中分配一部分给这个变量。如果已申请的内存不足以存储这个变量,V8就会继续申请,直到堆的大小到了V8的内存上限为止。

堆内存分为两种分配方式:

  1. 静态分配,全局变量、函数之类的分配方式,它们在页面没有关闭之前,是不会被清除的
  2. 动态分配,使用new创建出来的,主动要求给分配空间的

可达性

JavaScript中内存管理关键的一个概念是可达性,一个变量的“可达性”值得就是它能够被以某种方式访问到或者引用到,被保证存储到内存中。JavaScript引擎中有一个名为“垃圾回收器”的进程,它监视所有对象,并删除不可访问的对象。

一些常见的具有可达性,会常驻内存的情况:

(1)全局变量,全局变量会常驻内存,除非刷新页面、离开页面时才会被清理

(2)对象引用

1
2
3
4
5
6
7
function ob(){
var bar = new largeObject(); // 很大一个对象\变量\字符串
bar.someCall();
return bar;
}

var a = new ob();

现在有一个指向bar对象的引用,当ob调用结束后,bar对象不会被回收,直到变量a分配其他引用(或者a超出了作用域范围)。

(3)DOM事件,即使DOM元素被移除,其绑定的事件不会被回收,除非使用removeEventListener显式的移除事件

(4)定时器,除非显式的清楚定时器

例子

假设定义了这样一个变量:

1
2
3
let user = {
name: 'John'
}

user这个变量引用了一个对象{name: 'John'}

如果将user的值覆盖user = null,则引用丢失,{name: 'John'}会被垃圾回收器清除,分配的内存被回收。

如果不将user的值覆盖,而是把user复制给另一个变量admin

1
let admin = user

这是两个变量都引用了同一个对象{name: 'John'}

这个时候再将user的值覆盖user = null,虽然user不再引用{name: 'John'},但是admin仍然与{name: 'John'}保持引用关系,所以{name: 'John'}不会被垃圾回收器清除,分配的内存也不会被回收。

复杂些的例子

函数marry通过给两个对象彼此提供引用来“联姻”它们,并返回一个包含两个对象的新对象

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

return {
father: man,
mother: woman
}
}

let family = marry({
name: "John"
}, {
name: "Ann"
})

产生的内存结构:

现在如果想要清除{name: John}这个变量,必须要同时删除外界对它的所有引用

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

它对外的引用man.wife是否删除不重要,因为只有外界对变量的引用,才决定了这个变量是否是“可达”的。现在虽然{name: John}仍然通过wife属性引用了{name: 'Ann'},但是外界对它的引用都删除了,所以它是不可达的,所以会被垃圾回收器清除。

也有可能整个对象变的不可访问从而被从内存中删除,例如我们切断family的引用family = null,那么:

虽然{name: John}{name: 'Ann'}仍然相互引用,但是family已经从根上断开了连接,不再有对它们的外界的引用(也就是说无法从局部或者全局变量访问到它们),所以下面整个块都是不可达的,整体都会被删除。

垃圾回收的算法

标记-清除算法

垃圾回收的基本算法是“标记-清除”,具体步骤如下,会被定期执行:

(1)垃圾回收器获取「根」并标记

(2)继续访问并标记所有来自「根」的引用,所有标记过引用都不会被重复访问:

(3)继续标记引用的后代引用,直到所有的“可达”(可以从根访问的)的引用全部被标记为止

(4)删除所有未标记的对象,回收它们占用的内存:

这就是JavaScript垃圾回收期的工作原理,JavaScript引擎应用了许多优化,使其运行的更快,并且不影响执行,例如:

(1)分代回收

对象分为两组“老对象”和“新对象”,许多新对象出现,完成任务后迅速被解除应用,它们很快被清理。而那些存活时间足够久的对象就会认为是“老对象”,很少接受检查

(2)增量回收

如果试图一次性遍历并标记整个对象集,那么花费时间会很长,因此引擎会试图将垃圾回收分解为多个部分,然后各个部分分别执行。这需要额外的标记来跟踪变化

(3)空闲时间收集

垃圾回收期只在CPU空闲时运行,以减少对正常任务的影响

除了“标记-清除”算法之外,还有其他的多种算法,比如“引用计数”算法,记录每个对象被引用的次数,每次新建对象、赋值引用和删除引用的同时更新计数器,如果计数器值为0则直接清除回收内存。这篇文章介绍了多种算法,并分析了优缺点,写的很好。

引用计数算法

引用计数算法是最初级的垃圾收集算法,此算法把“对象是否不再需要”简化定义为“有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收器回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var o = { 
a: {
b:2
}
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集

var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1; // 现在,“这个对象”的原始引用o被o2替换了

var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 最初的对象现在已经是零引用了
// 他可以被垃圾回收了
// 然而它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
// 它可以被垃圾回收了

这种算法没有办法处理循环引用的情况,例如下面的例子,两个对象被创建,并相互引用,形成循环。它们被调用后不再被外部引用,没用了,可以被回收了。但是引用计数算法认为它们都还至少有一次引用,所以不会被回收,一直保存在内存中。

1
2
3
4
5
6
7
8
9
10
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o

return "azerty";
}

f();

经验法则

为了使Chrome的垃圾回收器不保留不再需要的对象,有几点需要牢记:

  1. 在恰当的作用域中使用变量,尽量在函数作用域中声明变量,尽量声明局部变量,尽量避免全局变量
  2. 确保移除不再需要的事件监听器,比如即将被移除的DOM对象所绑定的事件
  3. 避免缓存大量不会被重用的数据
  4. 少用闭包
  5. 不要再生产环境使用console.log()console.error()console.dir()等方法打印任何复杂对象,因为这些对象不会被垃圾回收器回收。

参考