阅读 325

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的帮助下,被平行标记


参考文献:



关注下面的标签,发现更多相似文章
评论