我不知道的 V8(17)— 垃圾回收:Scavenge、Mark-Sweep-Compact 与增量优化
JavaScript 的内存管理是自动的——代码里没有 malloc 和 free。但"自动"不意味着"没有代价"。V8 的垃圾回收(GC)在清理内存时会占用主线程,产生停顿。一次严重的 GC 停顿可以让页面卡顿 100ms 以上。理解 V8 的 GC 策略,不只是知识储备,直…
JavaScript 的内存管理是自动的——代码里没有 malloc 和 free。但”自动”不意味着”没有代价”。V8 的垃圾回收(GC)在清理内存时会占用主线程,产生停顿。一次严重的 GC 停顿可以让页面卡顿 100ms 以上。理解 V8 的 GC 策略,不只是知识储备,直接影响写出来的代码在内存上的行为。
一、两代分治:新生代与老生代
V8 把堆内存分成两个区域,针对不同的对象生命周期采用不同的回收策略:
新生代(Young Generation):
- 大小:1-8MB(视 V8 版本和平台而定)
- 特征:存放刚创建的对象,大多数对象在这里就”死”了(变成不可达)
- 策略:Scavenge 算法(快速复制)
老生代(Old Generation):
- 大小:通常几十 MB 到几百 MB
- 特征:存放”长寿”对象——从新生代晋升过来的对象,或直接分配的大对象
- 策略:Mark-Sweep + Mark-Compact(标记清除 + 标记整理)
这种分代思路基于一个统计规律:大多数对象生命周期很短(函数执行完毕后,局部变量就可以回收)。对短命对象用快速算法,对长寿对象用彻底算法,整体效率最高。
二、新生代回收:Scavenge(复制算法)
Scavenge 的核心思路是将新生代空间一分为二(From 和 To),存活对象从 From 复制到 To,然后翻转两个空间的角色:
初始状态:
From 空间: [A, B, C, D, E](A、C、E 是存活对象,B、D 已死亡)
To 空间: [空]
Scavenge 执行:
1. 从根对象开始扫描,标记存活对象
2. 把存活的 A、C、E 复制到 To 空间(连续排列,消除碎片)
3. From 空间整体清空
4. 翻转:原 To 变 From,原 From 变新的 To
结果:
From 空间: [A, C, E](连续排列)
To 空间: [空](等待下次使用)
晋升机制:存活对象不能永远待在新生代。晋升条件:
- 对象经历了两次(或更多次)Scavenge 仍然存活
- 或者 To 空间的使用率超过 25%(为了避免 To 空间很快被填满)
// 这个对象很快就会被 GC
function processRequest() {
const temp = { id: Math.random() }; // 函数返回后 temp 不可达,新生代 GC
return processData(temp);
}
// 这个对象会晋升到老生代
const cache = {}; // 全局变量,长期存活 → 晋升到老生代
三、老生代回收:Mark-Sweep-Compact
老生代不适合 Scavenge——老生代大(几十 MB),复制开销太大。V8 使用两种互补的算法:
标记-清除(Mark-Sweep)
标记阶段: 从根对象(全局变量、调用栈里的引用)出发,深度优先遍历所有可达对象,在 V8 维护的位图(Bitmap)里标记它们为存活。
清除阶段: 遍历老生代空间,释放没有被标记的对象占用的内存,标记为”空闲”。
标记-清除的问题:清除后内存不连续,产生碎片。如果后续需要分配一块较大的连续内存,可能找不到足够大的连续空闲空间,即使总空闲内存够用。
标记-整理(Mark-Compact)
标记阶段同上,但清除时多做一步:把存活对象移动到内存的一端,使其连续排列,然后统一释放另一端的内存。
标记-清除后(有碎片):
[A, 空, C, 空, E, 空, 空]
标记-整理后(无碎片):
[A, C, E, 空, 空, 空, 空]
标记-整理消除了碎片,但移动对象需要更新所有指向它们的引用(Relocation),开销比标记-清除大。V8 不会每次都触发标记-整理,只在碎片过多、内存紧张时才触发。
四、增量标记:消除长停顿
传统的全量 GC(一次完成所有标记和清除)会造成”Stop-the-World”——主线程暂停,页面卡顿。在老生代大的情况下,这个停顿可能长达几百毫秒。
V8 的**增量标记(Incremental Marking)**把标记过程分成小片段执行:
传统全量标记(一次完成):
主线程: [JS执行] [========= GC标记 =========] [JS执行]
↑ 停顿 100ms+
增量标记(分片执行):
主线程: [JS执行] [GC 5ms] [JS执行] [GC 5ms] [JS执行] [GC 5ms] [JS执行]
↑ ↑ ↑
每片最多 5ms,几乎感知不到停顿
增量标记的挑战:JS 在标记间隙里可能修改对象引用(新建对象、改变引用关系),导致之前的标记结果过时。
**写屏障(Write Barrier)**解决这个问题:每次修改对象引用时,V8 会额外记录这次修改(Dijkstra Write Barrier),确保新建或修改后的引用不会被漏标。
五、并行 GC:利用多核 CPU
增量标记把 GC 分散到主线程的空隙里,但仍然占用主线程时间。V8 还支持并行 GC——用工作线程(Worker Threads)分担 GC 工作:
- ParallelScavenge:新生代 GC 使用多个工作线程并行扫描和复制
- ParallelSweep:老生代清除阶段使用多线程并行处理不同内存页
在多核 CPU 上,这可以显著缩短 GC 的墙钟时间(wall-clock time)。
六、空闲时 GC
V8 还有延迟 GC(Idle-time GC):当主线程空闲时(如用户没有输入,也没有 JS 任务要执行),利用这个空闲时间做增量 GC,把 GC 压力分摊到不影响用户体验的时段。
Chrome 的 scheduler 会向 V8 传递”空闲时间”信号,V8 据此安排增量 GC 的执行。
七、写出 GC 友好的代码
减少短命的大对象
// Bad Case:每次调用都创建大数组
function processData(items) {
const sorted = [...items]; // 创建副本,处理完就丢弃
sorted.sort((a, b) => a - b);
return sorted[0];
}
// Better:如果原数组可以修改,直接排序
function processData(items) {
items.sort((a, b) => a - b);
return items[0];
}
// 或者:对象池模式(复用大对象)
const pool = [];
function getBuffer() {
return pool.pop() || new Array(1000);
}
function releaseBuffer(buf) {
pool.push(buf);
}
避免意外的老生代引用
// 全局数组不断增长 → 对象被晋升到老生代,且永不回收
const globalLog = [];
function logEvent(event) {
globalLog.push(event); // 内存只增不减
}
// 修复:限制大小或使用 WeakRef
const MAX_LOG = 1000;
const globalLog = [];
function logEvent(event) {
globalLog.push(event);
if (globalLog.length > MAX_LOG) {
globalLog.shift(); // 超出限制就移除最旧的
}
}
用 WeakMap/WeakRef 持有临时引用
// WeakMap 的键是弱引用,键对象无其他引用时,GC 可以回收它
const cache = new WeakMap();
function getComputedValue(obj) {
if (cache.has(obj)) return cache.get(obj);
const value = computeExpensive(obj);
cache.set(obj, value); // 当 obj 被 GC,cache 里的这条记录也自动清除
return value;
}
诊断工具
# 查看 GC 事件和停顿时间
node --trace-gc your-script.js
# 查看堆内存统计
node --expose-gc -e "gc(); console.log(process.memoryUsage())"
Chrome DevTools 的 Memory 面板提供堆快照(Heap Snapshot)和分配时间线(Allocation Timeline),用于分析内存泄漏和大对象的来源。
八、总结
V8 的 GC 用分代策略应对 JavaScript 对象的生命周期特征:新生代用 Scavenge(快速复制)处理短命对象,老生代用 Mark-Sweep-Compact 处理长寿对象。
三个关键优化消除了全量 GC 的停顿问题:
- 增量标记:把标记分成小时间片,穿插在 JS 执行之间
- 并行 GC:多线程分担扫描和清除工作
- 空闲时 GC:利用主线程空闲时间消化 GC 压力
写出 GC 友好的代码,核心是:不创建不必要的短命大对象、不让长寿引用意外持有大量临时数据、需要临时关联数据时用 WeakMap/WeakRef。
本系列其他文章:
- 上一篇:async/await 的实现原理
- 系列索引:我不知道的 V8 — 系列导读
- 相关:为什么不建议使用 delete