在前面的章节中,我们反复提到 Node.js 通过 V8 引擎执行 JavaScript 代码,而 V8 的一个重要职责就是管理程序运行时分配的内存。不同于在浏览器中页面刷新后内存即被回收,Node.js 的服务器进程通常会长达数周甚至数月持续运行,内存泄漏或者频繁的垃圾回收停顿会直接影响服务的稳定性和响应延迟。因此,理解 V8 如何组织堆内存、如何回收垃圾,是写出高质量 Node.js 应用的必备知识。
6.1.1 为什么需要分代内存管理
大多数程序的对象的生命周期都遵循一个规律:新创建的对象往往存活时间很短,而存活较久的对象通常会继续存活下去。这就是分代假说。V8 正是基于这一规律,将堆内存划分为不同的区域(代),对存活时间不同的对象采用不同的回收策略,从而在吞吐量和停顿时间之间取得平衡。Node.js 默认的堆内存上限在 64 位系统上大约为 1.4 GB(老生代),但可以通过 --max-old-space-size 调整。
6.1.2 新生代(Young Generation):短命对象的培育场
新生代主要存放生命周期较短的对象,比如函数内部的局部变量、临时计算结果等。它的空间相对较小(在 64 位系统下通常为 32 MB),但回收频率极高,且回收速度非常快。
空间划分
新生代内部采用 Semispace(半空间) 设计,将内存平分为两个同样大小的区域:From 空间(活动区) 和 To 空间(空闲区)。绝大多数时候,只有 From 空间处于使用状态,To 空间保持闲置,等待下一次垃圾回收。
Scavenge 算法
新生代的垃圾回收使用 Scavenge 算法,其核心是复制而非“标记清除”。过程如下:
- 当 From 空间即将填满时,触发一次新生代 GC(Scavenge)。
- GC 从根对象(全局对象、栈上变量等)开始遍历,找出 From 空间中所有存活的对象。
- 将存活对象复制到 To 空间中,并紧凑排列,释放原有空间。
- 完成复制后,交换 From 和 To 的角色:原来的 To 成为新的活动区 From,原来的 From 则变为空闲 To。
这种算法的优势在于:只处理存活对象,速度极快,且复制过程中自然形成了紧凑布局,没有内存碎片。缺点是需要保留一半的备用空间,浪费了一定内存。但对于存活率低的新生代而言,这完全值得。
晋升机制
对象在新生代不会无限存活。每次 Scavenge 后仍存活的对象,会被记录“存活次数”。当一个对象在两次 Scavenge 后依然存活,它就会被晋升到老生代。此外,如果 To 空间使用率超过 25%,后续的存活对象也会直接晋升,避免新生代过度拥挤。
6.1.3 老生代(Old Generation):长命对象的大本营
老生代用于存放生命周期较长的对象,如全局变量、闭包引用、大对象、长期缓存等。老生代的初始空间较大(默认约 1.4 GB),并且 GC 频率较低。正因为老生代空间大、存活率高,新生代的复制算法不再适用(复制存活对象过多会导致效率低下),因此采用标记-清除与标记-整理相结合的策略。
标记-清除(Mark-Sweep)
老生代 GC 的第一阶段是标记-清除:
- 标记阶段:从根对象出发,遍历所有可达对象,将它们标记为“存活”。
- 清除阶段:遍历整个老生代堆,将未被标记的对象的空间释放回空闲列表。
标记-清除的缺点在于:清除后存活对象可能散落在内存各处,产生内存碎片。当需要分配一个较大对象时,可能找不到连续的足够空间,即使空闲内存总量足够。
标记-整理(Mark-Compact)
为了解决内存碎片问题,老生代在空间不足以分配新对象或碎片严重时,会进行标记-整理:
- 标记阶段与标记-清除一致。
- 整理阶段:把所有存活对象向一端移动,使它们连续排列,释放出另一端的完整空间。
标记-整理的代价更高,需要移动大量对象并更新引用,因此不会在每次 GC 时执行,而是作为碎片严重时的补救手段。这种“标记-清除为主、标记-整理为辅”的组合,兼顾了回收效率和空间利用率。
增量标记与并发回收
老生代空间大,一次完整的 GC 停顿可能会达到几十甚至上百毫秒,严重影响服务延迟。V8 为此引入了增量标记和并发标记技术:
- 增量标记将标记阶段拆分为多个小步骤,与 JavaScript 执行交替进行,每次只造成短暂的停顿,将总的停顿时间打散。
- 并发标记让标记工作在后台线程中与主线程并发执行,进一步减少主线程的停顿时长。
同时,老生代的部分清除和整理工作也可以交给后台线程完成,最终使得 GC 对业务的干扰降到最低。
6.1.4 大对象空间(Large Object Space)
新生代和老生代之外,V8 还划分了大对象空间,专门存放那些超过一定大小阈值的对象(如大型 ArrayBuffer、巨型字符串等)。大对象的分配和回收策略与普通对象不同:
- 大对象不会在新生代分配,而是直接进入大对象空间,避免复制开销。
- 大对象空间中每个对象使用独立的 mmap 区域,回收时直接释放整个区域,无需参与新生代的复制或老生代的标记整理。
- 大对象同样由标记-清除算法管理,但因为它们数量相对较少、尺寸巨大,其回收行为会影响整体堆的利用率。
在 Node.js 中,类似 Buffer.alloc(1024 1024 10) (10 MB)的分配就可能直接落入大对象空间(具体阈值与 V8 版本有关,通常为几 MB)。大量创建释放大对象会频繁触发老生代 GC,应当尽量避免在热路径上反复分配大内存。
6.1.5 新生代、老生代与大对象空间的协同关系
理解各代之间的协同,对排查内存问题很有帮助:
- 新对象在新生代分配。如果存活时间短,则在 Scavenge 中被回收;如果存活次数足够多,晋升到老生代。
- 老生代的对象如果被标记为不可达,则在标记-清除中被释放。
- 特别大的对象越过新生代,直接进入大对象空间,按老生代类似的标记-清除方式管理。
- 若老生代空间不足,会触发完整的标记-清除甚至标记-整理,此时可能会因为堆内存上限而引发进程退出(OOM)。
6.1.6 对 Node.js 开发者的实践启示
了解内存结构后,在日常开发中可以落实以下几点:
- 避免在全局作用域或模块顶层持有大量对象的引用,这些对象会持续存活于老生代,累积到一定程度后触发高成本的 GC。
- 及时置空不再需要的大对象引用,特别是数组或对象缓存,以便 GC 能够回收老生代空间。
- 减少在频繁调用的函数中创建临时大对象,大量短命大对象会快速耗尽新生代,引发频繁的 Scavenge 甚至过早晋升。
- 合理使用 Buffer 与 TypedArray,它们的内存在原生堆外分配(Off-Heap),不受 V8 堆限制,但需要手动管理生命周期,及时
fill(0)或置为null以允许 GC 回收关联的缓冲区。 - 评估设置合理的
--max-old-space-size,特别是在内存受限的容器环境中(如 Docker),防止进程因达到堆上限而崩溃。
理解新生代、老生代和大对象空间的分工,是我们后续分析内存泄漏和性能优化问题的基础。下一节,我们将深入垃圾回收算法在 V8 中的实际工作细节,以及如何通过 Chrome DevTools 或 heapdump 工具观察 Node.js 进程的堆快照。