文章

V8垃圾回收机制深度解析

分代回收、Scavenge 算法、标记-清除-压缩、并发标记,搞懂 V8 怎么管理内存。

V8垃圾回收机制深度解析

一句话概括

V8 把堆内存分成新生代(存短命对象)和老生代(存长命对象)。新生代用 Scavenge 算法快速复制存活对象,老生代用标记-清除-压缩处理。通过并发/增量标记,GC 停顿从早期的数百毫秒降到了个位数毫秒。

核心知识点

1. 分代回收:为什么分两代

JavaScript 对象的生命周期规律:约 80-90% 的对象在创建后很快就死了(函数内的局部变量、临时中间结果),剩下的 10-20% 活很久(全局变量、闭包引用、缓存)。分代就是利用这个特点——快速回收短命对象,少折腾长命对象。

1
2
3
4
5
6
新生代 (1-8MB)              老生代 (可达几百MB)
┌──────────────┐           ┌──────────────────┐
│ From │ To    │  →晋升→   │ 老对象 / 大对象    │
│ 活跃 │ 空闲   │  两次GC后 │                  │
└──────────────┘           └──────────────────┘
  副GC频繁做,毫秒级           主GC不频繁,可能秒级

2. Scavenge 算法:新生代的回收

新生代用 Cheney 算法,把空间分成两半(From + To),回收时把 From 里的存活对象复制到 To 空间,然后清空 From。这比标记-清除快,因为只操作存活对象(通常很少),不碰死对象。

1
2
3
4
5
6
7
8
// 这个临时对象在新生代,函数返回后就被回收
function process() {
  const temp = { data: new Array(1000) }; // 新生代分配
  return temp.data.length; // 返回后 temp 不可达 → 下次 GC 回收
}

// 这个被全局引用,熬过 2 次 GC 后晋升老生代
window.cache = { data: new Array(10000) }; // 第 3 次 GC 后到老生代

3. 标记-清除-压缩:老生代三件套

标记:从根对象(Global/window、栈上变量)出发遍历所有引用,标记为”活”。 清除:回收没标记的内存。但会产生碎片——已分配和空闲区域交错。 压缩:移动存活对象到连续区域,消除碎片,相当于”内存整理”。

ArrayBuffer 等大对象直接在老生代分配,跳过新生代(因为复制大对象成本太高)。

4. 并发与增量标记:消灭 Stop-The-World

早期 V8 做 GC 时 JS 全部暂停(STW)。V8 的 Orinoco 项目逐步把所有 GC 阶段并发化:

  • 增量标记:标记工作拆成小块,穿插在 JS 执行间隙
  • 并发标记:标记在 Worker 线程执行,与 JS 主线程并行
  • 并发清除/压缩:清除和压缩也在后台线程做
  • 并行 Scavenge:新生代回收用多线程并行复制

现在的 GC 停顿:新生代 < 1ms,老生代 1-5ms(而不再是 700ms)。

5. 常见内存泄漏模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. 意外全局变量
function leak() { bar = 'I am global'; } // 忘记 let/const → window.bar

// 2. 被遗忘的定时器
setInterval(() => { /* 引用了大对象 */ }, 1000); // 永不清理

// 3. 闭包持有外部大对象
function outer() {
  const bigData = new Array(10000000);
  return function inner() { console.log(bigData.length); };
  // inner 活着 → bigData 不被回收
}

// 4. 脱离 DOM 树但 JS 有引用
const el = document.getElementById('btn');
el.remove(); // DOM 没了
// 但 el 变量还持有引用 → 已删除的 DOM 无法回收

其实你每天都在用

  • Chrome Performance → Memory 面板:Heap Snapshot 看哪些对象占内存,Allocation Timeline 看分配频率
  • WeakMap / WeakRef:弱引用不阻止 GC,适合做缓存。WeakMap 的 key 对象被回收后 entry 自动清除
  • React 的 useMemo / useCallback:本质是避免每次渲染产生新的临时对象,减少 GC 压力
  • Vue 3 的 WeakMap 依赖收集:组件卸载时依赖自动释放,不需要手动清理
  • Node.js --max-old-space-size:控制老生代上限,防止 OOM

常见误解(FAQ)

❌ 误区一:”赋值 null 能立即释放内存”

obj = null 只是断掉引用,让对象下次 GC 时可被回收。GC 的时机由 V8 的启发式算法决定(堆内存增长到一定比例或 idle 时),不是立即的。而且如果还有其他引用指向该对象,赋 null 毫无意义。

❌ 误区二:”闭包一定会内存泄漏”

健康的闭包不会泄漏——函数返回后如果没有外部引用,闭包作用域也会被 GC。问题出在无意中持有了不需要的大对象,比如闭包引用了整个作用域的变量但实际只用了一个小字段。

❌ 误区三:”WeakMap 的值也是弱引用”

WeakMap 只有 key 是弱引用,value 仍然是强引用。如果 value 引用了 key 本身,那 key 永远无法被回收,WeakMap 形同虚设。

❌ 误区四:”V8 的 GC 会自动处理所有内存”

JavaScript 的原始对象和闭包可以被 GC,但 Web API 创建的资源(如 FileReader、WebSocket、Worker)以及传给 WASM 的内存需要手动释放。另外 ArrayBuffer 过大时可能导致堆外内存(OOM),GC 看不到。

一句话总结

V8 的 GC 不是魔法——分代回收利用”大部分对象活不长久”的经验规律,并发标记消除 STW 停顿。理解 GC 不是为了手动管理内存,而是写出不意外闭包、不清空定时器、不悬挂 DOM 引用的代码。

本文由作者按照 CC BY 4.0 进行授权