人人都会AI编程

新生代 Scavenge 算法

更新时间:2026-07-11

V8 的内存堆被划分为多个区域,其中新生代(New Space / Young Generation) 专门负责存放存活时间较短的对象。在设计思想上,新生代就像一张临时的写字桌:大部分新创建的东西放在这儿,用完很快就扔掉,只有少数真正有价值、会长期保留的东西,才会被搬到更稳固的“老生代”书架上。这种分工让垃圾回收能够频繁而快速地进行,而不必每次都扫描整个堆的“旧库存”。

新生代的内存布局:From & To 空间

新生代内存被划分为两个大小相等的半空间(Semi-space):

  • From 空间:当前正在使用、存放活动对象的内存区域。
  • To 空间:为空闲状态,专门用于垃圾回收时的对象迁移。

在64位系统下,新生代默认大小为 32MB,各自占用 16MB。这种“双区轮替”的结构,是 Scavenge 算法能够高效运作的基础。

Scavenge 算法的核心流程

Scavenge 算法本质上是一种基于复制的可达性分析算法,由 C.J. Cheney 在 1970 年提出,V8 中的实现也常称为 Cheney 半空间复制算法。每次新生代垃圾回收(称为 Minor GC)的执行步骤如下:

  1. 根扫描与标记

从根对象(全局对象、调用栈、局部变量等)出发,遍历并标记出所有“可达”的活动对象。这一步类似于从“活着”的对象出发,弄清楚哪些对象仍然被引用。

  1. 存活对象迁移

将 From 空间中所有被标记为可达的对象,按顺序复制到 To 空间。复制过程中会重新排列对象的内存地址,消除碎片。同时,对象的指针会被更新为新的地址。

  1. 角色互换

复制完成后,From 空间中的所有对象——包括原本不可达的死对象——都被视为“无效内存”。然后 From 空间和 To 空间的角色互换:原来的 To 空间成为新的 From 空间,原来的 From 空间被清空,成为新的 To 空间,整个新生代又回到初始状态,等待下一轮对象分配。

这个过程的巧妙之处在于:它不回收单个对象,而是直接抛弃整片内存。复制完成后,From 空间被整体释放,不需要逐个对象调用析构器或清理碎片。同时,复制过程中活对象被紧凑地排列在 To 空间中,天然避免了内存碎片。

可以用如下伪代码来概括一轮 Scavenge:

遍历根集合,标记可达对象
for each 存活对象 in From 空间:
    复制到 To 空间的连续地址
    更新其引用指针
清空 From 空间
交换 From 和 To 的角色

为什么 Scavenge 能这么快?

  • 时间与存活对象数量成正比:算法的代价仅取决于标记和复制活对象,死对象不需要任何处理,因为它们所在的 From 空间会被整体放弃。
  • 内存连续性:复制后的对象紧凑排列,提升了 CPU 缓存的命中率,也方便后续内存分配(只需从 To 空间空闲区线性划拨)。
  • 无碎片:复制天然消除碎片,无需压缩阶段。

在新生代中,绝大多数对象具有“朝生夕死”的特性,因此存活对象通常很少,Scavenge 的实际耗时极短(通常只有几毫秒),这也是它能高频运行(偶尔会一次分配就触发一次 GC)却不明显影响性能的原因。

对象晋升(Promotion):从新生代到老生代

虽然新生代的对象大多短命,但总有一些对象过了好几轮 GC 依然存活。为了防止反复复制这些长寿对象,V8 会在 Scavenge 过程中检查每个存活对象的“年龄”。一旦满足以下任一条件,该对象就会被晋升到老生代,而不是继续留在新生代:

  • 对象已经经历过两次 Minor GC 依然存活。
  • 在复制到 To 空间时,To 空间的使用率超过了 25%(该阈值用于防止新生代被少量驻留对象占满,导致频繁 GC)。

晋升到老生代的对象就不再受 Scavenge 管理,转而由老生代的 Mark-Sweep / Mark-Compact 算法负责回收。这保障了新生代始终保持“纯净”的短命对象池,优化复制算法的效率。

对开发者意味着什么?

了解 Scavenge 的工作方式,能够帮助我们在编码时避免一些会影响 GC 效率的模式:

  1. 避免频繁创建大而短命的对象

在新生代中,默认的半空间只有 16MB,如果分配的对象体积较大(几百 KB 甚至 MB),很容易在一次 GC 中被发现存活,却因为体积过大而立即晋升到老生代,接着可能很快就“死亡”,从而污染老生代并要求代价更高的 Full GC 来清理。可以尽量使用对象池或复用技术来减少不必要的临时大对象。

  1. 注意循环里的“隐式对象”

在热路径中,如果循环体内不断创建短命的临时对象,Scavenge 复制这些活对象的开销会累积。例如:

   for (let i = 0; i < 100000; i++) {
       const temp = { value: i };  // 每轮创建新对象
   }
   

虽然完全避免不是必须的,但在性能敏感代码中,尽量复用对象或批量处理是很好的实践。

  1. 函数作用域与闭包

闭包会让一些本该释放的对象存活更久,从而导致它们在新生代中存活次数增加,最终晋升到老生代。如果闭包持有外部大对象,可能会造成意外的常驻内存占用。了解晋升机制有助于排查“明明没用,却一直不被回收”的对象。

  1. 利用 --trace_gc 观察行为

启动 Node.js 时加入 --trace_gc 标志,可以在标准输出看到每一次垃圾回收的类型和耗时,包括 Minor GC(Scavenge)。通过观察是否频繁触发、是否有很多对象晋升,可以判断是否存在不理想的内存使用模式。

小结

Scavenge 算法是 V8 新生代内存回收的快速引擎:通过复制存活对象并整体丢弃 From 空间的死对象,既保证了回收速度,又避免了碎片化。它与晋升机制配合,使得长生命周期的对象及时迁移至更适合大规模管理的区域,而新生代则始终保持小巧、灵敏。在编写 Node.js 应用时,理解这种“分代回收”的本质,有助于我们设计出对 GC 更友好的代码,减少应用抖动和潜在的内存瓶颈。