人人都会AI编程

6.2 垃圾回收(GC)机制

更新时间:2026-07-11

在 6.1 节中,我们了解了 V8 引擎将内存划分为新生代和老生代两个主要区域,这种分代式设计本身就是为垃圾回收服务的。Node.js 应用能够长时间稳定运行,很大程度上依赖 V8 的自动内存管理——开发者只需创建和使用对象,由 GC 负责找出不再被引用的数据并释放其占用的空间。但这套机制并非免费:不合理的对象分配或持有方式仍会导致内存泄漏,而 GC 自身也需要消耗 CPU 时间。理解 GC 的工作原理,是写出内存高效应用、避免性能抖动的前提。

6.2.1 分代回收的基本思路

V8 的 GC 基于一个统计观察:绝大多数对象都是“朝生夕死”的。临时变量、局部计算结果只活很短时间,而全局配置、长期缓存等则存活很久。因此,V8 将堆内存分为两个区域:

  • 新生代(Young Generation):存放存活时间短的新对象,空间较小(通常约 4–32 MB)。
  • 老生代(Old Generation):存放存活时间长的对象,空间较大,可达到几十 MB 甚至数 GB。

这种划分能让 GC 针对不同区域采用不同算法:新生代回收频率高但算法快速轻量,老生代回收频率低但算法更彻底。两个世代之间的移转通过晋升(Promotion)机制完成:当新生代中的一个对象在两次 GC 后仍然存活,它就会被移动到老生代。

6.2.2 新生代回收:Scavenge 算法

新生代垃圾回收采用 Scavenge 算法,是一种典型的“空间换时间”的半空间复制(Semi-space Copying)方式。V8 将新生代划分为两个等大的半空间:

  • From 空间:当前对象分配区域。
  • To 空间:空闲区域,始终处于待接收状态。

当新生代 From 空间被填满时,触发一次 小回收(Minor GC)。过程如下:

  1. GC 从根集合(全局对象、当前栈变量等)开始,通过引用链遍历所有可达对象。
  2. 对于每个可达对象,将其 复制 到 To 空间中,并在原位置留下转发地址(避免重复复制)。
  3. 遍历结束后,To 空间内存活对象紧密排列,无碎片。From 空间整体清空,然后 From 和 To 角色互换。

Scavenge 算法的优点在于 只处理存活对象,不触碰死亡对象,因此速度很快,停顿时间极短。其代价是需要额外的 To 空间(物理内存被一分为二),且只能处理较小的新生代区域。当对象大小超过一定阈值(通常在 1 MB 左右)或新生代空间不足以容纳它时,对象会直接创建在老生代中。

6.2.3 老生代回收:标记-清除与标记-整理

老生代中存放了大量长时间存活的对象,Scavenge 算法不再适用——复制大量长寿命对象的开销太大,且 To 空间会很快被填满。老生代回收(大回收,Major GC)使用以下算法组合:

标记-清除(Mark-Sweep)

这是老生代 GC 的主体阶段。它分为两步:

  • 标记阶段(Mark):GC 同样从根集合出发,遍历所有可达对象并打上标记。这一步是“停止世界”(Stop-The-World)的,即暂停 JavaScript 主线程执行。
  • 清除阶段(Sweep):线性遍历整个老生代内存,将没有任何标记(即不可达)的对象空间释放回空闲链表。由于清除过程只扫描已分配内存而无需移动对象,可以利用多线程并行处理,减少主线程停顿。

标记-清除的缺点是会产生内存碎片:被回收的对象散落在老生代各处,留下大小不一的空闲块,可能导致后续大对象无法分配而提前触发 GC。

标记-整理(Mark-Compact)

为了缓解碎片问题,V8 在必要时会执行 标记-整理,即在标记阶段后,将所有存活对象向内存一端移动,使它们连续排列,另一端形成一大块连续空闲空间。整理阶段必须移动对象,因此会产生额外的 CPU 开销和更长的停顿时间。V8 会智能判断碎片程度来自动选择是仅做清除还是进一步整理,优先采用避免停顿的清除,只有在碎片严重时才触发整理。

6.2.4 增量标记与并发优化

传统的“全量标记-清除”在大型堆上会造成长达几十到上百毫秒的主线程暂停,对实时性有要求的应用会因此出现延迟尖峰。为了降低 GC 停顿对用户体验的影响,V8 引入了一系列优化措施:

增量标记(Incremental Marking)

将标记阶段拆分成多个小片段,与 JavaScript 执行交替进行。每一小步标记一小部分对象后,主线程可以继续执行应用代码,然后再进行下一步标记。这样就将一次长停顿分散为多次极短的停顿(通常在 1 毫秒以下),使 GC 的影响肉眼不可见。增量标记需要额外的“写屏障”机制来追踪标记期间被修改的对象引用,代价是总标记时间会略有增加。

并发标记(Concurrent Marking)

标记阶段最耗时的部分(如遍历对象图)可以在后台线程中并行执行,而不需要暂停主线程。主线程只在标记开始时进行一次短时间暂停来获取根集合的快照,之后标记工作全部由工作线程完成。这进一步减小了主线程的停顿时间。

并发清除(Concurrent Sweeping)与并发整理

清除阶段同样可以利用后台线程并发执行,减少主线程参与。对于整理阶段,最新的 V8 版本也部分实现了并发移动对象的能力,进步空间仍在持续拓展。

当前 V8 的垃圾回收已经能做到在大多数情况下将主线程停顿控制在几毫秒以下,保持应用的高响应性。

6.2.5 内存限制与调优参数

尽管 V8 的 GC 机制已经非常高效,但开发者在实际部署时仍需了解 Node.js 进程的内存限制及相关调优参数,以避免 GC 成为性能瓶颈。

默认堆大小限制

  • 在 64 位系统上,Node.js 默认的老生代堆大小约为 1.4 GB(新生代约 64 MB)。这一限制是为了确保 GC 停顿时间可控。
  • 如果应用进程内存接近此限制,GC 会频繁触发并消耗大量 CPU,最终可能抛出 JavaScript heap out of memory 错误。

调整堆内存大小:可以在启动时通过 V8 参数放宽此限制:

# 设置老生代最大堆大小为 4 GB
node --max-old-space-size=4096 app.js

对于内存消耗较大的应用(如缓存大量数据、处理大文件),适度提高堆大小是必要的。但需要留意:堆越大,单次 GC 扫描的时间越长,停顿也越明显。还需结合服务器的物理内存,避免因内存不足引起操作系统层面的换页。

GC 日志与监控

可以通过 --trace-gc 参数输出每次 GC 的简要信息,用于分析 GC 频率和耗时:

node --trace-gc app.js

输出示例:

[7387:0x5f14a10]  32145 ms: Scavenge 4.6 (5.5) -> 4.2 (5.8) MB, 1.2 / 0.0 ms
[7387:0x5f14a10]  33512 ms: Mark-sweep 8.7 (9.1) -> 7.2 (9.2) MB, 5.6 / 0.0 ms
  • Scavenge 表示新生代小回收。
  • Mark-sweep 表示老生代标记-清除(大回收)。
  • 括号内为 GC 前后的已使用/总容量(MB)。
  • 时间值分别为总耗时和主线程暂停时间。

持续监控这些数据有助于发现内存泄漏和 GC 压力过大的问题。在自动化运维中,也可以结合 Prometheus 等工具采集 V8 堆统计。

6.2.6 日常开发中的 GC 友好实践

理解了 GC 的运作方式后,我们在写代码时应自觉避免以下几类破坏 GC 效率的行为:

  1. 避免频繁创建短生命周期的大对象:在循环中反复创建大型字符串或数组,会快速填充新生代,导致高频 Minor GC 并可能过早晋升到老生代,增加 Major GC 压力。
  2. 避免造成老生代碎片:不断创建大小不一的对象并释放,会产生内存碎片。虽然 V8 会做整理,但高频碎片化会导致频繁的整理停顿。尽量复用对象、或使用对象池来稳定分配。
  3. 不要让全局变量持有大量引用:全局或闭包中无意中保留 reqres 等大对象,会让它们无法被回收,长期占用老生代空间。对于请求级别的缓存,一定要设置 TTL 或使用弱引用(WeakMap/WeakSet)。
  4. 注意闭包的内存陷阱:当一个长生命周期的闭包引用了一个短暂需要的变量时,即使代码中已经不需要该变量,它仍会因为闭包绑定而存活,导致内存泄漏。

GC 是自动的,但并非无代价的。通过理解新生代 Scavenge 的复制机制、老生代标记-清除-整理的平衡、以及增量/并发优化,开发者可以更有意识地去分配和管理内存,让 Node.js 应用在长时间运行下保持平稳、高效。有关具体的内存泄漏场景与排查方法,我们会在 6.4 节中展开。