V8 引擎垃圾回收机制详解

从分代假说到 Orinoco 并发 GC,全面拆解 V8 如何在不阻塞主线程的前提下, 高效回收数百万 JavaScript 对象所占用的内存。

阅读约 18 分钟
难度:高级

为什么需要垃圾回收?

栈内存 vs 堆内存

栈内存 (Stack)

存储原始类型值和函数调用帧。由操作系统自动分配和回收,遵循 LIFO(后进先出)原则,速度极快。

numberstringbooleannullundefined
堆内存 (Heap)

存储引用类型对象(Object、Array、Function 等)。 内存由开发者分配(new),但无法手动释放, 必须依赖 GC 自动回收。这就是 V8 垃圾回收器的舞台。

ObjectArrayFunctionMapSetSymbol

内存生命周期

1

分配 (Allocation)

声明变量或创建对象时,V8 在堆中分配内存

2

使用 (Usage)

读写对象属性、调用方法、传递引用

3

释放 (Release)

当对象不再被任何可达路径引用时,GC 将其标记为可回收

可达性 (Reachability) — GC 的核心判据

V8 判断一个对象是否能被回收的标准是可达性——从 GC Roots 出发,通过引用链能够访问到的对象就是「活的」。 以下都是 GC Roots:

全局对象

window / globalThis

调用栈中的变量

当前正在执行的函数中引用的对象

DOM 引用

已挂载的 DOM 元素及事件监听器

闭包引用

未被销毁的闭包所捕获的外部变量

WeakRef / FinalizationRegistry

特殊弱引用机制(不阻止 GC)

内置对象

原生 JS 内置对象及引擎内部对象

GC Root ──引用──▶ 对象 A ──引用──▶ 对象 B ──引用──▶ 对象 C  ✅ 均可达,不可回收

对象 D ──无引用──▶ ❌ 孤岛,可回收

分代假说 (Generational Hypothesis)

绝大多数对象的生命周期极短——它们「生来即死」。少数存活较久的对象往往会继续存活很久。

V8 堆内存结构

新生代 (Young Generation) ~1-8 MB
From Space
To Space
老生代 (Old Generation) ~700MB - 1.4GB
Old Pointer
Old Data
Code
大对象空间 (Large Object Space)
大于其他空间半页大小的对象
Map 空间 (Map Space)
存放隐藏类 (Hidden Classes / Maps)
90%+

的对象在新生代就被回收

Scavenge

新生代回收算法,速度极快(< 1ms)

Mark-Sweep

老生代回收算法,需要增量/并发优化

新生代 GC:Scavenge 算法

ASemispace(半空间)算法流程

From Space

① 分配阶段

新对象只在 From Space 中分配。To Space 处于空闲状态。 当 From Space 使用率达到 25% 时触发 Scavenge。

From → 标记存活

② 标记 & 复制

从 GC Roots 遍历,标记所有存活对象。 将存活对象复制到 To Space 并紧密排列(消除碎片)。 死亡对象被直接抛弃。

To Space ✓
From (清空)

③ 角色翻转

From 和 To 交换角色。新的 From Space(原 To)继续服务分配。 存活次数达 2 次的对象将被晋升到老生代。

速度快

只处理存活对象,新生代 90%+ 对象是短命的,所以复制量极小。 典型耗时 < 1ms

无碎片

复制过程中对象被紧密排列,天然避免内存碎片化问题。 代价是空间利用率仅 50%

晋升条件

① 经历过 2 次 Scavenge 仍存活 → 晋升老生代。
② To Space 内存占用超过 25% → 直接晋升。

新变化:Orinoco

Orinoco 优化中使用了 Parallel Scavenge——在多个线程上并行执行复制, 进一步缩短停顿时间。

老生代 GC:标记清除 & 标记整理

阶段一Mark-Sweep(标记-清除)

标记阶段 (Mark)

从所有 GC Roots 出发,递归遍历所有可达对象并标记为「活跃」

V8 使用三色标记法

白色——未访问的对象(潜在垃圾)
灰色——已访问,但子引用尚未扫描
黑色——已访问,且所有子引用已扫描完毕

清除阶段 (Sweep)

遍历整个老生代空间,回收所有未被标记的白色对象, 将其占用的内存归还到空闲链表 (Free List)中。

问题:清除后会产生内存碎片—— 大量不连续的小空闲块无法存放较大的新对象。

阶段二Mark-Compact(标记-整理)

整理过程

在标记之后,将所有存活对象向一端移动,使它们在内存中连续排列。 这样就消除了内存碎片,但移动对象的代价很高(需要更新所有引用指针)。

特性Mark-SweepMark-Compact
碎片
速度较快较慢
移动对象
场景空间充足需要整理

Stop-The-World 与 V8 优化演进

什么是 STW (Stop-The-World)?

传统的垃圾回收需要暂停所有 JavaScript 执行来保证内存一致性。 对于老生代(数百 MB 以上),全量标记-清除可能导致数百毫秒的停顿, 造成页面卡顿、动画掉帧、响应延迟。V8 的演进目标就是尽可能减少甚至消除 STW

V8 GC 优化演进路线

V8 早期STW单线程

Full Mark-Sweep / Mark-Compact

全量 STW,老生代回收时间随内存线性增长。大堆场景下卡顿明显。

2011增量写屏障

增量标记 (Incremental Marking)

将标记工作拆成多个小步骤(每个约 5-10ms),穿插执行 JavaScript 代码。通过写屏障 (Write Barrier) 追踪标记期间的引用变更。

2016并发后台线程

并发标记 (Concurrent Marking)

标记工作在后台线程中并发执行,主线程继续运行 JS。这大幅减少了主线程的 GC 暂停时间。

2018并行多线程

并行清除 (Parallel Sweeping)

清除阶段由多个辅助线程并行完成,每个线程负责一个内存页 (Page)。

2018+Orinoco< 10ms

Orinoco — 并行 Scavenge + 并发标记

新生代 Scavenge 在主线程 + 辅助线程并行执行;老生代标记主要在并发线程完成。主线程 STW 暂停压缩到 5-10ms 以内

持续优化未来全面并发

并发回收 (Concurrent Sweeping/Compaction)

进一步将清扫和整理操作也并发化,正在持续推进中。

写屏障 (Write Barrier) — 并发标记的守护者

当 GC 在后台线程进行并发标记时,JavaScript 主线程可能正在修改对象引用。 如果一个已标记为黑色的对象新增了一个指向白色对象的引用,而标记线程已经扫描过它, 这个白色对象就会被错误地回收

Dijkstra 写屏障

每次写引用操作时(如 obj.field = val), 检查被写入的对象是否已经被标记为灰色或黑色。如果不是,将其标记为灰色(放入标记队列)。

function writeBarrier(obj, val) {
if(obj.color === BLACK &&
val.color === WHITE) {
obj.color = GRAY;
marking_queue.push(obj);
}
}

对象丢失 (Object Lost) 问题

场景:

① 标记线程将 A 标记为黑色(已扫描完)

② JS 主线程执行 A.ref = B(B 是白色未标记对象)

③ JS 主线程同时删除了 B 的其他引用路径

④ 标记线程不会再次扫描 A → B 被错误回收 💀

写屏障正是为了解决这个问题:在写操作发生时, 确保 B 也进入标记队列,不会被遗漏。

GC 触发策略与内存限制

定时触发

  • 新生代:From Space 使用达 25%
  • 老生代:老生代增长到一定阈值
  • 基于分配速率的自适应策略

内存压力

  • V8 堆接近最大限制时强制 GC
  • 默认限制:~1.4GB (64位)
  • 可通过 --max-old-space-size 调整

空闲时触发

  • Idle GC:利用 requestIdleCallback
  • 在浏览器空闲期执行增量 GC
  • 对用户完全透明无感知

V8 GC 完整流程总览

1新对象分配

在新生代的 From Space 中分配。如果对象来自字面量或临时变量,它极大概率短命。

2新生代 Scavenge

From Space 达到 25% 阈值时触发。并行复制存活对象到 To Space,死亡对象被清除。存活 2 次的对象晋升到老生代。

3老生代并发标记

当老生代增长触发 GC 时,辅助线程执行并发标记(三色标记法 + 写屏障),主线程继续执行 JS。

4增量标记 / 主线程修正

标记尾声会有短暂的 STW (5-10ms) 来完成最终标记和处理遗留的写屏障队列。

5并行清除 (Sweep)

多线程并行清除不可达对象,将内存归还到空闲链表。如果碎片严重则触发 Mark-Compact。

6可选:Mark-Compact

当碎片率过高或内存紧张时,执行标记-整理,将存活对象压缩到连续内存区域。这是最耗时的操作。

开发者 GC 优化最佳实践

DO

减少全局变量

全局变量始终可达,永远不会被 GC。使用模块作用域或 IIFE 隔离生命周期。

DO

及时解除引用

不再需要的大对象设置为 null,帮助 GC 尽早识别不可达对象。

DO

使用 WeakRef / WeakMap

对于缓存和观察者模式,使用弱引用避免阻止 GC 回收。

AVOID

避免内存泄漏

常见泄漏:未清理的事件监听器、定时器、闭包持有大对象、detached DOM 节点。

AVOID

避免频繁创建大对象

减少短命大对象的创建频率,使用对象池复用;避免在热路径中触发 GC。

TIP

监控内存使用

使用 Chrome DevTools 的 Memory 面板、Performance Monitor 和 heap snapshot 定期排查。

Chrome DevTools 中观察 GC

Performance 面板

1

打开 DevTools → Performance 面板

2

点击录制,执行页面操作

3

停止录制,查看 GC 事件(灰色竖线)

4

关注 JS Heap 曲线的锯齿状波动——每次下降就是一次 GC

Memory 面板

1

Heap Snapshot:堆快照,查看对象类型和引用关系

2

Allocation Timeline:实时观察内存分配热点

3

Allocation Sampling:低开销的分配采样分析

4

拍两个快照做 Comparison → 找出未释放的对象

// 手动触发 GC(仅在 DevTools 打开时有效)
if (global.gc) {
global.gc(); // 需要 node --expose-gc
}
// 在 Node.js 中查看 V8 堆统计
const stats = process.memoryUsage();
console.log(stats.heapUsed, stats.heapTotal);
// 调整堆内存上限(单位:MB)
// node --max-old-space-size=4096 app.js

核心要点速览

核心算法

新生代 Scavenge(半空间复制)+ 老生代 Mark-Sweep / Mark-Compact

关键优化

增量标记 → 并发标记 → 并行清除 → Orinoco,STW < 10ms

写屏障

并发标记期间确保引用变更不丢失,是并发 GC 正确性的基石

分代假说

绝大多数对象「生来即死」,据此将堆分为新生代和老生代分别管理