在前面的小节中,我们已经了解了 V8 如何通过分代回收来处理新生代和老生代的内存。新生代的 Scavenge 算法速度很快,停顿时间极短;但老生代使用标记-清除和标记-整理时,如果一次性地扫描整个堆内存,停顿时间可能会达到上百毫秒——这对一个正在处理数千个并发请求的 Node.js 服务来说是完全不可接受的,因为整个 JavaScript 执行都会被暂停,所有请求都会在这段时间内被阻塞。
因此,现代 V8 引擎在老生代回收中引入了增量标记和并发标记/并发清扫等一系列优化,目的就是打散单次 GC 的停顿时间,让垃圾回收与 JavaScript 代码交替或并行执行,从而将长时间的全停顿变为若干次短停顿,甚至完全不让用户代码感知到停顿。
从全停顿到增量标记
我们先设想一个最简单的标记-清除流程:GC 从根对象开始,遍历整个对象图,将所有存活对象标记出来,然后一次性清扫掉未标记的对象。在一个内存几百 MB 甚至上 GB 的 Node.js 进程中,这个过程可能需要几十到几百毫秒。在这期间,JavaScript 代码完全无法执行,所有的请求处理都会挂起,客户端的响应延迟会出现一个明显的“毛刺”。
增量标记的策略就是:不要把标记整个堆当成一个不可中断的原子操作,而是拆分成很多个小步骤,每一个小步骤只标记一部分对象,然后就让出控制权给 JavaScript 执行,之后再标记下一部分。这样,一次完整的标记被分解成了很多个“增量步”(incremental step),每一步的停顿时间通常只有几毫秒甚至更短,分布在多个事件循环的 tick 中。
为了实现这种增量式标记,V8 采用了三色标记法:
- 白色:尚未被标记的对象。在回收结束时,白色对象就是不可达的,将会被清除。
- 灰色:对象自身已被标记,但其引用的子对象还未被扫描。灰色对象是标记工作的“待办项”。
- 黑色:对象自身及所有子对象都已被标记,不再需要处理。
增量标记的过程大致是:
- 初始时,所有对象都是白色。GC 从根对象出发,把根直接引用的对象标记为灰色,放入一个工作队列。
- 在每一步增量中,GC 从队列中取出一个灰色对象,将其标记为黑色,然后遍历该对象的字段,将字段引用的对象标记为灰色并加入队列。这一步完成后,立即暂停 GC,让 JS 继续运行。
- JS 在执行过程中可能会修改对象之间的引用关系。例如,将一个黑色对象某个字段指向一个白色对象,或者将某个字段置为 null。如果不加处理,这个白色对象可能会被错误地回收。
- 为了处理这种并发修改,V8 使用了写屏障:当 JS 代码执行写操作时,如果将一个黑色对象的字段指向一个白色对象,写屏障会将该白色对象重新标记为灰色,保证它不会被遗漏。
通过这种机制,增量标记可以在多个短暂的停顿中完成,每步停顿只持续几毫秒,整个标记过程的总停顿时间被分摊到较长的时段内,服务对客户端的响应更加平滑。
并发标记:让标记工作完全脱离主线程
增量标记还是一种“交替执行”模式,主线程在 JS 执行和标记工作之间来回切换,仍然需要让出时间片。并发标记则更进一步:它将标记任务放到一个或多个后台线程中执行,主线程几乎不需要因为标记而停顿,只有极短暂的同步操作(比如获取根集合的初始快照和结束时的同步)。
在并发标记模式下,JS 主线程继续执行用户代码,后台线程并发地遍历对象图,标记存活对象。同样,写屏障保证了主线程在修改对象图时,标记线程能够感知到变化,不会漏标存活对象。
并发标记极大地减少了主线程的停顿时间,因为主线程只需要在并发标记开始和结束时进行少量的同步工作,其余大部分标记工作都不再阻塞 JS 执行。这也是为什么在生产环境中,Node.js 应用的 GC 停顿通常可以控制在几毫秒范围之内。
并发清扫与惰性清扫
标记完成后,还需要清除未标记(白色)的对象,回收它们占用的内存。清扫阶段同样可以被优化:
- 惰性清扫:不一次性清扫所有未标记对象,而是在后续内存分配时,按需地清扫页面(Page)。当需要分配新对象时,分配器会检查当前页面上是否有未标记对象需要清扫,如果有就立即清扫出一部分空闲空间,然后进行分配。这样清扫的开销被分摊到多次分配操作中,避免了集中的停顿。
- 并发清扫:可以在后台线程中并行地执行清扫工作,释放内存,而主线程几乎不参与。
通过增量标记、并发标记和并发清扫的组合,现代 V8 引擎在老生代的垃圾回收中实现了极低的停顿。对于一个典型的 Node.js Web 服务,即使堆内存达到数百 MB 甚至 1 GB,经过这些优化后的 GC 停顿也能维持在几毫秒到十几毫秒之间,不会对用户体验造成显著影响。
实际中如何观察与调优
在 Node.js 中,你可以通过一些启动参数来观察 GC 的行为:
node --trace-gc app.js # 每次 GC 时打印简要信息
node --trace-gc-verbose app.js # 打印详细的 GC 步骤和时间
输出中,你会看到类似 Mark-sweep 这样的老生代回收,以及 Scavenge 新生代回收。每条记录会包括回收前后的堆大小、停顿时间等。通过这些日志,你可以判断 GC 是否成为性能瓶颈。如果老生代 GC 频繁触发且停顿时间较长,通常意味着内存使用压力过大,可能的原因包括:
- 内存泄漏,导致对象不断堆积。
- 缓存策略不当,对象存活时间过长,导致老生代被占满。
- 单个请求处理过程中产生了大量临时对象,新生代 GC 压力巨大。
针对 GC 优化,开发者通常不需要去调整 V8 的 GC 参数(如 --max-old-space-size 限制老生代大小,或 --max-semi-space-size 调整新生代大小,除非确实需要)。更有效的方式是从代码层面入手:
- 避免频繁创建大对象(如大字符串拼接),改用 Buffer 或流式处理。
- 及时解除不必要的事件监听和对大对象的引用。
- 使用对象池复用高频对象,减少新生代 GC 频率。
- 确保
async/await调用链中的异常被捕获,避免未完成的 Promise 持有大量闭包引用。
小结
增量标记和并发回收优化,是 V8 引擎能够胜任高并发服务端场景的关键技术之一。它们将原本可能长达数百毫秒的 GC 停顿,分解成几乎无感知的微停顿,在开发者不进行任何特殊配置的情况下,就能让 Node.js 应用平稳地应对大量并发请求。尽管如此,理解这些机制的最大意义在于:帮助我们写出对 GC 友好的代码,从而进一步降低停顿风险,让服务运行得更加平稳高效。