JavaScript垃圾回收

背景知识

术语解释

垃圾(Garbage)

垃圾就是需要被回收的对象。

如果可以直接或者间接引用一个对象,这个对象就被认为是“存活”,与之相反就是“死亡”。

将“死亡”的对象找出来,作为垃圾回收,就是GC的本质。

根 (Root)

所谓的根就是判断对象是否可以被引用的起始点。

至于那里是根,不同编译器、不同语言都有不同规定,但是基本上是将变量和运行栈空间作为根。

一、垃圾回收的基本策略

1. 标记清除 (Mark and Sweep)

原理:从根开始将可能被引用的对象用递归的方式进行标记,然后将没有标记到的对象作为垃圾进行回收。

mark_and_sweep.png

显示了标记清除算法的大致原理 (《代码的未来》P76 图1)

缺点:

标记清除算法有一个缺点,就是在分配了大量对象,并且其中只有一小部分存活的情况下, 所消耗的时间会大大超过必要的值,这是因为在清除阶段还需要对大量死亡对象进行扫描。

1.1 标记压缩(Mark and Compact)

作为标记清除的变形,还有一种叫做标记压缩(Mark and Compact)的算法,它不是将被 标记的对象清除,而是将它们不断压缩。

2. 复制收集(Copy and Collection)

上面提到过“标记清除”算法存在一个缺点,就是存活数据很少的时候,消耗时间会非常大。

复制收集(Copy and Collection)则试图克服这一缺点。在这种算法中,会将从根开始被引 用的对象复制到另外的空间中,然后,再将复制的对象所能够引用的对象用递归的方式不断复 制下去。

copy_and_collection.png

(《代码的未来》P77 图2)

优点:

不需要像引用计数一样全部扫描对象。

这种算法的另一个好处是它具有局部性(Locality) 。在复制收集过程中,会按照对象被引 用的顺序将对象复制到新空间中。于是,关系较近的对象被放在距离较近的内存空间中的可能 性会提高,这被称为局部性。局部性高的情况下,内存缓存会更容易有效运作,程序的运行性 能也能够得到提高。

缺点:

复制收集的过程中,将对象复制一遍的开销大,因此在存活比例较高的情况下反而不利。

3. 引用计数 (Reference Count)

它的基本原理是,在每个对象中保存该对象的引用计数,当引用发生增减时对计数进行更新。 引用计数的增减,一般发生在变量赋值、对象内容更新、函数结束(局部变量不再被引用) 等时间点。当一个对象的引用计数变为 0 时,则说明它将来不会再被引用,因此可以释放相应 的内存空间。

    var obj = { a : 123 }; // 引用次数+1
    var obj1 = { a : 123 } //引用次数+1
    var obj = {}; //引用次数-1
    var obj = null; //引用次数为0

当声明了一个变量并将一个引用类型值赋值该变量时,则这个值的引用次数就是1.如果同一个值又被赋给另外一个变量,则该值得引用次数加1。相反,如果包含对这个值引用的变量又取 得了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那 些引用次数为零的值所占用的内存。

优点:

实现容易是引用计数算法最大的优点。标记清除和复制收集这些 GC 机制在实现上都有一 定难度;而引用计数方式的话,凡是有些年头的 C++ 程序员(包括我在内),应该都曾经实现 过类似的机制,可以说这种算法是相当具有普遍性的。 除此之外,当对象不再被引用的瞬间就会被释放,这也是一个优点。其他的 GC 机制中, 要预测一个对象何时会被释放是很困难的,而在引用计数方式中则是立即被释放的。而且,由 于释放操作是针对每个对象个别执行的,因此和其他算法相比,由 GC 而产生的中断时间(Pause time)就比较短,这也是一个优点。

缺点:

  1. 无法克服循环引用。
    function fn () {
      const objectA = { a : 123 };
      const objectB = { b : 10 };
      objectA.c = objectA;
      objectB.b = objectB;
      };

objectA和objectB这两个对象通过各自的属性相互引用,引用次数都为2,永远不会为0,永远得不到回收。如果这个方法多调用几次。会导致大量的内存得不到释放。

  1. 引用计数的第二个缺点,就是必须在引用发生增减时对引用计数做出正确的增减,而如果漏掉了某个增减的话,就会引发很难找到原因的内存错误。

  2. 引用计数管理并不适合并行处理。如果多个线程同时对引用计数进行 增减的话,引用计数的值就可能会产生不一致的问题(结果则会导致内存错误)。为了避免这种 情况的发生,对引用计数的操作必须采用独占的方式来进行。如果引用操作频繁发生,每次都 要使用加锁等并发控制机制的话,其开销也是不可小觑的。

二、进一步改良

GC 的基本算法,大体上都逃不出上述三种方式以及它们的衍生品。现在,通过对这三种方 式进行融合,出现了一些更加高级的方式。

其中最有代表性的三种,即分代回收、增量回收和并行回收。有些情况下,也可以对这些方法中的几种进行组合使用。

三、V8中垃圾回收

垃圾回收并不是语言的一部分,没有硬性标准。不同解释器实现的不一。这里说下Chrome V8引擎使用的方式。

v8采用的是一种分代回收的策略,将内存分为两个生代:新生代和老生代,v8针对新生代和老生代使用不同的垃圾回收算法来提升垃圾回收效率。

新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。新生代通常只支持1~8M的容量,而老生代支持的容量就大很多了。对于这两块区域,V8分 别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

副垃圾回收器-Minor GC (Scavenger),主要负责新生代的垃圾回收。 主垃圾回收器-Major GC,主要负责老生代的垃圾回收。

副垃圾回收器

副垃圾回收器主要负责新生代的垃圾回收。通常情况下,大多数小的对象都会被分配到新生 代,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。 新生代中的垃圾数据用Scavenge算法来处理。所谓Scavenge算法,是把新生代空间对半划 分为两个区域,一半是对象区域(from-space),一半是空闲区域(to-space),如下图所示

v8_gc_01.png

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

v8_gc_02.png

成复制后,对象区域与空闲区域进行⻆色翻转,也就是原来的对象区域变成空闲区域,原 来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时,这种⻆色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

v8_gc_03.png

不过,副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。

主垃圾回收器

主垃圾回收器主要负责老生代中的垃圾回收。除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。因此,老生代中的对象有两个特点:

  • 一个是对象占用空间大;
  • 另一个是对象存活时间⻓。

由于老生代的对象比较大,若要在老生代中使用Scavenge算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。所以,主垃圾回收器是采用标记-清除(Mark-Sweep)的算法进行垃圾回收的。

标记清除 工作流程

0. V8中对 根的定义

在浏览器环境中,GC Root有很多,通常包括了以下几种(但是不止于这几种):

全局的window 对象(位于每个 iframe 中); 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成; 存放栈上变量。

1. 标记阶段

首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历 过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

2. 清除过错

接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会 直接将标记为垃圾的数据清理掉。

参考

Mark24

Everything can Mix.