概述
压缩是我们 垃圾回收器 的一项新功能,它在 Firefox 38 中发布,可以帮助我们减少 JavaScript 堆中的外部碎片。 目标是总体上使用更少的内存,并能够从更多内存不足的情况中恢复。 到目前为止,我们只为 JavaScript 对象实现了压缩,这些对象是堆中几种垃圾回收单元中的一种。
问题
JavaScript 堆由称为arena 的 4K 内存块组成,每个块都分成固定大小的单元。 不同的 arena 用于分配不同类型的单元;每个 arena 仅包含相同大小和类型的单元。
堆包含各种类型的单元,包括用于 JavaScript 对象、字符串和符号的单元,以及一些内部类型,例如脚本(用于表示 JS 代码单元)、形状(用于确定内存中对象属性的布局)和 jitcode(编译的 JIT 代码)。 在这些类型中,对象单元通常占用的内存最多。
arena 无法在其包含任何活动单元时被释放。 同时分配的单元可能具有不同的生命周期,因此堆最终可能处于包含少量单元的许多 arena 的状态。 同类型的单元可以分配到该空间中,但该空间不能用于不同类型的单元,也不能在内存不足时返回给操作系统。
以下是对堆中一些数据的简化图,显示包含两种不同单元类型的 arena
请注意,如果将 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
部分的屏幕截图,显示“已使用”和“未使用”大小之间的区别
我用启用了压缩 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 中,并重用未使用的空间。 当然,说起来容易做起来难,因为必须更新指向已移动单元的每个指针。 漏掉一个指针就一定会让浏览器崩溃!
此外,这是一个可能很昂贵的操作,因为我们必须扫描许多单元才能找到需要更新的指针。 因此,我们的想法是在内存不足或用户处于非活动状态时才压缩堆。
该算法分为三个阶段
- 选择要移动的单元。
- 移动单元。
- 更新指向这些单元的指针。
选择要移动的单元
我们希望移动最少的数据量,并且希望在不分配更多内存的情况下完成此操作,因为我们可能在没有可用内存时执行此操作。 为此,我们将所有包含空闲空间的 arena 放入一个列表中,按它们包含的空闲单元数量的降序排列。 我们将此列表分成两部分,第一部分是在先前 arena 包含足够的空闲单元以容纳后续 arena 中的已使用单元的第一个位置。 我们将从后续 arena 中移出所有单元。
移动单元
我们从我们未移动的 arena 中分配一个新单元。 上一步确保始终有足够的空间用于此。 然后,我们从原始位置复制数据。
在某些情况下,我们知道单元包含指向自身的指针,并且这些指针会在此时更新。 浏览器可能对某些类型的对象具有外部引用,因此我们也在这里调用可选钩子以允许更新这些引用。
移动单元后,我们使用指向新位置的转发指针更新原始位置,以便我们以后可以找到它。 这也会标记单元,在更新下一阶段的指针时,会向 GC 指示单元已移动。
更新指向已移动单元的指针
这是压缩过程中最苛刻的部分。 通常,我们不知道哪些单元可能包含指向我们已移动单元的指针,因此似乎我们必须遍历堆中的所有单元。 这将非常昂贵。
我们以多种方式降低了这种成本。 首先,请注意堆被分成几个区域(每个浏览器选项卡都有一个区域,以及其他用于系统使用的区域)。 压缩按区域执行,因为通常单元没有跨区域指针(这些指针由单独处理)。 按区域压缩允许我们将总成本分散到许多增量切片中。
其次,并非所有类型的单元都可以包含指向其他所有类型单元的指针(实际上并非所有类型的单元都可以包含指针),因此某些类型的单元可以从搜索中排除。
最后,我们可以将这项工作并行化,并利用所有可用的 CPU 资源。
重要的是要注意,这项工作是我们转向精确栈根植的推动 在此博客文章中描述。 只有当我们知道哪些栈位置是根时,才能移动对象,否则如果碰巧看起来像已移动单元指针,我们可能会覆盖栈上的不相关数据。
调度堆压缩
如前所述,压缩 GC 并非每次收集时都运行。 目前,它由三个事件触发
- 我们内存不足,我们正在执行最后一次尝试释放一些空间
- 操作系统已向我们发送内存压力事件
- 用户已处于非活动状态一段时间(目前为 20 秒)
前两个应该允许我们避免一些内存不足的情况,而最后一个旨在释放内存,而不会影响用户的浏览体验。
结论
希望这解释了压缩 GC 试图解决的问题以及它是如何完成的。
实施压缩 GC 的一个意想不到的好处是,它向我们展示了我们没有正确跟踪单元指针的几个地方。 这样的错误会导致难以重现的崩溃或潜在的安全漏洞,因此这是一项额外的胜利。
未来工作思路
添加压缩是改进我们 GC 的重要一步,但绝不是最终目标。 我们还有几种方法可以继续开发它
目前,我们只压缩对应于 JavaScript 对象的单元,但堆中还有其他几种类型的单元。 移动这些将带来更大的内存节省。
是否可以在事前确定哪些单元包含指向我们要移动的单元的指针? 如果我们拥有这些信息,我们可以降低压缩成本。 一种可能性是在后台扫描堆以确定此信息,但我们需要能够检测到由变异器进行的更改。
当前算法将不同时间分配的单元混合在一起。 具有类似生命周期的单元通常在同一时间分配,因此这可能不是最佳策略。
如果压缩速度足够快,我们可能能够在收集器在堆中看到一定程度的碎片时随时执行它。
如何测量压缩释放的堆空间
要粗略地测量压缩释放了多少空间,您可以执行以下步骤
- 通过导航到 about:config 并将
javascript.options.mem.gc_compacting
设置为 false 来禁用压缩。 - 此时,最好也禁用多进程 Firefox。 这可以在主偏好设置页面中完成。
- 重新启动浏览器并打开一些选项卡。 我使用“重新加载所有选项卡”来打开我上次的所有页面。 等待所有内容加载。
- 打开 about:memory 并通过点击“最小化内存使用量”然后点击“测量”来强制执行完全 GC。 由于内存使用量可能需要一段时间才能稳定下来,因此我重复执行此操作几次,直到我获得一致的数字。
- 请注意总“显式”大小以及
js-main-runtime-gc-heap-committed/unused/gc-things
的大小。 - 通过将
javascript.options.mem.gc_compacting
设置为true,重新启用压缩。此更改无需重启即可生效。 - 再次点击“最小化内存使用量”,然后点击“测量”。
- 比较新的读数和之前的读数。
这并不能提供精确的读数,因为后台可能发生各种事情,但它可以提供一个良好的估计值。
关于Jon Coppeard
软件工程师,主要从事 JavaScript 引擎中的垃圾回收工作。
6 条评论