V8对GC的优化

525 阅读12分钟

1. 分代式垃圾回收

1.1 新老生代

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8中将堆内存分为新生代和老生代区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。

新声代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持1~8M的容量,而老生代的对象为存活事件较长或常驻内存的对象,简单来说是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大。

V8整个堆内存的大小就等于新生代加上老生代的内存:

对于新老两块内存区域垃圾回收,V8采用了两个垃圾回收器来管控,我们暂且将管理新生代的垃圾回收器叫做新生代垃圾回收器,同样的,我们称管理老生代的垃圾回收器叫做老生代垃圾回收器。

1.2 新生代垃圾回收

新生代对象是通过一个名为Scavenge的算法进行垃圾回收,在Scavenge算法的具体实现中,主要采用了一种复制式的方法即 Cheney算法:

Cheney算法中将堆内存一分为二,一个是处于使用状态的空间暂且称之为使用区,一个是处于闲置状态的空间称之为 空闲区:

新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。

当开始进行垃圾回收时,新生代垃圾回收器会堆使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉,最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区。

当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。

另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中,设置为25%的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。

1.3 老生代垃圾回收

老生代垃圾回收的整个流程采用了标记清除。

首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。清除阶段老生代垃圾回收器会直接将非活动对象清理掉。

标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而V8中采用了标记整理算法来解决这一问题来优化空间。

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率。

2. 并行回收(Parallel)

有个概念叫做:全停顿(Stop-The-World),JavaScript在进行垃圾回收时就会被阻塞,垃圾回收结束后恢复执行。

比如一次 GC 需要 60ms,那应用的逻辑就得暂停 60ms,假如一次 GC的时间过长,对用户来说就可能造成页面卡顿等问题。这里,引入多个辅助线程来同时处理,以此加速垃圾回收的执行速度,因此 V8 团队引入了并行回收机制。

所谓并行,也就是同时的意思,它指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作。

新生代对象空间就采用并行策略,在执行垃圾回收的过程中,会启动了多个线程来负责新生代的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针,这就是并行回收。

3. 增量标记与惰性清理

并行策略虽然可以增加垃圾回收的效率,对于新生代垃圾回收器能够有很好的优化,但是它还是一种全停顿式的垃圾回收方式,对于老生代来说,它的内部存放的都是一些比较大的对象,对于这些大的对象 GC 时哪怕我们使用并行策略依然可能会消耗大量时间。所以为了减少全停顿的时间,在2011年,V8 对老生代的标记进行了优化,从全停顿标记切换到增量标记。

3.1 什么是增量

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会,这样交替多次完成一轮 GC 标记:

将一次完整的 GC 标记分次执行,那在每一小次 GC 标记执行完之后如何暂停下来去执行任务程序,而后又怎么恢复呢?那假如我们在一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了又怎么办呢?

3.2 三色标记法(暂停与恢复)

我们知道老生代是采用标记清理算法,而上文的标记清理中我们说过,也就是在没有采用增量算法之前,单纯使用黑色和白色来标记数据就可以了,其标记流程即在执行一次完整的 GC 标记前,垃圾回收器会将所有的数据置为白色,然后垃圾回收器在会从一组跟对象出发,将所欲哦能访问到的数据标记为黑色,遍历结束之后,标记为黑色的数据对象,剩余的白色数据对象也就是待清理的垃圾对象。

如果采用非黑即白的标记策略,那在垃圾回收器执行了一段增量回收后,暂停后启用主线程去执行了应用程序中的一段JS代码,随后当垃圾回收器再次被启动妈这时候内存中黑白色都没有,我们无法得知下一步走到哪里了。

为了解决这个问题,V8团队采用了一种特殊方式:三色标记法

三色标记法即使用每个对象的两个标记和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑

  1. 白色指的是未被标记的对象;
  2. 灰色指自身被标记,成员变量(该对象的引用对象)未被标记;
  3. 黑色指自身和成员变量皆被标记;

如上图所示,最初所有的对象都是白色,意味着回收器没有标记它们,从一组根对象开始,先将这组根对象标记为灰色并标记工作表中,当回收器从标记工作表中弹出对象并访问它的引用对象时,将其自身由灰色转变成黑色,并将自身的下一个引用对象转为灰色;

就这样一直往下走,直到没有可标记灰色的对象时,也就是无可达(无引用)的 对象乐乐,那么剩下的所有白色对象都是无法到达的,即等待回收(如上图中的C、E将要等待回收)。

采用三色标记法后我们在恢复执行时就可以直接通过当前内存中有没有灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行。

三色标记法的 mark 操作可以渐进执行的而不需每次都扫描整个内存空间,可以很好的配合增量回收进行暂停恢复的一些操作,从而减少 全停顿 的时间。

3.3 写屏障(增量中修改引用)

一次完成的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了,增量中修改引用。下面是个例子:

假如我们有A、B、C三个对象依次引用,在第一次增量分段中全部标记为黑色(活动对象),而后暂停开始执行脚本(JS),在脚本中将对象B的的指向对象C改为对象D,接着恢复执行下一次增量分段。

这时其实对象 C 已经无引用关系了,但目前它是黑色(代表活动对象)此一整轮GC是不会清理C的,不过我们可以不考虑这个,因为就算次轮不清理等下一轮GC也会清理,这对我们程序运行并没有太大影响。

我们再看新的对象D时初始的白色,按照我们上面所说,已经没有灰色对象了,也就是全部标记完毕接下来要进行清理了,新修改的白色对象D将在此轮GC的清理阶段被回收,还有引用关系就被回收,后面可能还会用到对象D,这是不对的。

为了解决这个问题,V8增量回收使用 写屏障(Write-barrier)机制,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量GC标记阶段可以正确标记,这个机制也被称作 强三色不变性。

上图的例子中,将对象B的指向由对象C改为对象D后,白色对象D会被强制改为灰色。

3.4 惰性清理

增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存V8采用的是惰性清理(Lazy Sweeping)

增量标记完成后,惰性清理就开始了。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要理机清理内存的,可以将清理过程稍微延迟一下,让JS脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一清理直到所有的非活动对象内存都是清理完毕,后面再接着执行增量标记。

3.5 增量标记与惰性清理的优缺点

增量标记与惰性清理的出现,使得主线程的停顿时间大大减少了,让用户与浏览器交互的过程变得更加流畅。但是由于每个小的增量标记之间执行了JS代码,堆中的对象指针可能发生了变化,需要使用写屏障技术来记录这些引用关系的变化,所以增量标记缺点也很明显:

  1. 并没有减少主线程的总暂停的时间,甚至会略微增加;
  2. 由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量;

4. 并发回收

并行回收依然会阻塞主线程,增量标记同样由增加了总暂停时间、降低应用程序吞吐量两个缺点,那么怎么才能在不阻塞主线程的情况下执行垃圾回收并且与增量相比更高效呢?

这就要说到并发回收了,它指的是主线程在执行JS的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起:

辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起,这是并发的优点,但同样也是并发回收实现的难点,因为它需要考虑主线程在执行JS时,堆中的对象引用关系随时都有可能发生变化,这时辅助线程之前做的一些标记或者正在进行的标记就会要有所改变,所以它需要额外实现一些读写锁机制来控制这一点。

5. 总结

V8的垃圾回收策略主要基于分代式垃圾回收机制,关于新生代垃圾回收器,使用并行回收可以很好的增加垃圾回收的效率,上述的三种方式各有优缺点,所以在老生代垃圾回收器中这几种策略都是融合使用的:

  1. 老生代主要使用并发标记,主线程在开始执行JS时,辅助线程也同时执行标记操作(标记操作全部由辅助线程完成)
  2. 标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作);
  3. 清理的任务会采用增量的方式分批在各个JS任务之间执行;