V8 引擎垃圾回收机制详解
从分代假说到 Orinoco 并发 GC,全面拆解 V8 如何在不阻塞主线程的前提下, 高效回收数百万 JavaScript 对象所占用的内存。
为什么需要垃圾回收?
栈内存 vs 堆内存
存储原始类型值和函数调用帧。由操作系统自动分配和回收,遵循 LIFO(后进先出)原则,速度极快。
存储引用类型对象(Object、Array、Function 等)。 内存由开发者分配(new),但无法手动释放, 必须依赖 GC 自动回收。这就是 V8 垃圾回收器的舞台。
内存生命周期
分配 (Allocation)
声明变量或创建对象时,V8 在堆中分配内存
使用 (Usage)
读写对象属性、调用方法、传递引用
释放 (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 堆内存结构
的对象在新生代就被回收
新生代回收算法,速度极快(< 1ms)
老生代回收算法,需要增量/并发优化
新生代 GC:Scavenge 算法
ASemispace(半空间)算法流程
① 分配阶段
新对象只在 From Space 中分配。To Space 处于空闲状态。 当 From Space 使用率达到 25% 时触发 Scavenge。
② 标记 & 复制
从 GC Roots 遍历,标记所有存活对象。 将存活对象复制到 To Space 并紧密排列(消除碎片)。 死亡对象被直接抛弃。
③ 角色翻转
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-Sweep | Mark-Compact |
|---|---|---|
| 碎片 | 有 | 无 |
| 速度 | 较快 | 较慢 |
| 移动对象 | 否 | 是 |
| 场景 | 空间充足 | 需要整理 |
Stop-The-World 与 V8 优化演进
什么是 STW (Stop-The-World)?
传统的垃圾回收需要暂停所有 JavaScript 执行来保证内存一致性。 对于老生代(数百 MB 以上),全量标记-清除可能导致数百毫秒的停顿, 造成页面卡顿、动画掉帧、响应延迟。V8 的演进目标就是尽可能减少甚至消除 STW。
V8 GC 优化演进路线
Full Mark-Sweep / Mark-Compact
全量 STW,老生代回收时间随内存线性增长。大堆场景下卡顿明显。
增量标记 (Incremental Marking)
将标记工作拆成多个小步骤(每个约 5-10ms),穿插执行 JavaScript 代码。通过写屏障 (Write Barrier) 追踪标记期间的引用变更。
并发标记 (Concurrent Marking)
标记工作在后台线程中并发执行,主线程继续运行 JS。这大幅减少了主线程的 GC 暂停时间。
并行清除 (Parallel Sweeping)
清除阶段由多个辅助线程并行完成,每个线程负责一个内存页 (Page)。
Orinoco — 并行 Scavenge + 并发标记
新生代 Scavenge 在主线程 + 辅助线程并行执行;老生代标记主要在并发线程完成。主线程 STW 暂停压缩到 5-10ms 以内。
并发回收 (Concurrent Sweeping/Compaction)
进一步将清扫和整理操作也并发化,正在持续推进中。
写屏障 (Write Barrier) — 并发标记的守护者
当 GC 在后台线程进行并发标记时,JavaScript 主线程可能正在修改对象引用。 如果一个已标记为黑色的对象新增了一个指向白色对象的引用,而标记线程已经扫描过它, 这个白色对象就会被错误地回收。
Dijkstra 写屏障
在每次写引用操作时(如 obj.field = val), 检查被写入的对象是否已经被标记为灰色或黑色。如果不是,将其标记为灰色(放入标记队列)。
对象丢失 (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 优化最佳实践
减少全局变量
全局变量始终可达,永远不会被 GC。使用模块作用域或 IIFE 隔离生命周期。
及时解除引用
不再需要的大对象设置为 null,帮助 GC 尽早识别不可达对象。
使用 WeakRef / WeakMap
对于缓存和观察者模式,使用弱引用避免阻止 GC 回收。
避免内存泄漏
常见泄漏:未清理的事件监听器、定时器、闭包持有大对象、detached DOM 节点。
避免频繁创建大对象
减少短命大对象的创建频率,使用对象池复用;避免在热路径中触发 GC。
监控内存使用
使用 Chrome DevTools 的 Memory 面板、Performance Monitor 和 heap snapshot 定期排查。
Chrome DevTools 中观察 GC
Performance 面板
打开 DevTools → Performance 面板
点击录制,执行页面操作
停止录制,查看 GC 事件(灰色竖线)
关注 JS Heap 曲线的锯齿状波动——每次下降就是一次 GC
Memory 面板
Heap Snapshot:堆快照,查看对象类型和引用关系
Allocation Timeline:实时观察内存分配热点
Allocation Sampling:低开销的分配采样分析
拍两个快照做 Comparison → 找出未释放的对象
核心要点速览
核心算法
新生代 Scavenge(半空间复制)+ 老生代 Mark-Sweep / Mark-Compact
关键优化
增量标记 → 并发标记 → 并行清除 → Orinoco,STW < 10ms
写屏障
并发标记期间确保引用变更不丢失,是并发 GC 正确性的基石
分代假说
绝大多数对象「生来即死」,据此将堆分为新生代和老生代分别管理