ThornWu/v8-blog-translation

【译】零成本垃圾回收

ThornWu opened this issue · 0 comments

JavaScript 性能一直是 Chrome 价值的一个关键,尤其是在实现流畅体验方面。从 Chrome 41 开始,V8 采用了一项新技术,通过将大开销的内存管理操作隐藏到小的、未使用的空闲时间段内,进而提升 web 应用程序的响应能力。Web 开发人员可以期待着看到更顺滑的滚动和动画体验,因为(新的)垃圾回收方式大大减少了卡顿(jank)。

许多现代语言的引擎,如 Chrome V8 中的 JavaScript 引擎,都会动态地对运行中的程序进行内存管理,这样开发人员就不必自己操心内存问题。引擎定期地检查分配给应用程序的内存,决定哪些数据是不再被需要的,清除它们以腾出空间,这个过程称为垃圾回收。

在 Chrome 中,我们力争实现流畅的、每秒 60 帧的视觉体验。尽管 V8 已经尝试在小块时间段内执行垃圾回收,但时间较长的垃圾回收操作可能并且确实会发生在不可预测的时间 —— 有时候会出现在动画的中间 —— 导致暂停,并阻止 Chrome 达到每秒 60 帧的目标。

Chrome 41 包含了一个为 Blink 渲染引擎准备的任务调度器,能够对延迟敏感的任务设置优先级,以确保 Chrome 快速响应。除了能够对任务的优先级进行排序,这个任务调度器还汇集了系统有多繁忙,需要执行哪些任务,以及这些任务各自有多紧急等信息。因此,它可以预测 Chrome 什么时候处于空闲状态,并估计会有多长时间的空闲。

举个例子,当 Chrome 在 web 页面上显示动画时就会遇到上述情况。动画将以每秒 60 帧的速度刷新屏幕,所以 Chrome 大概有 16.6ms 的时间来执行更新。只要上一帧显示完毕,Chrome 就会马上开始当前帧的工作,为新的帧执行输入响应、动画和帧渲染任务。如果 Chrome 在少于 16.6ms 的时间里完成了所有的这些任务,那么在需要开始渲染下一帧前,剩余的时间内就没有别的事情可以做了。Chrome 的调度器让 V8 能够调度特殊的空闲任务来利用好这段空闲时间。

20190415075623
图1: 帧渲染与空闲任务

空闲任务是当调度器确定处于空闲时期时运行的特殊的、低优先级任务。空闲任务拥有一个截止时间,这是调度器对预计空闲时间长度的估计。在图1 的动画示例中,这会是开始绘制下一帧的时间。在其它情况下(比如,当屏幕上没有活动发生时),这可能是计划运行下一个挂起任务的时间,上限是 50ms,以确保 Chrome 对预期外用户输入的响应。空闲任务根据截止时间来估计可以完成多少工作,而不会引起卡顿或输入响应延迟。

在空闲任务中执行垃圾回收对关键的、延迟敏感的任务来说是透明的。这意味着这些垃圾回收任务是 “零成本” 的。为了理解 V8 是如何做到这一点的,我们有必要回顾一下 V8 当前的垃圾回收策略。

深入 V8 垃圾回收引擎

V8 使用分代的垃圾收集器,JavaScript 堆被分成用于新分配对象的、小的新生代(young generation)堆和用于长寿对象的、大的老生代(old generation)堆。因为大多数对象去世得早,这种分代策略使垃圾回收器能够在较小的新生代(也被称为 scavenges)中定期执行短时间的垃圾回收,而不需要跟踪老生代中的对象。新生代使用半空间(semi-space)分配策略,新对象最初被分配到新生代中活跃的半空间内。一旦半空间变满,清扫(scavenge)操作会把活着的对象移动到另外的半空间。被移动过一次的对象会被提升到老生代,并且会被认为是长寿的对象。一旦存活的对象移动完成,新的半空间就会变为活跃状态,旧的半空间中任何剩余的、去世的对象都会被丢弃。

因此,新生代清扫的时间取决于新生代中存活对象的大小。当新生代中大多数对象变得不可达时,清扫将会很快(< 1ms)。然而,如果大多数的对象在清扫的过程中存活下来,清扫的持续时间可能会明显变长。

当老生代中活动对象的大小超过启发式派生(heuristically-derived)的大小时,垃圾回收将对整个堆进行完整的清理。老生代使用带有许多优化的标记清除回收器(mark-and-sweep collector),以改善延迟和内存消耗。标记延迟取决于需要标记的活动对象的数量,对于大型 web 应用程序,标记整个堆可能需要超过 100ms 的时间。为了避免长时间暂停主线程,很早的时候 V8 就已经拥有了在许多小步骤中增量标记活动对象的能力,目的是保持每个标记步骤的持续时间能够低于 5ms。

在标记之后,通过清理整个老生代的内存,空闲内存可以再次供应用程序使用。该任务由专用的清理线程并发执行。最后,执行内存压缩整理(compaction)以减少老生代中的内存碎片。这个任务可能非常耗时,所以只有在内存碎片成为问题的时候才执行。

总之,有四种主要的垃圾回收任务:

  1. 清扫新生代,通常速度很快
  2. 增量标记器执行标记步骤,运行时间可以是任意长度,取决于每个步骤所需的时间
  3. 完整的垃圾回收,这可能需要很长时间
  4. 主动进行内存整理的完整垃圾回收,这可能需要很长时间,并会清理内存碎片

为了在空闲时间执行这些操作,V8 会向调度程序发布垃圾回收空闲任务。在运行这些空闲任务时,调度程序会为它们提供一个完成任务的截止日期。V8 的垃圾回收空闲时间处理器评估应该执行哪些垃圾回收任务,以减少内存消耗,同时满足截止时间,以避免之后帧渲染的卡顿或输入延迟。

如果应用程序测量到的分配率显示新生代可能会在下一个期望的空闲时间之前变满,垃圾收集器将在空闲任务时期执行新生代的清扫工作。另外,收集器将会计算最近清扫任务所花费的时间,以预测未来清扫任务的持续时间,确保它不会超出空闲任务的截止时间。

当老生代中存活对象的大小接近堆限制时,增量标记将启动。增量标记步骤的长度可以按应标记的字节数线性进行缩放。基于测量到的平均标记速度,垃圾回收空闲时间处理器将把尽可能多的标记工作放入给定的空闲任务时间内。

如果老生代几乎占满,并且分配给回收任务的截止时间对于预期完成回收来说足够长,那么在空闲任务期间将会安排一次完整的垃圾回收任务。基于标记速度乘上已分配的对象数量,对回收导致的暂停时间进行预测。附带额外压缩整理工作的完整垃圾回收任务只会在网页已经空闲了一段相当可观的时间后执行。

性能评估

为了评估在空闲时间运行垃圾回收任务的影响,我们使用 Chrome 的 Telemetry 性能基准测试框架 来衡量热门网站加载时滚动的流畅程度。我们在 Linux 工作站上测试了排名前 25 的网站,并且在 Android 平台的 Nexus 6 手机上测试了典型的移动网站,两个设备都打开了流行的 Web 页面(包括 Gmail,Google Docs 和 YouTube)并滚动几秒钟的内容。Chrome 的目标是将滚动速度保持在每秒 60 帧,以获得流畅的用户体验。

图2 显示了空闲时间内安排的垃圾回收任务所占的百分比。与 Nexus 6 相比,工作站的硬件速度更快,总体空闲时间更多,因此在空闲时间段内安排了更大比例的垃圾回收任务(43%,在 Nexus 6 中为 31%),从而使我们的 Jank 指标提升了大概 7%。

20190416092219
图2: 空闲时间内发生的垃圾回收所占的百分比

除了提高页面渲染的流畅性,这些空闲期还提供了在页面完全空闲时,执行更多积极的垃圾回收操作的机会。最近 Chrome 45 中的改进利用了这一点,大大减少了空闲前台标签页所消耗的内存量。图3展示的是,当 Gmail 中的 JavaScript 堆变得空闲时,它的内存使用率是如何比 Chrome 43 中同样的页面减少大约 45% 的。

视频连接
图3: 最新版本的 Chrome 45(左)和 Chrome 43(右)的 Gmail 内存使用情况

这些改进表明,通过更智能地判断什么时候执行开销较大的垃圾回收操作,可以隐藏垃圾回收导致的暂停。Web 开发人员不再需要担心垃圾回收导致的暂停,即使目标是平稳的 60 帧动画。敬请持续关注我们推进垃圾回收调度带来的更多改进。