4. V8 引擎的垃圾回收机制:新生代与老生代
说实话,很多同学写 JavaScript 多年,一问垃圾回收就是「标记清除、引用计数」—— 背得挺熟。但真到排查内存泄漏的时候,脑子里还是乱的。
我个人习惯,先搞清楚 V8 到底把内存分成了几块。其实就两大块:新生代和老生代。说白了,一个住年轻人(短命对象),一个住老人(长寿对象)。
我当年在做一个长耗时单页应用时,页面跑了 3 小时突然卡成幻灯片。Chrome DevTools 里一看,老生代 GC 耗时超过 500ms。那时候我才意识到,不了解 GC 分区,根本看不懂性能瓶颈在哪。
4.1 新生代(Young Generation)
新生代放的是「活不过一轮 GC」的对象。比如函数里的临时变量、循环内部创建的对象。V8 把新生代又切成两半:From 空间和 To 空间。只用一个空间干活,另一个闲着。
回收算法叫 Scavenge(清除),具体是 Cheney 算法。流程很简单:
- 在 From 空间分配对象。
- From 空间快满时,把还活着的对象复制到 To 空间。
- 清空 From,然后交换角色(From 变 To,To 变 From)。
我习惯这么记:「复制活着的,扔掉死掉的,左右互搏」。你看,每次回收只复制少量存活对象,代价很低。但缺点也明显:浪费一半空间,不适合放长命对象。
4.2 对象晋升(Promotion)
如果一个对象在新生代里熬过了好几次 Scavenge,V8 就觉得「这家伙估计还要活很久」,把它挪到老生代去。这个过程叫晋升。
晋升有两个触发条件:
- 年龄达标:默认经过 2 次 GC 还活着。
- To 空间快溢了:复制过去发现空间不够,直接升到老生代。
我曾经遇到过一个坑:某个全局对象在函数里被不断赋值,每次赋值都产生新对象。结果那些新对象因为被全局引用着,一直不回收,反复晋升,把老生代撑得巨大——这就是典型的「晋升风暴」。
4.3 老生代(Old Generation)
老生代里的对象大多是大块头、长寿命。比如框架的虚拟 DOM 树、缓存池、全局配置对象。这里的 GC 不能再玩「复制」了——对象太大,复制代价太高。
V8 用了组合拳:Mark-Sweep(标记-清除) + Mark-Compact(标记-整理) + 增量标记(Incremental Marking)。
| 算法 | 干什么的 | 特点 |
|---|---|---|
| Mark-Sweep | 从根对象遍历,标记可到达的,然后清除未标记的。 | 会产生内存碎片。 |
| Mark-Compact | 标记后把存活对象往一边挪,腾出连续空间。 | 解决碎片,但更慢(挪动耗时)。 |
| 增量标记 | 把标记过程拆成多个小步骤,穿插在 JS 执行间隙。 | 避免长时间阻塞主线程(卡顿)。 |
你想想看,如果一次 GC 停掉所有 JS 执行 200ms,那用户滑动页面时肯定掉帧。增量标记就是 V8 的优化策略——把大任务切碎,每次只停几毫秒。
我记得 Chrome 66 之后默认启用了并行标记,利用多核加速。但仍然没法完全避免「全停顿」(Stop-The-World)。尤其是老生代空间超过几百 MB 时,一次 Full GC 依然可能触发几十毫秒的卡顿。
4.4 新生代 vs 老生代对比
| 维度 | 新生代 | 老生代 |
|---|---|---|
| 大小 | 16~64MB(动态) | 几百 MB 到几 GB |
| 算法 | Scavenge(复制) | Mark-Sweep + Mark-Compact |
| GC 频率 | 高(几十毫秒一次) | 低(几秒到分钟级) |
| GC 时长 | 1~5ms | 几十到几百毫秒 |
| 主要问题 | 晋升风暴 | 全停顿、内存碎片 |
嗯,看到这张图你应该能理解整体脉络了。新生代负责「短平快」,老生代负责「稳准狠」。但不管怎么优化,只要堆内存里塞满了不该存活的对象,GC 越努力,页面越卡。
顺便提一句:V8 还有大对象空间(Large Object Space),专门放超过 1MB 的大块(比如大数组、大字符串)。这些对象既不复制也不移动,直接分配在老生代尾部。我遇到过一个案例,有人反复拼接字符串导致产生大量碎片化大对象,最后 GC 时间飙到 1 秒——这就是典型的「大对象陷阱」。
下一小节我们会实际分析一段泄漏代码,看它到底是怎么把老生代撑爆的。先记住这个结构图,后面排查时你会反复用到。