V8 之标记
V8 的垃圾回收(GC)中,标记(Marking) 阶段是整个回收过程的核心,其目标是准确识别堆内存中所有可达对象(存活对象)。V8 采用了一种高效且复杂的标记策略,主要基于 三色标记法(Tri-color Marking) 并结合了 增量标记(Incremental Marking) 和 并发标记(Concurrent Marking) 技术来显著减少 GC 停顿时间(Stop-The-World Pauses)。
核心:三色标记法(Tri-color Marking)
这是一种抽象模型,将堆中的对象根据其标记状态分为三类颜色:
白色(White):
- 初始状态:所有对象在 GC 开始时都是白色。
- 含义:未访问或未标记。GC 结束时,白色对象被视为垃圾,将被回收。
灰色(Grey):
- 含义:对象已被标记为可达,但其直接引用的子对象(属性、元素等)还未被扫描/标记。
- 作用:表示“待处理”。这些对象存放在一个专门的 标记工作列表(Marking Worklist) 中。
黑色(Black):
- 含义:对象已被标记为可达,并且其所有直接引用的子对象也已被标记(即扫描完成)。
- 作用:表示“处理完成”。黑色对象是确定存活的。
标记过程(以老生代回收为例)
初始化(Initialization):
- 所有对象设置为白色。
- 根对象(Roots) 被识别并放入标记工作列表(变成灰色)。根对象包括:
- 全局对象(
window
/global
)。 - 当前执行栈上的所有变量(局部变量、参数)。
- 激活的函数作用域链。
- 编译缓存(字节码、内联缓存)。
- 所有
WeakMap
、WeakSet
中的键(仅用于标记值,不影响键本身存活)。 - (在 Minor GC 中)写屏障记录的跨代引用(来自老生代指向新生代的指针)。
- 全局对象(
标记阶段(Marking Phase):
- 循环处理工作列表:
- 从标记工作列表中取出一个灰色对象。
- 扫描这个对象的所有属性(包括数组元素、Map/Set 条目、原型链等),查找它引用的所有子对象。
- 对于扫描到的每一个子对象:
- 如果该子对象是白色(未标记),则将其标记为灰色(放入工作列表)。
- 如果该子对象已经是灰色或黑色(已标记或在处理中),则跳过。
- 将当前处理的对象标记为黑色(表示它及其所有直接引用都已处理完)。
- 结束条件:当标记工作列表为空时,标记阶段结束。此时所有可达对象都变成了黑色,所有不可达对象仍然是白色。
- 循环处理工作列表:
关键优化技术:减少停顿时间
标记整个堆(尤其是大型应用的老生代)可能非常耗时,导致明显的应用卡顿(Stop-The-World)。V8 采用以下技术解决:
增量标记(Incremental Marking):
- 核心思想:将原本一次性的、长时间的完整标记过程,拆分成许多小的增量步骤,穿插在 JavaScript 主线程的执行间隙中执行(利用事件循环的空闲时间)。
- 如何实现:
- GC 启动增量标记后,每次主线程执行一小段时间 JS 代码(如几毫秒),就暂停 JS 执行,让 GC 执行一小部分标记任务(处理工作列表中的一些灰色对象),然后再恢复 JS 执行。如此反复,直到标记完成。
- 挑战:JS 代码在标记过程中修改了对象图(添加/删除引用),可能导致标记不准确(漏标活对象 - 导致错误回收,或多标死对象 - 影响不大但浪费内存)。
- 解决方案:写屏障(Write Barrier):
- 在 JS 代码写入对象的属性(即修改引用)时,插入一小段特殊的“屏障”代码。
- 当写入操作满足特定条件时(通常是:被写入的对象是黑色,且新写入的值是一个白色对象),写屏障会将这个白色对象直接标记为灰色,并放入标记工作列表。
- 作用:确保在增量标记过程中,新产生的、被黑色对象引用的白色对象不会被错误地当成垃圾回收(满足了强三色不变性:黑色对象不能直接指向白色对象)。这是增量标记正确性的关键保障!
并发标记(Concurrent Marking):
- 核心思想:在后台线程(Worker Threads) 上执行大部分标记工作,与 JavaScript 主线程并发执行(几乎不阻塞主线程)。
- 如何实现:
- V8 启动一个或多个专用的后台线程。
- 主线程负责扫描根对象(快照),并将其放入共享的标记工作列表。
- 后台线程不断从工作列表中取出灰色对象,扫描其引用,标记子对象(白变灰),并将其自身变黑。新发现的灰色对象放回工作列表。
- 挑战:并发执行时,JS 主线程可能同时修改对象图(添加/删除引用),导致后台标记线程看到不一致的对象图状态。
- 解决方案:
- 写屏障(Write Barrier again):同样至关重要!当主线程修改引用时(如设置属性),如果可能破坏标记正确性(如使黑色对象指向白色对象),写屏障会将被引用的白色对象标记为灰色(并记录到工作列表),或者将修改前的引用关系记录下来供后台线程处理。
- 原子操作与内存屏障:确保标记线程和主线程对标记位(颜色)的读写操作是原子的,并且内存可见性得到保障(使用内存屏障指令)。
- 保守处理与重新扫描:并发标记结束时,主线程需要短暂停顿,执行一次最终标记(Finalization Marking)。此时主线程会:
- 再次快速扫描根对象(可能发生了变化)。
- 处理写屏障在并发阶段积累的所有记录(
Marking Deque
或Remembered Sets
)。 - 确保所有在并发期间被修改的对象及其影响都被正确处理,完成最终的标记状态确认。这个停顿比一次完整标记的停顿短得多。
其他标记优化:
- 位图标记(Bitmap Marking):对象头中不直接存储颜色标记位,而是在独立的内存位图中存储标记信息。这提高了缓存局部性(GC 扫描时连续访问位图),也便于并发标记时减少对对象内存的争用。
- 懒惰标记(Lazy Sweeping):标记完成后,清除(Sweep)阶段不立即执行,而是延迟到需要分配内存且空闲内存不足时,按需清理内存页。减少一次性停顿。
- 并行标记(Parallel Marking):在多个后台线程上并行执行标记任务(处理工作列表的不同部分),充分利用多核 CPU。这通常与并发标记结合使用。
总结 V8 的标记过程
- 基于三色抽象:白(未处理)、灰(待处理)、黑(已处理完)。
- 广度优先遍历:通过工作列表实现,高效遍历对象图。
- 增量标记:拆分成小任务穿插在 JS 执行中,依赖写屏障保证正确性,大幅减少感知停顿。
- 并发标记:后台线程执行大部分标记工作,与主线程并发,极度依赖写屏障和原子操作保证一致性和正确性,最小化主线程停顿。
- 优化手段:位图标记、懒惰清除、并行标记等进一步提升效率和吞吐量。
正是通过这些精妙的设计(尤其是三色标记+写屏障+增量/并发),V8 能够在保证垃圾回收正确性的前提下,将 GC 对 JavaScript 应用性能的影响(尤其是卡顿)降到最低,支撑了现代高性能 Web 应用和 Node.js 的运行。