一文搞懂 V8 引擎的垃圾回收
Category(分类): Browser Status: 已更新
原文写于较早的 V8 版本,重点介绍新生代、老生代、Scavenge、Mark-Sweep、Mark-Compact 和常见内存泄漏。本文尽量保留原文的解释、示意图和代码主题,同时补充 V8 Orinoco 之后的并行、增量、并发回收,以及 Node.js 和现代浏览器中的诊断方式。
V8 的内部实现会变化。文中出现的“两个半空间”“存活次数”“25%”“64 位约 1.4GB”等内容属于帮助理解的历史模型或旧版本经验,不是所有当前版本的固定规则。
引言:V8 为什么需要垃圾回收?
V8 是 Chrome、Node.js 等环境使用的 JavaScript 引擎之一。它负责解析和执行 JavaScript,也负责对象布局、内存分配、编译优化和垃圾回收。JavaScript 代码通常不需要像 C/C++ 那样显式调用 free,但这不代表内存无限,也不代表开发者不需要关注对象生命周期。
垃圾回收器需要周期性完成三类工作:
- 判断哪些对象仍然存活;
- 回收或复用不可达对象占用的空间;
- 必要时整理碎片,让后续分配更容易。
现代 V8 的 GC 已经不是“每次都暂停 JavaScript,然后单线程遍历整个堆”。不过,堆越大、存活对象越多、分配越频繁,GC 仍可能带来 CPU 消耗、内存带宽压力和可见的暂停。
一、垃圾回收判断的是可达性,而不是函数是否结束
早期文章常把过程描述为“函数执行完毕,作用域销毁,局部变量全部释放”。这在没有外部引用的简单函数中可以帮助入门,但更准确的规则是:
一个对象如果仍能从垃圾回收根集合沿引用关系访问到,就必须保留;无法访问到的对象才有机会被回收。
根集合可能包括:
- 全局对象和模块环境;
- 当前调用栈上的局部变量、参数和临时句柄;
- 活跃的定时器、事件监听器、Promise/异步任务和宿主对象引用;
- Worker、WebSocket、原生模块等宿主维护的引用;
- V8 和嵌入器为执行任务暂时保存的句柄。
例如,返回的函数会继续持有外层词法环境:
function createReader() {
const data = new Array(1000).fill('data')
return () => data.length
}
const reader = createReader()
console.log(reader())
// 只要 reader 仍然可达,data 就可能继续存活
这里不是“闭包导致泄漏”,而是闭包按语言规则保留了仍然需要的数据。只有当 reader 也不再被任何可达对象引用时,这个环境才有机会回收。
二、V8 的内存限制:历史数字不能照搬
原文提到 64 位系统约 1.4GB、32 位系统约 0.7GB 的 V8 堆上限。这些数字曾经是 Node.js/V8 资料中常见的经验值,但不能当作今天浏览器或 Node.js 的固定限制。实际可用堆空间会受到以下因素影响:
- V8 和 Node.js 版本;
- 32 位或 64 位架构;
- 指针压缩和堆布局;
- 浏览器进程、Node.js 进程和 Worker/Isolate 的不同配置;
- 操作系统、容器、cgroup 和物理内存;
--max-old-space-size等启动参数;- V8 为避免长时间 GC 而动态计算的堆增长限制。
在 Node.js 中,读取一个很大的文件时,不能只看“机器有多少内存”,还要区分 V8 堆、Buffer、原生模块和进程 RSS。可以通过参数调整老生代相关上限:
node --max-old-space-size=4096 app.mjs
这只是提高允许使用的上限,不会修复内存泄漏;设置过大也可能让系统直接终止进程。
原文还把“JS 单线程”作为 GC 停顿的主要原因。更准确的说法是:浏览器页面的 JavaScript 执行通常集中在某个渲染器线程,但 V8 可以使用辅助线程完成并行或并发 GC;Web Worker 拥有独立的 JavaScript 执行环境,不能直接操作主线程 DOM,却不改变单个 JavaScript 执行上下文中的语言语义。
Node.js 内存诊断
import process from 'node:process'
import v8 from 'node:v8'
console.table(process.memoryUsage())
console.table(v8.getHeapStatistics())
heapUsed 只反映 V8 堆中的一部分。处理 Buffer、ArrayBuffer、图片、原生模块或多个 Worker 时,还要观察 external、arrayBuffers 和 rss。


三、V8 的分代式内存结构
V8 垃圾回收建立在弱分代假说上:绝大部分对象的生命周期很短,真正长期存活的对象只占少数。因此,V8 会把对象按年龄和用途分到不同区域。

概念上可以这样理解:
| 区域或分代 | 作用 |
|---|---|
| 新生代 | 保存刚创建、预计生命周期较短的对象,通常由 Minor GC/Scavenger 处理 |
| 老生代 | 保存存活时间较长的对象,通常由 Major GC 处理 |
| 大对象区 | 保存体积较大的对象,通常不会像普通对象那样频繁移动 |
| 代码相关区域 | 保存编译生成的代码和相关数据 |
| Map、Trusted、共享等内部区域 | 保存对象布局、内部元数据或宿主/引擎专用对象,具体划分随版本变化 |
新版本资料还会把年轻对象进一步描述为 nursery 和 intermediate 等阶段。开发者应关注“短命对象优先在年轻代处理、长期存活对象逐步进入老生代”这个原则,不要依赖 V8 内部区域名称或固定容量。
四、新生代:Scavenge / Minor GC
4.1 两个半空间的历史模型
原文把新生代分为大小相等的 From-Space 和 To-Space。这是理解 Scavenge 的经典模型:
- 当前分配和扫描的空间叫
From-Space; - 空闲、用来接收存活对象的空间叫
To-Space; - 回收时只复制存活对象;
- 完成后两个空间交换角色。
这是一种“牺牲空间换时间”的方案。死亡对象所在空间可以整体复用,避免逐个清理空洞;如果存活对象太多,复制成本和空间压力就会上升。
4.2 Scavenge 流程图
下面保留原文的 A、B、C、D 示例。图中的空间名称是为了说明算法,实际 V8 会使用页、工作队列、转发地址和更复杂的实现。
- 假设
From-Space中分配了对象 A、B、C:

- 第一次 Minor GC 发现 A 已经不可达:

- B、C 仍然存活,被复制到
To-Space:

- 清空原
From-Space中的死亡对象:

- 两个空间交换角色,原来的
To-Space成为下一次分配空间:

- 下一轮任务在新的
From-Space中分配对象 D:

- 下一次回收发现 D 不再可达:

- B、C 继续存活,被复制到新的
To-Space:

- 清除本轮
From-Space中的死亡对象:

- 两个半空间再次交换角色:

在真实 V8 中,标记、疏散和更新指针可能交错进行;对象移动后会留下转发地址,帮助其他引用更新到新位置。现代 V8 还会使用并行 Scavenger 和线程本地分配缓冲区降低停顿。
4.3 写屏障:为什么不用每次扫描整个老生代?
假设老生代对象 A 指向了新生代对象 B。如果每次 Minor GC 都从头扫描完整老生代才能发现 B,成本会很高。写屏障会在引用关系变化时记录“老生代指向新生代”的信息,Minor GC 再把这类跨代引用与根集合一起作为扫描入口。
写屏障需要维护额外的记录,也不是完全没有成本,但通常比每次遍历整个老生代更划算。
4.4 对象晋升不是固定次数或固定 25%
存活对象最终可能从新生代晋升到老生代。原文保留了两张常见的晋升示意图:


旧资料经常总结为“存活一次就晋升”或“To-Space 使用率超过 25% 就晋升”。这些描述可以解释早期实现的启发式策略,但当前 V8 会结合对象年龄、页面空间、分配压力、疏散失败和版本实现决定晋升。不要在代码或性能预算中假设一个固定次数或百分比。
五、老生代:Mark-Sweep、Mark-Compact 与可达性
5.1 引用计数为什么不是 V8 的主要算法?
引用计数只统计“有多少个直接引用指向对象”。它实现简单,但两个对象互相引用时,即使整个环已经无法从程序根集合到达,计数仍然不为零:
function createCycle() {
const a = {}
const b = {}
a.other = b
b.other = a
}
createCycle()
现代浏览器和 V8 使用以可达性为核心的追踪式垃圾回收,因此上述函数结束且没有外部引用后,循环对象仍然可以被回收。这里不能说“循环引用一定造成内存泄漏”;真正的问题是循环仍被全局缓存、定时器、监听器或宿主对象间接持有。
5.2 Mark-Sweep:标记清除
Mark-Sweep 大致包含两个阶段:
- 从根集合出发遍历对象图,标记可达对象;
- 扫描页面,把未标记对象占用的空间加入空闲列表。

标记阶段可以用三色模型理解:白色表示未处理,灰色表示已经发现但仍有引用待扫描,黑色表示扫描完成。V8 会使用标记位图、工作列表和写屏障实现更复杂的并发标记过程。
Mark-Sweep 的优点是不需要移动所有存活对象;缺点是多次分配和释放后可能出现碎片。空闲内存总量足够,并不代表一定存在足够大的连续空间。
5.3 Mark-Compact:标记整理
当页面碎片较严重或需要整理时,V8 可能选择 Mark-Compact:
- 标记 A、C 等存活对象;
- 把适合移动的存活对象向一端疏散;
- 更新所有指向这些对象的引用;
- 释放边界外的空间。




整理可以缓解碎片,但移动对象需要更新引用,成本通常高于简单清扫。因此现代 V8 会选择性地整理部分页面,并使用并行、增量和并发技术降低主线程停顿。
六、现代 V8 的 Orinoco 回收器
Orinoco 是 V8 垃圾回收器演进项目的代号。它把早期较多的串行、全停顿工作拆分为更充分的并行、增量和并发阶段:
- 并行:JavaScript 暂停,但多个辅助线程共同完成一段 GC 工作,缩短总停顿;
- 增量:把标记或整理拆成小片段,片段之间允许 JavaScript 继续运行;
- 并发:辅助线程在 JavaScript 运行时完成部分标记或清扫;
- 并行 Scavenger:年轻代回收可以由多个工作线程共同疏散存活对象;
- 并发标记和清扫:老生代接近动态分配限制时,部分工作可以在后台开始。
这几种术语容易混淆:并行仍可能是 Stop-the-world,只是多个线程一起工作;增量是把主线程工作切成片段;并发则是辅助线程和 JavaScript 同时工作。它们都降低了停顿或改善了响应,但不会让 GC 完全免费。
七、避免和定位内存泄漏
7.1 避免意外全局变量
在 ES 模块和严格模式下,未声明赋值会直接抛出异常;旧的非严格脚本可能把它变成全局属性:
'use strict'
function update() {
// accidentalGlobal = new Array(100000) // ReferenceError
}
全局缓存和单例也可能是合法设计,但需要有上限、淘汰策略和清理入口。把对象设置为 null 只有在没有其他引用时才有帮助,不能替代查找保留路径。
7.2 清理定时器和监听器
const numbers = []
const timerId = window.setInterval(() => {
numbers.push(Date.now())
}, 1000)
const controller = new AbortController()
window.addEventListener('resize', handleResize, {
signal: controller.signal
})
function dispose() {
clearInterval(timerId)
controller.abort()
}
组件卸载、路由切换、任务结束时,应清理 setInterval、重试定时器、MutationObserver、ResizeObserver、WebSocket、Worker、订阅和未完成的请求。使用 AbortController 可以统一取消一批支持 signal 的 API。
7.3 不要把闭包当成原罪
闭包只表示函数保留了外层词法环境:
function foo() {
const local = 123
return () => local
}
const bar = foo()
console.log(bar())
只要 bar 还要读取 local,它继续存活是正确的。若长期缓存了大量闭包、监听器或回调,才需要检查它们是否不再必要却仍然可达。
7.4 清除分离 DOM 的外部引用
const button = document.querySelector('#button')
const cache = new Map([['button', button]])
button?.remove()
// 如果 cache 仍然存在,button 及其关联对象可能继续可达
cache.delete('button')
从文档中移除节点不会自动清除 JavaScript 缓存、闭包、事件监听器或第三方库引用。应在移除节点时清理业务缓存和组件资源。
7.5 WeakMap、WeakSet 和 WeakRef
WeakMap 的键是弱引用,适合给对象附加元数据而不延长对象生命周期:
const metadata = new WeakMap()
function attachMetadata(element, value) {
metadata.set(element, value)
}
弱集合不可枚举,因此不能用来统计当前有多少对象。WeakRef 和 FinalizationRegistry 的回收时机由引擎决定,不能依赖它们实现核心业务逻辑或确定性的资源释放。
八、用 global.gc() 观察 WeakMap(Node.js 示例)
Node.js 默认不开放手动 GC,可以使用以下方式启动测试进程:
node --expose-gc weak-map-demo.mjs
示例:
import v8 from 'node:v8'
if (typeof global.gc !== 'function') {
throw new Error('请使用 node --expose-gc 启动')
}
const weakMap = new WeakMap()
let key = new Array(1_000_000)
weakMap.set(key, { createdAt: Date.now() })
console.log('创建后:', v8.getHeapStatistics().used_heap_size)
global.gc()
console.log('仍有强引用:', v8.getHeapStatistics().used_heap_size)
key = null
global.gc()
console.log('失去强引用后:', v8.getHeapStatistics().used_heap_size)
即使调用了 global.gc(),堆大小也可能因为空闲列表、碎片、其他对象和统计时机而不会精确回到原值。这个示例只能帮助理解弱引用,不能作为业务代码或性能基准。
九、开发者应该如何排查?
浏览器
- 打开 Chrome DevTools Memory 面板,拍摄 Heap Snapshot;
- 比较多次快照中的对象数量、构造函数和 Retainers/保留路径;
- 使用 Allocation instrumentation on timeline 观察对象分配后是否持续存活;
- 检查 Detached DOM tree、Event Listener、Timer、闭包和无限增长的缓存;
- 配合 Performance 面板观察长任务、GC 事件和用户交互是否同时变慢。
Node.js
node --inspect app.mjs
node --trace-gc app.mjs
使用 --trace-gc 时应观察长时间趋势,不要根据一次 Minor GC 或一次堆扩容就判断存在泄漏。堆增长、RSS 增长、外部内存增长和不可回收对象增长是不同问题。
十、总结
- V8 通过可达性而不是引用计数判断对象是否可以回收;
- 新生代利用对象短命特征,用 Scavenge/Minor GC 快速疏散存活对象;
- 老生代主要使用 Mark-Sweep、Mark-Compact 以及选择性清扫和整理;
- 写屏障维护老生代到新生代的引用,避免每次 Minor GC 扫描整个老生代;
- Orinoco 让 V8 更多使用并行、增量和并发技术,但 GC 仍然会消耗资源;
- “闭包、循环引用、DOM 移除”本身不等于泄漏,关键在于对象是否仍从根集合可达;
- 固定堆大小、晋升次数和 25% 阈值属于历史实现,不能照搬到所有版本;
- 发现问题时优先用堆快照、保留路径、内存趋势和最小复现验证。