V8垃圾回收机制深度解析
分代回收、Scavenge 算法、标记-清除-压缩、并发标记,搞懂 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 引用的代码。