Jank 克星第二部分: Orinoco

发布时间 · 标签: internals memory

先前的博客文章中,我们介绍了由于垃圾回收中断流畅的浏览体验而导致的 jank 问题。在此博客文章中,我们介绍了三个优化,这些优化为 V8 中代号为 Orinoco 的新垃圾回收器奠定了基础。Orinoco 基于这样的想法,即在没有严格的分代边界的情况下实现大部分并行和并发的垃圾回收器将减少垃圾回收的 jank 和内存消耗,同时提供高吞吐量。我们决定将 Orinoco 的功能增量地交付到 V8 tip of tree 上,而不是在标志后面将 Orinoco 实现为单独的垃圾回收器,以使用户立即受益。这篇文章中讨论的三个功能是并行压缩(parallel compaction),并行记忆集处理(parallel remembered set processing)和黑色分配(black allocation)。

V8 实现了一个分代垃圾回收器,对象可以新生代(young generation)内部、从新生代到老年代以及在老年代(old generation)内部进行移动。移动对象非常昂贵,因为需要将对象的基础内存复制到新位置,并且指向这些对象的指针也需要更新。图 1 显示了这些阶段及其在 Orinoco 之前的执行方式。本质上,首先移动对象,然后再更新这些对象之间的指针,所有这些指针均按顺序排列,从而导致可观察到的 jank。

图1:对象的顺序移动和更新指针

V8 将其堆内存划分为固定大小的块(称为页面),这些块被分配给新生代或老年代空间。对象最初是在新生代中分配的。进行垃圾回收后,存活对象(live objects)会在新生代中移动一次。在下一个垃圾回收中幸存下来的对象被提升为老年代。对于这两个阶段,我们统称为新生代晋升(young generation evacuation),我们基于页面并行化内存的复制。在新生代中,移动对象始终涉及在新页面上分配内存(并释放旧页面),从而留下紧凑的内存布局。在老年代中,此过程以略有不同的方式发生,因为死亡内存留下了无法使用的漏洞(或碎片)。这些漏洞中的一些漏洞可以通过空闲列表重用,而其它漏洞则留在后面,需要压缩以将活动对象移动到更好的页面(可能是新页面)。与新生代类似,此过程在页面级别(page-level)上并行进行。

由于新生代的晋升与老年代的压缩之间没有依赖关系,因此 Orinoco 现在并行执行这些阶段,如图 2 所示。这些改进的结果是将压缩时间从平均约 7ms 减少了 7% 到 2ms 以下 。

图2:并行移动对象和更新指针

Orinoco 引入的第二个优化改进了垃圾回收如何跟踪指针。当一个对象在堆上移动位置时,垃圾回收器必须找到包含被移动对象的旧位置的所有指针,并用新位置更新它们。由于遍历堆以查找指针将非常慢,因此 V8 使用称为 记忆集(remembered set) 的数据结构来跟踪堆上所有有意义的(interesting)指针。如果指针指向在垃圾回收期间可能移动的对象,则该指针是有意义的。例如,从上一代到新一代的所有指针都很有意义,因为新一代对象在每个垃圾回收期间移动。指向高度分散的页面中的对象的指针也很有意义,因为这些对象在压缩过程中将移动到其它页面。

以前,V8 将记忆集实现为指针地址或 存储缓冲区(store buffers) 的数组。对于新生代和每个零散的老年代页面都有一个存储缓冲区。页面的存储缓冲区包含所有传入指针的地址,如图 3 所示。条目被附加到 写屏障(write barrier) 中的存储缓冲区中,以保护 JavaScript 代码中的写操作。这可能导致重复的条目,因为存储缓冲区可能多次包含一个指针,而两个不同的存储缓冲区可能包含相同的指针。重复的条目使指针更新阶段的并行化变得很困难,因为两个线程试图更新同一指针会导致数据争用。

图3:旧的记忆集(remembered set)

Orinoco 通过重组记忆集来简化并行化并确保线程获得不相交的指针集以进行更新,从而消除了这种复杂性。现在,每个页面不再将传入的有意义的指针存储在数组中,而是将源自该页面的有意义的指针的偏移量存储在位图的存储区(buckets)中,如图 4 所示。每个存储区要么为空,要么指向固定长度的位图。位图中的一位对应于页面中的指针偏移量。如果设置了一个位,则该指针很有意义且位于记住的位置。使用这种数据结构,我们可以基于页面并行化指针更新。缺少重复的条目和密集的指针表示,还使我们能够删除复杂的代码来处理记忆集溢出(remembered set overflow)。在我们长期运行的 Gmail 基准测试中,此更改将压缩垃圾回收的最大停顿时间从 42ms 减少了 23% 至 23ms。

图4:新的记忆集(remembered set)

Orinoco 引入的第三个优化是 黑色分配(black allocation),这是垃圾回收器标记阶段的改进。黑色分配(在 V8 5.1 中已提供)是一种垃圾回收技术,其中,将老年代中分配的所有对象(例如,pre-tenured 分配或垃圾回收器晋升的对象)立即标记为黑色,以将其指定为“活动(live)”。黑色分配背后的直觉是,在老年代中分配的对象可能寿命很长。因此,最近在老年代中分配的对象至少应在下一次老年代垃圾回收中幸存,否则它们将被错误地晋升。将新分配的对象着色为黑色后,垃圾回收器将不会访问它们。我们通过将黑色对象分配在默认情况下所有对象均为黑色的黑色页面上来加快着色。黑页的另一个好处是不必清除它们,因为(根据定义)分配给它们的所有对象都是活动的。黑色分配可加快增量标记进度,因为标记工作不会随新的内存分配而增加。黑色分配的影响在 Octane Splay 基准测试中清晰可见,吞吐量和延迟得分提高了约 30%,而由于标记处理速度更快且总体上减少了垃圾回收工作,因此使用的内存减少了约 20%。

我们计划很快推出更多 Orinoco 功能。请继续关注,我们仍在修补!

作者:the jank busters: Ulan Degenbaev, Michael Lippautz, and Hannes Payer.