人人都会AI编程

增量标记、并发回收优化

更新时间:2026-07-11

在介绍完新生代的 Scavenge 算法和老生代的基础标记清除、标记整理后,你会注意到一个关键问题:垃圾回收不是免费的。在回收过程中,引擎需要暂停 JavaScript 的执行(通常被称为“Stop-The-World”),以便安全地遍历对象图、标记活动对象、回收垃圾。对于小内存场景,这种停顿可以忽略不计;但面对大型 Web 应用或 Node.js 服务,堆内存可能达到数百 MB 甚至几 GB,一次完整的 GC 会引发显著的卡顿,严重影响用户体验和请求延迟。

为了解决这一问题,现代 JavaScript 引擎引入了两种核心优化技术:增量标记并发回收。它们的目标是同一个:将一次长时间的全停顿拆分成多个短停顿,或者将回收工作移植到后台线程,尽可能减少主线程的阻塞时间


增量标记:化整为零的标记策略

基础思想
传统的标记阶段是“一口气”完成的:从根对象开始,递归或循环遍历所有可达对象,直到标记结束,期间 JS 线程完全挂起。增量标记的核心是“断点续传”:将标记工作拆分为许多小的增量步骤,每个步骤只标记一部分对象,然后让 JavaScript 事件循环继续执行一会儿,之后再拾起下一段标记任务。通过交替执行标记与应用代码,垃圾回收造成的停顿被分散到多个微小的时间片中,用户几乎感知不到卡顿。

实现挑战与三色标记法
交替执行带来的一个致命困难是:当标记暂停后,JavaScript 代码可能会继续修改对象间的引用关系。可能出现这样的情况:一个已经被引擎走访并标记为“非活动”的对象,突然又被应用程序赋值到某个活跃对象的属性上。如果引擎不采取措施,这个本应存活的对象就会被错误回收,导致致命的程序崩溃。

为了处理这种复杂交互,V8 及大多数现代引擎采用了三色标记法

  • 白色:尚未被标记器访问过的对象。标记结束时,所有仍为白色的对象被视为不可达,等待清除。
  • 灰色:对象自身已被标记,但其引用的子对象还没有被完全探查。灰色对象是标记工作的“待办队列”。
  • 黑色:对象自身及其所有引用的子对象都已被标记。黑色对象不会再次被扫描。

增量标记的执行模型如下:

  1. 标记器从根对象开始,将其标记为灰色。
  2. 每执行完一小段增量,标记器挑选一个灰色对象,将其引用的白色子对象染灰,然后将该对象染黑。
  3. 应用程序(Mutator)在两次增量之间运行,可以执行任意读写操作。
  4. 为了保持正确性,引擎加入了写屏障(Write Barrier)机制。当应用程序执行类似 obj.field = someWhiteObject 的操作时,写屏障会拦截此次写入,如果 someWhiteObject 是白色且处于标记阶段,它会被立即染灰,防止从黑色对象漏标到白色对象而造成误回收。
  5. 循环执行 2-4,直到灰色对象队列为空。

通过三色标记和写屏障,增量标记能够在与主线程交替执行的情况下,仍然保证最终的可达对象都被标记为黑色,不可达对象保持白色,从而安全地进行清除阶段。

实用价值
关于增量标记带来的实际效果,Chrome 团队的公开数据表明,它能够将单次 GC 的最大停顿时间从几十甚至上百毫秒降低到几毫秒的水平。对于需要 60fps 的动画或交互密集型应用而言,这是保证流畅体验的关键。


并发回收:将回收移到后台线程

增量标记虽然将停顿拆分,但标记工作依然和 JavaScript 代码共享同一个主线程,本质上是时分的:总的一个标记阶段所花 CPU 时间并未减少,只是被切碎分散了。为了进一步压榨停顿,现代引擎将目光投向了多线程

并发标记
并发标记允许标记阶段完全运行在一个或多个后台线程中,主线程的 JavaScript 代码可以几乎不受干扰地继续执行。这听起来完美,但面临两个关键难题:

  1. 依旧需要写屏障:和增量标记一样,应用程序(Mutator)在并发标记期间会并发读写对象图。写屏障机制在这里同样必不可少,只不过现在是在跨线程的情况下工作,对线程同步的要求更高。
  2. “停车”的最终同步:并发标记完成后,主线程仍需一个短暂的“原子暂停”来最终确认标记结果,并处理并发期间漏掉的一小部分改动。但这次暂停的时间远短于传统全堵塞标记,通常在亚毫秒级。

并发清除与整理
除了标记,引擎还在尝试将清除和整理阶段也并行化:

  • 并发清除:清除(Sweep)指的是回收那些标记为白色的不可达对象的内存。由于清除操作仅影响垃圾对象,不受主线程应用逻辑的修改干扰(垃圾对象已经不可能被访问),因此清除可以安全地与主线程并发执行。V8 的后台线程会并发清理空闲内存页,将其返还给操作系统。
  • 并发整理:整理(Compact)需要移动存活对象并更新所有引用指向新地址,操作的对象直接关联到活跃代码,因此很难做到完全并发。目前,多数引擎在整理阶段仍需要一个短的独占暂停,但通过将大部分整理工作放在并发线程预处理(例如计算搬迁方案),主线程实际移动对象的时间已被压缩到极低。

并发回收的实际收益
Node.js 服务端场景尤其受益于并发回收。一个正在处理 HTTP 请求的服务器,如果突然触发几十毫秒的 GC 停顿,将直接导致尾部延迟飙升。并发标记与清除使主线程的停顿接近理论最小值,从而降低 P99 延迟抖动,提升服务质量。对浏览器而言,并发回收进一步消除了页面滚动、动画过程中的微卡顿,提升了感知性能。


总结:从全停顿到软实时

如果我们将垃圾回收优化的发展划一条线:

  1. 全停顿:标记和清除都霸占主线程,大堆内存时卡顿明显。
  2. 增量标记:标记切分,卡顿被拆小分散,但总 CPU 占用不变。
  3. 并发标记/清除:大部分工作移交后台线程,主线程停顿降至最短。

三者协同,现代 JavaScript 引擎(尤其是 V8)实现了 “软实时”的垃圾回收:绝大部分时间,GC 工作在后台默默进行,应用程序几乎感觉不到它的存在。虽然偶尔仍需要短暂的“停车”,但已经基本消失于人类感官的阈值之下。

对于开发者来说,理解这些机制主要有两个实际意义:

  • 避免在代码中无意制造突变型长停顿:比如,在动画帧循环中频繁分配大量临时对象,会加剧新生代 GC 压力,即使并发优化也难掩频繁的短停顿。
  • 信任引擎但保持监控:现代 GC 已经非常智能,通常不需要你手动调用 gc() 甚至禁用某些回收算法。通过 Chrome DevTools 的 Performance 面板或 Node.js 的 --trace_gc 标记,可以观察 GC 行为,在出现性能退化时定位是否是大对象常驻、频繁晋升等引起。

增量标记与并发回收是 JavaScript 内存管理从“能跑”走向“跑得丝滑”的关键一步,它们的存在让这门原本单线程的脚本语言,在实际生产环境中也能承载大规模应用的平稳运行。