SpiderMonkey 中的压缩垃圾回收

概述

压缩是我们 垃圾回收器 的一项新功能,它在 Firefox 38 中发布,可以帮助我们减少 JavaScript 堆中的外部碎片。 目标是总体上使用更少的内存,并能够从更多内存不足的情况中恢复。 到目前为止,我们只为 JavaScript 对象实现了压缩,这些对象是堆中几种垃圾回收单元中的一种。

问题

JavaScript 堆由称为arena 的 4K 内存块组成,每个块都分成固定大小的单元。 不同的 arena 用于分配不同类型的单元;每个 arena 仅包含相同大小和类型的单元。

堆包含各种类型的单元,包括用于 JavaScript 对象、字符串和符号的单元,以及一些内部类型,例如脚本(用于表示 JS 代码单元)、形状(用于确定内存中对象属性的布局)和 jitcode(编译的 JIT 代码)。 在这些类型中,对象单元通常占用的内存最多。

arena 无法在其包含任何活动单元时被释放。 同时分配的单元可能具有不同的生命周期,因此堆最终可能处于包含少量单元的许多 arena 的状态。 同类型的单元可以分配到该空间中,但该空间不能用于不同类型的单元,也不能在内存不足时返回给操作系统。

以下是对堆中一些数据的简化图,显示包含两种不同单元类型的 arena

heap layout smallest

请注意,如果将 arena 3 中的空闲空间用于容纳 arena 5 中的单元,我们可以释放整个 arena。

测量浪费的堆空间

您可以通过导航到 about:memory 并点击“测量”按钮来查看这些空闲单元占用了多少内存。 不同单元类型的总数显示在 js-main-runtime-gc-heap-committed/unused/gc-things 部分下。(如果您不习惯解释 about:memory 报告,这里有一些 文档)。

以下是禁用压缩 GC 后 js-main-runtime-gc-heap-committed 部分的屏幕截图,显示“已使用”和“未使用”大小之间的区别

unused heap screenshot

我用启用了压缩 GC 和未启用压缩 GC 的状态对我的正常浏览配置文件进行了粗略的测量(有关如何执行此操作的详细信息请见文章末尾)。 该配置文件包括 Google 邮件、日历、许多 bugzilla 选项卡和其他各种选项卡(总共约 50 个选项卡),我获得了以下读数

总显式分配 未使用的单元
压缩前 1,324.46 MiB 69.58 MiB
压缩后 1,296.28 MiB 40.18 MiB

这表明显式分配减少了 29.4 MiB (mebibytes)。 这仅占总分配的约 2%,但占 JS 堆所占空间的 8% 以上。

压缩如何运作?

为了释放该空间,我们必须允许 GC 在 arena 之间移动单元。 这样它就可以将活动单元合并到更少的 arena 中,并重用未使用的空间。 当然,说起来容易做起来难,因为必须更新指向已移动单元的每个指针。 漏掉一个指针就一定会让浏览器崩溃!

此外,这是一个可能很昂贵的操作,因为我们必须扫描许多单元才能找到需要更新的指针。 因此,我们的想法是在内存不足或用户处于非活动状态时才压缩堆。

该算法分为三个阶段

  1. 选择要移动的单元。
  2. 移动单元。
  3. 更新指向这些单元的指针。

选择要移动的单元

我们希望移动最少的数据量,并且希望在不分配更多内存的情况下完成此操作,因为我们可能在没有可用内存时执行此操作。 为此,我们将所有包含空闲空间的 arena 放入一个列表中,按它们包含的空闲单元数量的降序排列。 我们将此列表分成两部分,第一部分是在先前 arena 包含足够的空闲单元以容纳后续 arena 中的已使用单元的第一个位置。 我们将从后续 arena 中移出所有单元。

移动单元

我们从我们未移动的 arena 中分配一个新单元。 上一步确保始终有足够的空间用于此。 然后,我们从原始位置复制数据。

在某些情况下,我们知道单元包含指向自身的指针,并且这些指针会在此时更新。 浏览器可能对某些类型的对象具有外部引用,因此我们也在这里调用可选钩子以允许更新这些引用。

移动单元后,我们使用指向新位置的转发指针更新原始位置,以便我们以后可以找到它。 这也会标记单元,在更新下一阶段的指针时,会向 GC 指示单元已移动。

更新指向已移动单元的指针

这是压缩过程中最苛刻的部分。 通常,我们不知道哪些单元可能包含指向我们已移动单元的指针,因此似乎我们必须遍历堆中的所有单元。 这将非常昂贵。

我们以多种方式降低了这种成本。 首先,请注意堆被分成几个区域(每个浏览器选项卡都有一个区域,以及其他用于系统使用的区域)。 压缩按区域执行,因为通常单元没有跨区域指针(这些指针由单独处理)。 按区域压缩允许我们将总成本分散到许多增量切片中。

其次,并非所有类型的单元都可以包含指向其他所有类型单元的指针(实际上并非所有类型的单元都可以包含指针),因此某些类型的单元可以从搜索中排除。

最后,我们可以将这项工作并行化,并利用所有可用的 CPU 资源。

重要的是要注意,这项工作是我们转向精确栈根植的推动 在此博客文章中描述。 只有当我们知道哪些栈位置是根时,才能移动对象,否则如果碰巧看起来像已移动单元指针,我们可能会覆盖栈上的不相关数据。

调度堆压缩

如前所述,压缩 GC 并非每次收集时都运行。 目前,它由三个事件触发

  • 我们内存不足,我们正在执行最后一次尝试释放一些空间
  • 操作系统已向我们发送内存压力事件
  • 用户已处于非活动状态一段时间(目前为 20 秒)

前两个应该允许我们避免一些内存不足的情况,而最后一个旨在释放内存,而不会影响用户的浏览体验。

结论

希望这解释了压缩 GC 试图解决的问题以及它是如何完成的。

实施压缩 GC 的一个意想不到的好处是,它向我们展示了我们没有正确跟踪单元指针的几个地方。 这样的错误会导致难以重现的崩溃或潜在的安全漏洞,因此这是一项额外的胜利。

未来工作思路

添加压缩是改进我们 GC 的重要一步,但绝不是最终目标。 我们还有几种方法可以继续开发它

目前,我们只压缩对应于 JavaScript 对象的单元,但堆中还有其他几种类型的单元。 移动这些将带来更大的内存节省。

是否可以在事前确定哪些单元包含指向我们要移动的单元的指针? 如果我们拥有这些信息,我们可以降低压缩成本。 一种可能性是在后台扫描堆以确定此信息,但我们需要能够检测到由变异器进行的更改。

当前算法将不同时间分配的单元混合在一起。 具有类似生命周期的单元通常在同一时间分配,因此这可能不是最佳策略。

如果压缩速度足够快,我们可能能够在收集器在堆中看到一定程度的碎片时随时执行它。

如何测量压缩释放的堆空间

要粗略地测量压缩释放了多少空间,您可以执行以下步骤

  1. 通过导航到 about:config 并将 javascript.options.mem.gc_compacting 设置为 false 来禁用压缩。
  2. 此时,最好也禁用多进程 Firefox。 这可以在主偏好设置页面中完成。
  3. 重新启动浏览器并打开一些选项卡。 我使用“重新加载所有选项卡”来打开我上次的所有页面。 等待所有内容加载。
  4. 打开 about:memory 并通过点击“最小化内存使用量”然后点击“测量”来强制执行完全 GC。 由于内存使用量可能需要一段时间才能稳定下来,因此我重复执行此操作几次,直到我获得一致的数字。
  5. 请注意总“显式”大小以及 js-main-runtime-gc-heap-committed/unused/gc-things 的大小。
  6. 通过将javascript.options.mem.gc_compacting设置为true,重新启用压缩。此更改无需重启即可生效。
  7. 再次点击“最小化内存使用量”,然后点击“测量”。
  8. 比较新的读数和之前的读数。

这并不能提供精确的读数,因为后台可能发生各种事情,但它可以提供一个良好的估计值。

关于Jon Coppeard

软件工程师,主要从事 JavaScript 引擎中的垃圾回收工作。

更多 Jon Coppeard 的文章…


6 条评论

  1. Robert Knight

    SM 中的当前 GC 状态与其他 JS 引擎相比如何?有什么有趣的区别吗?

    2015 年 7 月 7 日 下午 12:57

    1. Jon Coppeard

      我们的 GC 基本上与 V8 的相似:两者都是分代标记和清除收集器,并且都支持增量收集、并发清除和压缩。一个区别是 V8 使用半空间托儿所,我们尝试过,但没有看到多少改进。

      微软的 Chakra 引擎是一个保守的标记和清除收集器,并且同时支持并发标记和清除。苹果的 JavaScriptCore 使用一个分代的主要是复制的收集器。与 SpiderMonkey 和 V8 不同,它们都是保守的收集器,而 SpiderMonkey 和 V8 则是精确的。

      但这只是 GC 算法。我相信 GC 与浏览器其余部分的交互细节对于不同的浏览器来说是相当不同的——例如,Firefox 也有一个循环收集器,它处理 GC 向其提供信息的 C++ 对象。

      2015 年 7 月 8 日 上午 04:58

  2. xunxun

    // 用户已在一段时间内(目前为 20 秒)处于非活动状态

    如何在 about:config 或源代码中修改数字 20?

    2015 年 7 月 12 日 下午 17:30

    1. Jon Coppeard

      这在 about:config 中不可配置,而是来自用户被声明为非活动状态之前最后一次用户输入后的时间(NS_USER_INTERACTION_INTERVAL,5 秒)加上启动压缩 GC 之前的另一个延迟(NS_SHRINKING_GC_DELAY,15 秒)。

      这些定义在源代码中的这里
      https://dxr.mozilla.org/mozilla-central/source/dom/base/nsJSEnvironment.cpp#114
      https://dxr.mozilla.org/mozilla-central/source/dom/events/EventStateManager.cpp#104

      如果您想更改压缩 GC 的运行频率,您应该更改后者。

      2015 年 7 月 13 日 上午 02:05

  3. Luke

    这很有趣,我认为本文中提到的所有内容都在 Mozilla 的 C 代码中,而不是 JS 代码中?是否有编写 JS 的方法可以为这种类型的 gc 算法带来任何优势?

    2015 年 7 月 28 日 下午 18:25

    1. Jon Coppeard

      垃圾收集器是用 C++ 实现的,它是 JS 引擎 SpiderMonkey 的一部分。

      编写 JS 时,有一件事会有所帮助,那就是尝试分配具有相似生命周期的对象,并避免将它们与具有非常不同生命周期的对象交织在一起。这样做意味着我们首先不需要进行那么多压缩(假设所有这些对象都存活下来了)。

      总的来说,最好的做法是编写惯用的 JS,让收集器来解决它。尝试调整到特定的实现总是存在使代码在其他实现中表现更差的风险。

      2015 年 7 月 29 日 上午 03:25

本文的评论已关闭。