V8 —— 你须要知道的垃圾回收机制

前言

V8 blog近日发布了文章描述了“并发标记”的新技术,提高标记过程的效率。
并发标记是一个主要用新的平行和并发的垃圾收集器替换旧的垃圾回收器的项目,如今Chrome 64和Node.js v10已经默认启用并发标记。讲解以前咱们先回顾一下基本知识点。


基本概念

弱分代假设(The Weak Generational Hypothesis)

  1. 多数对象的生命周期短
  2. 生命周期长的对象,通常是常驻对象
V8的GC也是基于假设将对象分为两代: 新生代和老生代。
对不一样的分代执行不一样的算法能够更有效的执行垃圾回收。


新生代与老生代

新生代包括一个New Space,老生代包括: Old Space, Code Space和Map Space,Large Object Space。
64位环境下的V8引擎的新生代内存大小32MB、老生代内存大小为1400MB,而32位则减半,分别为16MB和700MB。
对于新生代的对象,采用空间换取时间的Scavenge算法, 尽量快的回收内存。若是对象经历了2次GC还依然坚挺,就会在第二次回收时晋升为老生代(准确的说是保存在Old Space中)。
而老生代的GC采起Mark-Sweep的算法,并使用Mark-Sweep解决内存碎片的问题。


Scavenge算法

对于新生代对象,采用Scavenge算法来回收。
简单来讲,将内存的空间分为两个semispace,同一时刻只有一个空间处于使用中。使用中的叫作 to space,不被使用的叫作 from space。
分配对象时,先在From空间分配,垃圾回收时检查(宽度优先)From空间的存活对象,将存活对象复制到To空间,清理非存活对象,复制后,空间身份发生对调。


Mark-Sweep算法

处理老生代对象时,采用深度优先扫描,用三色标记的算法。
V8使用每一个对象的两个mark-bits和一个标记工做栈来实现标记。
两个mark-bits编码三种颜色:白色(00),灰色(10)和黑色(11)。
白色表示对象能够回收,黑色表示对象不能回收,而且他的全部引用都被便利完毕了,灰色表示不可回收,他的引用对象没有扫描完毕。
扫描过程:
  1. 从已知对象开始,即roots(全局对象和激活函数), 将全部非root对象标记置为白色
  2. 将root对象的全部直接引用对象入栈(marking worklist)
  3. 依次pop出对象,出栈的对象标记为黑,同时将他的直接引用对象标记为灰色并push入栈
  4. 栈空的时候,仍然为白色的对象能够回收
  5. 回收白色的对象
在清除阶段,只清除没被标记的对象。
可是进行清除后,内存会出现不连续的状态,对后续的大对象分配地址形成无心义的回收(由于可用内存的不足),这时就须要Mark-Compact来处理内存碎片了。


Mark-Compact算法

在对象标记死亡后,在整理的过程当中,将活着的对象向另外一个内存页移动,移动完后内存页就能够还给操做系统,但若是这一页的活动对象被不少其余页的对象引用,就不会compact,由于移动完后更新其余引用的指针开销大。


全暂停与增量标记

垃圾回收的3种基本算法须要应用逻辑暂停下来,垃圾回收完后恢复应用程序逻辑,即“全暂停”,过长的停顿会让用户感到卡顿,因此为了下降全堆的垃圾回收,当堆的大小到必定程度后,开始增量GC,V8在标记阶段将标记的动做分为不少小“步进”,应用逻辑与垃圾回收交替进行直到标记阶段完成。
可是,对于过大的堆,GC在试图跟上应用程序分配速度的过程当中,仍有长时间的停顿,而且应用程序须要通知GC对象图的全部变化,这些都是须要成本的(写保障 write-barrier)。
V8使用Dijkstra-style 的写屏障(write-barrier)来实现通知。
当object.field = value in JavaScript时,V8会插入如下代码:
// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}
复制代码
write-barrier能够保障不会出现黑色对象指向了白色对象的现象发生(强三色不变形 strong tri-color invariant),这样应用程序不会在GC时误删活动对象。在GC完成后全部白色对象都是可安全删除的。
可是,因为write-barrier的损耗,下降了应用程序的吞吐量,因此需用其余的worker threads提升吞吐量,使worker threads也能够进行标记的工做。这就是下面要讲的平行标记和并发标记。


平行标记 parallel marking

平行标记期间,应用程序暂停,main thread和worker thread共同执行标记操做,下图显示了平行标记所涉及的数据结构。箭头指示数据流的方向。
其中,对象图是只读的,不容许去修改他,Mark-bits和Marking worklist是能够读和写的。
Marking worklist负责决定分给其余worker thread的工做量,决定了性能与保持本地线程的均衡,因此如何高效地完成工做的分配相当重要。
以下图所示,V8使用基于内存段的方式去平衡各个线程的工做量,避免线程同步的耗时与尽量的工做。


并发标记 concurrent marking

并发标记容许标记行为与应用程序同时进行。这就须要解决数据竞争的问题,好比JS代码在更改一个对象的字段,而worker thread又在标记字段,就可能致使错误的垃圾回收。
因此main thread须要与worker threads在发生数据竞争时进行同步,大多数的数据竞争行为经过轻量级的原子级内存访问就能够同步,可是一些特殊的场景须要独占整个对象的访问。


优化的结果

有了平行标记与并发标记后,对比上面讲的流程,GC的流程变为:
  1. 从root对象开始扫描,填充对象到marking worklist
  2. 分布并发标记任务到worker threads
  3. worker threads帮助main thread去更快地消费marking worklist中的对象
  4. main thread 偶尔会经过执行bailout worklist 和 marking worklist来marking
  5. 一旦marking worklists为空,main thread 就完成GC行为
  6. 在结束以前,main thread从新扫描roots,可能会发现其余的白色节点,这些白色节点会在worker threads的帮助下,被平行标记


参考文献:

相关文章
相关标签/搜索