Firefox 中的分代垃圾回收

分代垃圾回收 (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 可能带来的各种好处。该基准测试有点像“向量斐波那契”计算,它计算二维向量的xy分量的斐波那契数列。该脚本在每次迭代时分配一个临时对象。它首先在(终身区)堆几乎为空的情况下计时循环,然后构建一个大型对象图,旨在将其放置在终身区部分的堆中,然后再次计时循环。

在我的笔记本电脑上,基准测试显示 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”机器人的作者。

更多 Steve Fink 的文章…

关于 Robert Nyman [荣誉编辑]

Mozilla Hacks 的技术布道师和编辑。会发表关于 HTML5、JavaScript 和开放网络的演讲和博客文章。Robert 是 HTML5 和开放网络的坚定支持者,自 1999 年以来一直从事 Web 前端开发工作——在瑞典和纽约市。他还会定期在 http://robertnyman.com 上发表博客文章,并且喜欢旅行和结识新朋友。

更多 Robert Nyman [荣誉编辑] 的文章…


14 条评论

  1. StyMaar

    干得好!
    感谢这项技术,Firefox 目前比 Google Chrome 快 4 倍,比 IE11 快 40 倍(使用您的基准代码)。太棒了!

    但我认为目前的垃圾收集机制并不完美,因为它在脚本结束后不会释放内存(如果您使用更大的数字,比如我在这里使用的大小,就会更明显:http://jsbin.com/yipihi/1/edit)

    我已经在此处提交了一个错误报告:https://bugzilla.mozilla.org/show_bug.cgi?id=1073499

    2014 年 9 月 26 日 下午 05:52

    1. Robert Nyman [编辑]

      感谢您的反馈和错误报告!

      2014 年 9 月 26 日 下午 07:49

  2. Ed Murphy

    这使得我的 Conway’s Life 细胞自动机以 60 fps 的速度流畅运行。以前版本会受到 GC 暂停的影响。我想许多游戏将从这项改进中受益。谢谢。

    2014 年 9 月 29 日 上午 09:07

  3. Thorsten

    “GGC 只是一个性能优化,不应影响脚本行为。”

    哦,但它确实影响了。我尝试制作的小游戏运行得明显更流畅。非常感谢。

    2014 年 9 月 30 日 上午 06:12

  4. Dawson Loudon

    SDK News 正在窃取您的内容并声称是他们写的:http://www.sdknews.com/firefox-os/generational-garbage-collection-in-firefox

    2014 年 10 月 1 日 上午 07:38

    1. Robert Nyman [编辑]

      感谢您提醒我们。这个问题已经在 Twitter 上得到解决,他们将发布对原始文章的适当引用。

      2014 年 10 月 1 日 上午 08:01

  5. eaglgenes101

    如果您可以拥有 2 级 GC,那么是什么阻止您拥有 3 级?或 4 级?在什么时候,拥有更多垃圾收集级别会比它帮助延迟更多地损害吞吐量?

    2014 年 10 月 5 日 下午 15:54

    1. Steve Fink

      您在评论中提出的所有建议都是正确的。您当然可以添加更多级别的 GC,许多系统都这样做了。但在所有这些方案中,您都需要跟踪跨代指针,这些指针可以在您写入 GC 指针时随时添加(和删除),这意味着您需要实现越来越复杂的写屏障(即,您必须在每次写入时运行的额外代码,以检查您写入的指针是否指向不同的代)。您还需要在某处存储所有这些跨代指针的记录。因此,收益递减,是否合理取决于工作负载。我们确实研究过这个问题,但目前感觉收益还不清楚,而且会增加很多复杂性。目前,我们还有很多事情要做,比如将现有的实现完善,以便充分利用我们已经实现的功能。如果我们最终发现瓶颈,至少在某些情况下,可以通过添加更多代来解决,那么我们将重新考虑这个问题。(我们也有一位研究人员的论文证明,在他研究的系统中,添加这种复杂性会造成净损失。他的结果可能并不完全适用于我们的系统,但这是一个巨大的警示信号。)

      目前,我们更关注的是提高并行性和并发性,以及更好的调度。

      2014 年 10 月 6 日 上午 09:50

  6. Naman Patel

    这篇文章很好地解释了 GGC 的目的。但育苗区和成熟区堆是否按标签分配,以便可以并行进行 GC?(只是进一步优化)

    2014 年 10 月 12 日 上午 03:45

    1. Steve Fink

      有点像。它不完全按标签分配,但对象会分配到特定的“隔间”,隔间会分组到“区域”,这些区域大致对应于标签。我们可以并且确实可以分别收集区域(一次收集一个区域或任何区域子集),但 Gecko 嵌入不会完全跟踪每个区域的基础信息,因此我们仍然需要进行一些全局收集来确保清理通过 C++ 对象的循环。决定收集哪些区域是我们一般 GC 调度的另一个问题,它……呃……我们只能说它还有改进的空间。:-)

      2014 年 10 月 13 日 上午 09:13

      1. Naman Patel

        感谢您提供这些宝贵的信息。我从您的回复中了解到,每个区域的 GGC 可以由一个映射到标签的线程执行(哪些区域需要收集可以进一步优化)。而循环收集器必须在全局范围内运行。

        而在分配方面,这意味着育苗区堆的分配不需要 JEMalloc,而成熟区堆的分配仍然使用 JEMalloc,它需要对已使用和空闲区域进行复杂的映射吗?

        2014 年 10 月 13 日 上午 10:44

        1. Terrence Cole

          并不完全正确。

          GGC 堆是每个运行时而不是每个标签。只有主要的 GC 可以按标签进行。这里实际上没有涉及任何线程:我们仍然是停止世界收集器。

          我们没有为 GC 堆使用 JEMalloc;我们直接从操作系统映射/VirtualAlloc 内存块,并拥有自己的管理结构,这些结构针对 JS 的需求进行了优化。但是,GC 堆中的对象可能会使用 JEMalloc 来存储更多数据,如果它们变得很大。

          2014 年 10 月 17 日 下午 11:21

        2. Steve Fink

          我说的不是线程。在 e10s 项目中,您可能会有单独的线程,但目前即使这样也只是一个针对所有标签的单线程。(将标签拆分为多个线程显然是一个相对容易的后续步骤,但目前他们只是让>1 个线程正常工作。)但能够收集区域子集仍然非常有用——既提高了延迟,也提高了吞吐量。您不会浪费时间标记或清除大量非活动区域。

          (我忘了提交这条评论,所以 Terrence 同时回复了关于分配的信息。太好了!)

          2014 年 10 月 17 日 下午 11:43

          1. Naman Patel

            @Terrence:感谢您纠正我对每个标签的 GC 和堆分配的理解。我曾在某个地方读到,堆碎片出现在成熟区堆中,它由 mmap/VirtualAlloc 分配,因此必须是外部碎片。育苗区中不会出现碎片,因为在次要 GC 之后,育苗区可以被清除。只是好奇,当我们需要比最初从操作系统获取的内存更多的堆内存时会发生什么?育苗区和成熟区堆的大小是如何决定的?
            @Steve:这意味着堆将被划分为区域,然后进一步划分为隔间吗?Firefox 使用哪种 GGC 形式?

            感谢您抽出时间。

            2014 年 10 月 21 日 上午 05:22

本文评论已关闭。