4. V8 引擎的垃圾回收机制:新生代与老生代

说实话,很多同学写 JavaScript 多年,一问垃圾回收就是「标记清除、引用计数」—— 背得挺熟。但真到排查内存泄漏的时候,脑子里还是乱的。

我个人习惯,先搞清楚 V8 到底把内存分成了几块。其实就两大块:新生代和老生代。说白了,一个住年轻人(短命对象),一个住老人(长寿对象)。

我当年在做一个长耗时单页应用时,页面跑了 3 小时突然卡成幻灯片。Chrome DevTools 里一看,老生代 GC 耗时超过 500ms。那时候我才意识到,不了解 GC 分区,根本看不懂性能瓶颈在哪。

4.1 新生代(Young Generation)

新生代放的是「活不过一轮 GC」的对象。比如函数里的临时变量、循环内部创建的对象。V8 把新生代又切成两半:From 空间和 To 空间。只用一个空间干活,另一个闲着。

回收算法叫 Scavenge(清除),具体是 Cheney 算法。流程很简单:

  1. 在 From 空间分配对象。
  2. From 空间快满时,把还活着的对象复制到 To 空间。
  3. 清空 From,然后交换角色(From 变 To,To 变 From)。

我习惯这么记:「复制活着的,扔掉死掉的,左右互搏」。你看,每次回收只复制少量存活对象,代价很低。但缺点也明显:浪费一半空间,不适合放长命对象。

关键指标:新生代默认约 16MB(根据机器内存和页面情况动态调整)。Scavenge 通常 < 5ms,用户几乎无感知。

4.2 对象晋升(Promotion)

如果一个对象在新生代里熬过了好几次 Scavenge,V8 就觉得「这家伙估计还要活很久」,把它挪到老生代去。这个过程叫晋升。

晋升有两个触发条件:

  • 年龄达标:默认经过 2 次 GC 还活着。
  • To 空间快溢了:复制过去发现空间不够,直接升到老生代。

我曾经遇到过一个坑:某个全局对象在函数里被不断赋值,每次赋值都产生新对象。结果那些新对象因为被全局引用着,一直不回收,反复晋升,把老生代撑得巨大——这就是典型的「晋升风暴」。

避坑:全局变量、闭包中意外引用的对象都会导致不该晋升的对象被强行提到老生代。我曾因为这个让一个数据看板页面每 30 秒卡顿一次。

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 依然可能触发几十毫秒的卡顿。

我常用的排查方法:在 Chrome 开发者工具的 Performance 面板里录制一段操作,看「V8 Major GC」事件时长。如果单次超过 100ms,说明老生代对象太多,需要检查是否有泄漏。

4.4 新生代 vs 老生代对比

维度 新生代 老生代
大小 16~64MB(动态) 几百 MB 到几 GB
算法 Scavenge(复制) Mark-Sweep + Mark-Compact
GC 频率 高(几十毫秒一次) 低(几秒到分钟级)
GC 时长 1~5ms 几十到几百毫秒
主要问题 晋升风暴 全停顿、内存碎片
V8 堆内存结构 & GC 流程 新生代 (Young Generation) From 空间 初始分配区域 To 空间 存活复制目标 Scavenge 老生代 (Old Generation) Mark-Sweep 标记清除 Mark-Compact 标记整理 晋升 (2次存活) 说明: 新生代 Scavenge 只复制存活对象,速度快但空间利用率约 50%。 老生代 Mark-Compact 会移动对象位置,解决碎片,但耗时更长。 GC 执行流程: ① 新对象分配在新生代 From → ② From 满,Scavenge → ③ 存活对象复制到 To,同时年龄+1 → ④ 年龄≥2 或 To 不够,晋升到老生代 → ⑤ 老生代用增量标记+并行清理

嗯,看到这张图你应该能理解整体脉络了。新生代负责「短平快」,老生代负责「稳准狠」。但不管怎么优化,只要堆内存里塞满了不该存活的对象,GC 越努力,页面越卡。

顺便提一句:V8 还有大对象空间(Large Object Space),专门放超过 1MB 的大块(比如大数组、大字符串)。这些对象既不复制也不移动,直接分配在老生代尾部。我遇到过一个案例,有人反复拼接字符串导致产生大量碎片化大对象,最后 GC 时间飙到 1 秒——这就是典型的「大对象陷阱」。

回顾一下:新生代用空间换时间,老生代用时间换空间。理解了这个权衡,你才能猜到内存泄漏后具体卡在哪一步。

下一小节我们会实际分析一段泄漏代码,看它到底是怎么把老生代撑爆的。先记住这个结构图,后面排查时你会反复用到。