分代垃圾回收 (GGC) 现已在 Firefox 32 中的 SpiderMonkey JavaScript 引擎中启用。GGC 仅是性能优化,对脚本行为应该没有可观察到的影响。
那么它是什么?它做了什么?
GGC 是 JavaScript 引擎更快地回收短期对象的一种方式。假设你有类似于以下的代码
function add(point1, point2) {
return [ point1[0] + point2[0], point1[1] + point2[1] ];
}
如果没有 GGC,你的垃圾回收(以下简称“GC”)开销会很高。每次调用 add()
都会创建一个新的 Array
,并且你传入的旧数组很可能已经变成了垃圾。很快,垃圾就会堆积起来,GC 就需要介入。这意味着需要扫描整个 JavaScript 堆(所有创建对象的集合)来找到仍然需要的对象(“活动”对象),以便可以丢弃其他所有对象,并将空间重新用于新对象。
如果你的脚本没有保留很多活动对象,这完全没问题。当然,你会不断创建大量的垃圾并回收它们,但扫描活动对象会很快(因为活动对象不多)。但是,如果你的脚本确实创建了大量对象并保持它们处于活动状态,那么完整的 GC 扫描就会很慢,而脚本的性能将很大程度上取决于它生成临时对象的速率——即使旧对象没有改变,你也会不断地重新扫描它们,以发现你已经知道的信息。(“你死了吗?”“没有。”“你死了吗?”“没有。”“你死了吗?”…)
分代收集器、苗圃和终身区
使用分代收集器,临时对象的开销会低得多。大多数对象将分配到一个称为苗圃的独立内存区域。当苗圃填满时,只扫描苗圃以查找活动对象。大多数短期临时对象将是死的,因此此扫描会很快。幸存者将被提升到终身区。
终身区堆也会积累垃圾,但通常比苗圃的速率低得多。它需要更长的时间才能填满。最终,我们仍然需要进行完整的 GC,但在典型的分配模式下,这些 GC 应该比苗圃 GC 发生得少得多。为了区分这两种情况,我们将苗圃收集称为次要 GC,并将完整的堆扫描称为主要 GC。因此,使用分代收集器,我们将 GC 分为两种类型:大多是快速的次要 GC,以及较少的较慢的主要 GC。
GGC 开销
虽然看起来我们应该一直这样做,但事实证明,它需要相当多的我们以前没有的设施,并且它在正常操作期间也带来了一些开销。考虑如何确定某个苗圃对象是否处于活动状态的问题。它可能被一个活动终身区对象指向——例如,如果你创建一个对象并将它存储到一个活动终身区对象的属性中。
你如何知道哪些苗圃对象被终身区对象保持活动状态?一种方法是扫描整个终身区堆以找到指向苗圃的指针,但这会违背 GGC 的目的。因此,我们需要一种更便宜的方法来回答这个问题。
请注意,堆图中的这些终身区 ⇒ 苗圃边不会持续很长时间,因为下一个次要 GC 会将苗圃中所有幸存者提升到终身区堆。因此,我们只关心自上次次要(或主要)GC 以来已被修改过的终身区对象。这些对象不会很多,因此我们将代码写入终身区对象以检查它是否正在写入任何苗圃指针,如果是,则将跨代边记录在存储缓冲区中。
从技术角度来说,这被称为写入屏障。然后,在次要 GC 期间,我们遍历存储缓冲区并将每个目标苗圃对象标记为活动状态。(我们实际上同时使用边的源,因为我们在标记苗圃对象为活动状态的同时将其重新定位到终身区区域,因此需要更新指向苗圃的终身区指针。)
使用存储缓冲区,次要 GC 的时间取决于从终身区区域到苗圃的新创建边的数量,而不仅仅是苗圃中活动对象的数量。此外,跟踪存储缓冲区记录(或仅仅是检查是否需要创建存储缓冲区记录)确实会稍微减慢正常的堆访问速度,因此某些代码模式在使用 GGC 时实际上运行速度会变慢。
分配性能
另一方面,GGC 可以加快对象分配速度。在 GGC 之前的堆需要完全通用。它必须跟踪使用区域和空闲区域,并避免碎片。GC 需要能够遍历堆中的所有内容以找到活动对象。在这样的通用堆中分配对象非常复杂。(GGC 的终身区堆基本上具有相同的约束,并且实际上重复使用了 GGC 之前的堆实现。)
另一方面,苗圃只是在填满之前增长。你永远不需要删除任何东西,至少在次要 GC 期间释放整个苗圃之前不需要删除,因此不需要跟踪空闲区域。因此,苗圃非常适合碰撞分配:要分配N字节,你只需检查是否有可用空间,然后将当前堆末尾指针增加N字节并返回先前指针。
甚至有一些技巧可以优化许多情况下的“可用空间”检查。因此,生命周期很短的对象永远不会经过较慢的终身区堆分配代码。
计时
我编写了一个简单的基准测试来演示 GGC 可能带来的各种好处。该基准测试有点像“向量斐波那契”计算,它计算二维向量的x和y分量的斐波那契数列。该脚本在每次迭代时分配一个临时对象。它首先在(终身区)堆几乎为空的情况下计时循环,然后构建一个大型对象图,旨在将其放置在终身区部分的堆中,然后再次计时循环。
在我的笔记本电脑上,基准测试显示 GGC 带来了巨大的优势。在堆为空的情况下,循环每次迭代的平均时间从 15 纳秒 (ns) 降至 6 ns,这证明了苗圃分配的速度更快。它还表明了与终身区堆大小的独立性:在没有 GGC 的情况下,填充长生命周期堆会将平均时间从 15 ns 降低到 27 ns。在使用 GGC 的情况下,速度保持在每次迭代 6 ns;终身区堆根本不重要。
请注意,此基准测试旨在突出显示 GGC 可能带来的改进。实际效果很大程度上取决于特定脚本的细节。在某些脚本中,初始化对象的耗时非常重要,可能超过分配内存所需的时间。较高百分比的苗圃对象可能会被提升到终身区。在浏览器内部运行时,我们会强制执行足够多的主要 GC(例如,在重绘之后),因此 GGC 的好处不那么明显。
此外,上面的描述意味着我们会暂停足够长的时间来收集整个堆,但事实并非如此——我们的增量垃圾收集器已经显著地减少了许多 Web 工作负载的暂停时间。(增量收集器和分代收集器相互补充——它们分别解决问题的不同部分。)
基准测试代码
function bigHeap(N) {
var result = [];
for (var i = 0; i < N; i++)
result.push({ 'number': i, 'prev': result[-1] });
return result;
}
function add(a, b) {
return [a[0] + b[0], a[1] + b[1]];
}
function vecfib(n) {
var v1 = [0, 0];
var v2 = [1, 1];
for (var i = 0; i < n; i++) {
var v = add(v1, v2);
v1 = v2;
v2 = v;
}
return v1;
}
var t = {};
var iters = 10000000;
t.smallheap_start = Date.now();
var dummy1 = vecfib(iters);
t.smallheap_end = Date.now();
H = bigHeap(10000000);
t.bigheap_start = Date.now();
var dummy2 = vecfib(iters);
t.bigheap_end = Date.now();
print("Small heap: " + ((t.smallheap_end - t.smallheap_start) / iters) * 1000000 + " ns/iter");
print("Big heap: " + ((t.bigheap_end - t.bigheap_start) / iters) * 1000000 + " ns/iter");
关于 Steve Fink
SpiderMonkey JavaScript 引擎开发人员,主要从事垃圾收集器和静态分析工作。兼职从事版本发布工程、Mercurial 自定义以及其他各种工作,总之就是个爱捣乱的人。IRC 上“mrgiggles”机器人的作者。
关于 Robert Nyman [荣誉编辑]
Mozilla Hacks 的技术布道师和编辑。会发表关于 HTML5、JavaScript 和开放网络的演讲和博客文章。Robert 是 HTML5 和开放网络的坚定支持者,自 1999 年以来一直从事 Web 前端开发工作——在瑞典和纽约市。他还会定期在 http://robertnyman.com 上发表博客文章,并且喜欢旅行和结识新朋友。
14 条评论