V8垃圾回收器近两年来的发展

31 阅读8分钟

原文:wingolog.org/archives/20…

让我们聊聊内存管理!继我之前那篇关于V8垃圾回收器5年发展历程的文章之后,今天我想为大家更新一下V8 GC在最近几年的发展情况。

研究方法

我分析了自上次总结以来所有对src/heap目录的提交记录,总共约1600个提交,包括回滚和重新合并的内容。我仔细阅读了所有提交日志、部分代码变更、相关bug报告以及能找到的设计文档。据我了解,在此期间约有4名Google全职工程师参与了这项工作,提交频率相对稳定。虽然偶尔会有来自Igalia、Cloudflare、Intel和Red Hat的贡献,但主要开发工作还是由Google团队完成。

通过我的分析(好吧,其实就是记录整理后思考),我发现V8的GC在这段时间主要聚焦于三个大方向,我将以估算的精力分配比例来介绍:

  1. 通过沙箱提升内存安全性:约占20%的开发时间
  2. Oilpan相关开发:约占40%
  3. 为多JavaScript和WebAssembly工作线程做准备:约占20%

除此之外,还有一些次要工作:启发式算法调优(占比高达10%!!!)以及各种杂项改进。让我们逐个深入探讨这些方向的发展。

沙箱技术

去年6月,Google发布了一篇很好的博客文章,详细总结了沙箱相关工作。沙箱的核心目标是防止用户控制的写入操作破坏JavaScript堆外的内存。我们的前提假设是:攻击者可能以某种方式获得任意内存写入权限,而我们需要尽可能减轻这种写入可能造成的危害。

实现这一目标的最基本方法是缩小可寻址内存范围,具体做法是将指针编码为32位偏移量,并确保攻击者可写入的虚拟内存空间与主机内存完全隔离。对于更大的对象,沙箱还会使用40位偏移量进行引用,以提供类似的安全保证。(是的,沙箱确实需要预留整整1TB的虚拟内存空间)。

然而,实际实现中存在大量细节需要处理。例如,对外部对象的访问必须通过类型检查的外部指针表进行中介;某些绝对不能被用户代码直接引用的对象需要放置在沙箱外的"可信空间"中;还有用于在不同隔离实例间共享数据的只读空间、用于多线程共享内存的"共享"空间变体,以及嵌入对象引用的可执行代码空间等。调整、完善和维护这些复杂细节占用了V8 GC开发人员的大量时间。

不过,这些努力是值得的,因为V8已经成功为沙箱启用了硬件级内存保护:现在硬件会直接阻止沙箱内的代码写入沙箱外的内存空间。

基于"攻击者可以写入其地址空间中任何位置"的威胁模型,开发过程中出现了一些有趣的补丁。例如,代码有时需要检查对象所在页面的标志作为写屏障的一部分,这就要求一些GC管理的元数据必须位于沙箱内。但问题是,运行在沙箱外的垃圾回收器不能直接信任这些元数据的有效性。因此在某些情况下,我们不得不在沙箱内外维护两份状态副本:一份供沙箱内代码使用,另一份供收集器使用。

这种设计最有趣的例子与整数处理有关。按照Google的代码风格指南,默认应使用有符号整数,因此堆上的数据结构中常能看到int32_t len这样的定义。但如果攻击者将长度值覆盖为负数,可能会引发两种有趣的问题:一是运行时代码将其转换为size_t时会进行符号扩展,可能导致沙箱逃逸;二是由于长度意外为负,可能会错误地认为对象很小(因为负数在比较时可能小于某个阈值)。这种安全隐患处理起来相当棘手!

Oilpan开发历程

奥德修斯花了10年时间才从特洛伊返回,而保守栈扫描技术从Oilpan引入V8核心也经历了差不多长的时间。简单来说,Oilpan是Chromium和Blink中使用的C++垃圾收集器。当栈为空时,它可以进行精确的垃圾回收;但当栈上可能存在对GC管理对象的引用时,它必须以保守方式运行。

上次我曾提到,V8团队希望为Oilpan添加代际垃圾收集支持,但要实现这一点,必须找到一种能与保守栈扫描兼容的对象提升方法。我曾认为V8的新型标记-清除托儿所设计可能会成功,但事实证明它的性能不如传统的复制托儿所。他们甚至尝试了粘性标记位代际收集技术,但也未能取得理想效果。

不过,Google的一个优点是他们愿意在必要时放弃行不通的方法,并尝试其他路径。

最终,V8团队成功实现了一种名为"滑动指针"的技术,这使得保守栈扫描能够与代际垃圾回收兼容。这项技术的核心思想是:当对象被提升到老年代时,不是直接移动对象,而是在原位置留下一个转发指针,指向老年代中的新位置。这样,即使保守扫描错误地将栈上的某些值识别为指针,它仍然能够找到正确的对象,无论是在新生代还是老年代。

这项技术看起来非常简单,但实际实现起来相当复杂。例如,需要处理各种边界情况,确保所有引用都能正确更新,同时还要保持良好的性能。不过,现在它已经成功集成到V8中,这是一项重要的成就。

多线程支持

近年来,Web平台对多线程的支持不断增强,特别是通过Web Workers和SharedArrayBuffer。V8垃圾回收器也在为这些功能做准备,尤其是为多个JavaScript和WebAssembly变更线程(mutator threads)提供支持。

多线程垃圾回收是一个极其复杂的问题,因为垃圾回收器需要在不中断应用程序执行的情况下工作,同时还要确保内存安全。V8团队主要在两个方面进行了改进:

  1. 增量垃圾回收的改进:将垃圾回收过程分解为更小的步骤,使得应用程序线程能够与垃圾回收线程交替执行,减少卡顿。

  2. 并发标记:允许垃圾回收器在应用程序继续执行的同时进行标记工作。这需要精心设计的写屏障(write barriers)来跟踪并发执行过程中发生的引用变更。

这些改进使得V8能够更好地支持多线程应用程序,同时保持良好的性能和内存安全性。虽然完全的并行垃圾回收尚未实现,但这些工作为未来的发展奠定了坚实的基础。

启发式算法调优

在所有这些大方向之外,V8团队还花费了大量时间(约10%)在启发式算法调优上。垃圾回收器需要做出许多决策,例如:何时触发垃圾回收?分配多少内存给新生代?是否进行完整的垃圾回收?

这些决策通常基于各种启发式规则,而这些规则需要根据实际应用程序的行为进行调整。V8团队通过分析大量真实世界的应用程序,不断优化这些启发式规则,以适应各种不同的工作负载。

例如,他们改进了内存压力检测机制,使得垃圾回收能够更准确地响应应用程序的内存需求。他们还优化了分配策略,根据对象大小和生命周期特性进行更智能的内存分配。

结语

V8垃圾回收器在过去几年取得了显著的进步,特别是在内存安全性、Oilpan集成和多线程支持方面。虽然这些改进大多是底层的,用户可能不会直接感受到,但它们对于提升JavaScript和WebAssembly应用程序的性能和安全性至关重要。

随着Web平台的不断发展,我们可以期待V8垃圾回收器继续进化,以应对新的挑战和需求。无论是支持更复杂的Web应用程序,还是适应新的硬件架构,V8团队都在不断努力,确保JavaScript引擎能够提供最佳的性能和用户体验。


更多 JavaScript 基础知识的学习,可以学习我写的这本 《JavaScript 语言编程进阶》 小册。