2. V8 引擎的垃圾回收机制:新生代与老生代、Scavenge 与 Mark-Sweep-Compact 算法,以及如何通过 --trace-gc 观察 GC 行为
聊到 Node.js 性能,垃圾回收(GC)是个绕不开的话题。很多人觉得 GC 是 V8 引擎自己的事,跟开发者关系不大。说实话,我以前也这么想。直到有一次线上服务频繁出现卡顿,排查了半天才发现是 GC 频率过高导致的。嗯,从那以后我就开始认真研究 V8 的 GC 机制了。
这一节,我们就来拆解 V8 的垃圾回收到底是怎么工作的。说白了,就是搞清楚两个问题:内存什么时候被回收?怎么回收的?
2.1 新生代与老生代:V8 的内存分代策略
V8 把堆内存分成了两个区域:新生代(New Space) 和 老生代(Old Space)。为什么要分代?你想想看,大部分对象的生命周期都很短——函数执行完就释放了。只有少数对象会长期存活。分代策略就是针对这个特点设计的。
| 区域 | 大小(默认) | 特点 | 回收算法 |
|---|---|---|---|
| 新生代 | 16MB ~ 32MB(取决于架构) | 存放生命周期短的对象 | Scavenge(Cheney 算法) |
| 老生代 | 几百 MB 到几 GB | 存放生命周期长或大对象 | Mark-Sweep-Compact |
新生代又细分为两个区域:From 空间 和 To 空间。每次分配对象时,先放到 From 空间。当 From 空间快满时,就会触发一次 GC。
核心要点:新生代 GC 非常快,通常只需要几毫秒。但如果你的代码频繁创建大量临时对象,新生代 GC 就会频繁触发,导致 CPU 开销上升。
2.2 Scavenge 算法:新生代的清理方式
Scavenge 算法,说白了就是「复制存活对象」。具体怎么做的?
- 从 From 空间找出所有存活的对象
- 把这些对象复制到 To 空间
- 清空 From 空间
- 交换 From 和 To 的角色
这个过程也叫 Cheney 算法。它的优点是快,因为只处理存活对象,不需要扫描整个堆。缺点是浪费了一半空间(To 空间始终是空的)。
我在项目中遇到过一个问题:某个接口每次请求都会创建大量临时对象,结果新生代 GC 每秒触发几十次。虽然每次只有几毫秒,但累积起来 CPU 占用率直接飙到 80%。后来我用了对象池复用,GC 频率降到了每秒 2-3 次。
我的建议:如果你发现新生代 GC 过于频繁,可以尝试增大新生代大小。通过 --max-new-space-size 参数调整。但别调太大,否则单次 GC 时间会变长。
2.3 Mark-Sweep-Compact 算法:老生代的清理方式
老生代的对象存活率高,用 Scavenge 就不合适了——复制大量存活对象太浪费。所以 V8 用了 Mark-Sweep-Compact 算法,分三步走:
- Mark(标记):从根对象(全局变量、当前执行栈等)出发,遍历所有可达对象,打上标记
- Sweep(清除):遍历堆,回收没有标记的对象
- Compact(整理):把存活对象移到一起,避免内存碎片
这里有个关键点:标记阶段需要暂停 JavaScript 执行,也就是所谓的「Stop The World」。老生代 GC 如果触发,暂停时间可能达到几十甚至几百毫秒。对于高并发服务来说,这就是灾难。
我曾经踩过的坑:有一次线上服务每隔几分钟就出现一次 200ms 的延迟尖峰。排查后发现是某个全局缓存对象不断增长,触发了老生代 GC。后来我改用 LRU 缓存限制大小,问题就解决了。
2.4 晋升机制:对象如何从新生代进入老生代
对象不会一直待在新生代。当满足以下条件之一时,它会被「晋升」到老生代:
- 经历过一次 Scavenge 后仍然存活
- To 空间的使用率超过 25%
- 对象大小超过某个阈值(通常是 512KB)
晋升本身不是问题,但如果你不小心把大量短期对象晋升到了老生代,就会导致老生代 GC 频繁触发。我见过一个案例:有人用 new Array(1000000) 创建大数组,结果直接晋升到老生代,每次 GC 都要扫描这个巨大的数组。
2.5 使用 --trace-gc 观察 GC 行为
理论说完了,咱们来点实际的。怎么观察 GC 到底在干什么?V8 提供了一个非常实用的参数:--trace-gc。
启动 Node.js 时加上这个参数:
node --trace-gc app.js
你会看到类似这样的输出:
[12345] 0.123 ms: Scavenge 2.5 (6.0) -> 1.2 (7.0) MB, 0.8 / 0.0 ms (average mu = 0.998, current mu = 0.997) allocation failure
[12345] 5.678 ms: Mark-sweep 8.9 (15.0) -> 4.2 (16.0) MB, 12.3 / 0.0 ms (average mu = 0.995, current mu = 0.994) allocation failure
我来解释一下这些字段:
| 字段 | 含义 |
|---|---|
Scavenge / Mark-sweep |
GC 类型 |
2.5 (6.0) -> 1.2 (7.0) MB |
GC 前后的堆内存使用情况(已用 / 总量) |
0.8 / 0.0 ms |
GC 耗时(mutator 暂停时间 / GC 本身时间) |
allocation failure |
触发 GC 的原因(内存分配失败) |
我个人习惯在开发环境一直开着 --trace-gc。这样能及时发现内存泄漏或者 GC 频率异常。比如你看到 Scavenge 每秒触发超过 10 次,或者 Mark-sweep 耗时超过 50ms,那就该优化了。
小技巧:如果想看更详细的信息,可以用 --trace-gc-verbose。它会输出每个对象的回收情况,不过信息量很大,适合做深度分析时用。
2.6 总结与避坑指南
好了,这一节的内容就这些。我帮你梳理几个关键点:
- 新生代 GC 快但频繁:注意控制临时对象的数量
- 老生代 GC 慢但次数少:避免让短期对象晋升到老生代
- 用 --trace-gc 做诊断:这是最直接的观察手段
我曾经接手过一个项目,内存占用持续增长,最后 OOM 了。打开 --trace-gc 一看,发现老生代 GC 每次回收后内存只降一点点。顺着线索排查,最终定位到一个闭包引用了大量 DOM 节点。嗯,这种问题光靠猜是猜不出来的,必须用工具说话。
下一节我们会聊内存泄漏的检测与排查,到时候会用到更多实战技巧。先消化好 GC 机制,后面才能理解为什么某些操作会导致内存泄漏。