V8 引擎垃圾回收与内存分配
Category(分类): Browser Status: 已更新
本文保留原文关于浏览器内核、栈与堆、弱分代假说、新生代、老生代、Scavenge、标记清除、标记整理、写屏障和 Stop-the-world 的主线。V8 的垃圾回收器一直在演进,内存大小、堆空间名称和晋升阈值都可能随版本、架构、宿主环境而变化,文中的固定数字仅作为历史背景,不能当作当前 V8 的保证。
一、浏览器内核、JavaScript 引擎和 V8
“浏览器内核”不是一个单独的程序。现代浏览器通常由渲染引擎、JavaScript 引擎、网络栈、图形系统和多个进程共同组成。常见组合包括:
| 浏览器或宿主 | 渲染引擎 | JavaScript 引擎 |
|---|---|---|
| Chrome、Edge 等 Chromium 浏览器 | Blink | V8 |
| Safari | WebKit | JavaScriptCore |
| Firefox | Gecko | SpiderMonkey |
| Node.js、Deno 等部分 JavaScript 宿主 | 无浏览器渲染引擎或使用宿主自己的渲染系统 | V8(Node.js、Deno 的具体版本和配置可能不同) |

因此,“JavaScript 垃圾回收”通常是口语说法;更严谨的说法是:JavaScript 引擎和宿主共同管理 JavaScript 对象、外部资源以及它们之间的可达关系。本文主要讨论 V8 堆中的 JavaScript 对象,不代表 Blink 的 DOM 垃圾回收或其他浏览器引擎的实现。
二、JavaScript 内存从哪里来?
JavaScript 不要求开发者手动调用 malloc 和 free。创建对象、数组、函数、字符串等值时,V8 会从自己的堆或相关分配区取得内存;对象不可达后,垃圾回收器会在合适的时机回收并复用空间。

1. 栈和堆只是帮助理解的模型
- 调用栈保存调用帧、部分局部状态和返回信息;它通常具有后进先出的结构;
- 堆保存需要动态管理的对象、数组、函数环境等;
- JavaScript 的闭包、宿主引用和引擎优化会让“每个变量都在栈上”或“所有对象都在堆上”的说法过于绝对;
- 变量本身只是绑定关系,真正需要垃圾回收的是对象及其关联的引擎数据。
函数执行结束后,局部变量并不一定马上释放。如果返回的函数、定时器、事件监听器或其他对象仍然可以通过引用访问它们,相关闭包环境仍然是可达的。
三、V8 的分代式垃圾回收
V8 的设计利用了弱分代假说(generational hypothesis):大量对象创建后很快就不再使用,少量对象会存活很久。因此,V8 通常把对象按年龄和用途分到不同区域,并使用不同的回收策略。
1. 常见的堆区域
V8 内部区域会随版本变化,不能把下表当作稳定的公开 API。为了理解垃圾回收,最重要的是新生代和老生代:
| 区域 | 作用(概念性描述) |
|---|---|
| 新生代 | 存放刚创建、预计生命周期较短的对象,回收频繁,通常采用复制/疏散式的 Minor GC |
| 老生代 | 存放存活时间较长的对象,通常采用标记、清除、整理以及并发/增量优化 |
| 大对象区 | 存放无法方便放入普通页的大对象,通常避免像普通对象那样频繁移动 |
| 代码相关区域 | 存放生成的机器码或编译相关数据,具体权限和布局由引擎管理 |
| Map、Trusted 等内部区域 | 存放隐藏类、内部元数据或受保护对象,名称和划分会随 V8 版本变化 |
过去的资料常把新生代描述为两个固定大小的半空间,把老生代描述为 64 位约 1.4GB。现在 V8 会根据指针压缩、平台、版本、宿主和动态内存压力调整策略;Node.js 还会受到命令行参数和进程资源限制影响。不要在业务代码中依赖这些数字。
四、新生代与 Scavenge
新生代适合生命周期短的对象。V8 采用过并且仍以其为基础的 Scavenge(清道夫)/Minor GC 思路,是用一部分空间换取快速回收:
- 新生代被划分为当前分配区域和用于接收存活对象的区域,历史资料称它们为
From-Space和To-Space; - 回收器从根、栈以及老生代指向新生代的记录中找到存活对象;
- 存活对象被疏散到新的连续区域,死亡对象所在的空间可以整体复用;
- 两个空间交换角色,下一轮在新的
From-Space中分配。

这种方式不需要逐个清理每个死亡对象的空洞,适合“绝大多数对象很快死亡”的场景。代价是需要保留备用空间,并且存活对象越多,复制成本越高。
写屏障和老生代到新生代的引用
如果新生代每次回收都遍历整个老生代,会非常浪费。V8 会在对象引用被写入时使用写屏障,记录老生代对象指向新生代对象的关系。Minor GC 再结合根和这些记录,就能找到需要保留的对象。
写屏障不是“防止垃圾回收”的开关,而是维护跨代引用信息的机制。频繁修改大量对象引用也可能带来额外的写屏障成本。
对象晋升
存活对象可能从新生代晋升到老生代。晋升不是简单的“存活一次就必然晋升”,V8 会根据对象年龄、空间压力、疏散结果和当前版本的启发式策略做决定。原文提到的“经历一次 Scavenge”或“To-Space 超过 25%”是旧实现中的常见解释,不应当作为今天所有版本的固定规则。
五、老生代:标记、清除与整理
老生代中的对象存活率更高,复制整块空间通常不划算,因此主要使用可达性分析:
- 从根集合出发遍历对象图;
- 标记能够到达的对象;
- 清理无法到达的对象并把空间加入空闲列表;
- 对碎片严重的页面选择性地整理或压缩,将部分存活对象移动到更连续的位置。

三色标记法
为了描述标记过程,常用白、灰、黑三种颜色:
- 白色:尚未确认可达;
- 灰色:已经发现,但其引用还没有完全扫描;
- 黑色:对象本身和需要扫描的引用都已经处理完成。

真实 V8 会使用标记位图、工作队列、写屏障和多个辅助线程来实现这些步骤。三色标记是理解算法的模型,不等同于 V8 源码中的全部数据结构。
Stop-the-world、增量、并行与并发
**Stop-the-world(全停顿)**指 JavaScript 执行需要在某个阶段暂停,以保证堆状态不会被同时修改。它并不意味着现代 V8 每次 GC 都会让所有回收工作在主线程上连续执行到底。
V8 的 Orinoco 项目引入和完善了多种降低暂停影响的技术:
- 并行(parallel):暂停 JavaScript,但让多个辅助线程共同完成 GC 工作;
- 增量(incremental):把一项 GC 工作拆成多个小片段,片段之间允许 JavaScript 继续运行;
- 并发(concurrent):让辅助线程在 JavaScript 运行时完成部分标记或清扫工作;
- 延迟清扫(lazy sweeping):按需逐步准备可用空间;
- 选择性整理:只整理碎片严重或适合移动的页面,不是每次都移动整个老生代。

这些优化可以减少主线程暂停,但不会让 GC 完全没有成本。分配压力、对象存活率、堆大小、设备性能和页面中的长任务都会影响最终体验。
六、Node.js 中查看和调整内存
浏览器页面通常不能直接设置 V8 的堆上限;Node.js 则提供了一些进程启动参数和诊断 API。
1. 查看当前进程内存
import process from 'node:process'
import v8 from 'node:v8'
console.table(process.memoryUsage())
console.table(v8.getHeapStatistics())
console.table(v8.getHeapSpaceStatistics())
process.memoryUsage() 常见字段包括:
rss:进程驻留集大小,包含 V8 堆、C++ 内存、线程栈、代码和其他映射;heapTotal:V8 当前申请的堆空间;heapUsed:V8 堆中正在使用的空间;external:由 V8 管理、但位于 V8 堆外的 C++ 资源;arrayBuffers:ArrayBuffer、SharedArrayBuffer等相关内存的统计项,具体展示依 Node.js 版本而定。
heapUsed 小并不代表整个 Node.js 进程占用小;处理图片、Buffer、原生模块或 Worker 时还要看 external、arrayBuffers 和 rss。
2. 调整 Node.js 的老生代上限
node --max-old-space-size=4096 app.mjs
该参数以 MiB 为单位设置老生代相关上限,适合在确认应用确实需要更大堆空间时使用。它不会修复内存泄漏,也不会把物理内存变多;设置过大还可能让进程在系统层面被 OOM 终止。
新生代半空间参数也可以调整,但属于更底层的调优项,必须结合版本和基准测试:
node --max-semi-space-size=32 app.mjs
不要把 Node.js 的启动参数当成浏览器端代码,也不要把 V8 堆上限等同于操作系统允许进程使用的全部内存。
七、常见内存泄漏模式
垃圾回收判断的是可达性,不是变量是否“看起来已经不用”。以下场景容易让对象继续从根集合可达:
1. 无意创建全局引用
在严格模式和 ES 模块中,给未声明变量赋值会抛错;在旧的非严格脚本中,它可能创建全局属性:
'use strict'
function update() {
// accidentalGlobal = new Array(100000) // ReferenceError
}
更重要的是显式的全局缓存、单例和模块级集合。它们的生命周期通常等于整个页面或进程,使用后应提供清理策略。
2. 定时器、事件监听器和订阅
const timerId = setInterval(() => {
// 如果回调闭包引用了大型对象,定时器存续期间对象也可能保持可达
}, 1000)
const controller = new AbortController()
window.addEventListener('resize', handleResize, { signal: controller.signal })
// 页面、组件或任务结束时
clearInterval(timerId)
controller.abort()
框架组件销毁时要取消订阅、观察器、网络重试、WebSocket 和 Worker;清除监听器时要匹配相同的函数和选项,使用 AbortSignal 可以简化统一取消。
3. 分离的 DOM 节点
从文档移除节点后,如果 JavaScript 缓存、闭包、监听器或第三方库仍然持有它,节点及其子树可能继续占用内存。移除 DOM 的同时清理相关缓存和业务引用;“调用 removeChild 就一定释放”是不准确的。
4. 闭包并不等于泄漏
闭包只是保留词法环境的语言机制。下面的代码会让 local 在 reader 仍可访问时继续存活,这是预期行为:
function createReader() {
const local = new Array(1000)
return () => local.length
}
const reader = createReader()
reader()
// 当 reader 不再被任何可达对象引用后,闭包环境才有机会回收
只有在不再需要时仍被长生命周期对象持有,闭包才会形成泄漏风险。
5. WeakMap、WeakSet 和弱引用
WeakMap 的键是弱引用:如果键对象没有其他强引用,垃圾回收器可以回收该键和关联条目。它适合保存对象元数据,不适合用来遍历或统计对象数量。
WeakRef 和 FinalizationRegistry 的回收时机不可预测,不能用来实现业务正确性、缓存过期计时或资源释放的唯一机制。需要确定性清理时,仍应显式调用 dispose、close 或取消订阅。
八、如何定位内存问题?
可以使用以下工具和方法:
- Chrome DevTools 的 Memory 面板拍摄 Heap Snapshot,比较对象数量和保留路径;
- 使用 Allocation instrumentation on timeline 观察对象分配后是否持续存活;
- 检查 Event Listener、Timer、Detached DOM tree 和长生命周期缓存;
- Node.js 使用
--inspect连接 DevTools,结合process.memoryUsage()记录趋势; - 必要时使用
--trace-gc观察 GC 频率和停顿,但不要只凭一条日志判断泄漏; - 让测试运行足够长,并区分正常缓存增长、堆扩容、RSS 增长和真正不可回收对象增长。
九、小结
- V8 通过可达性分析和分代式策略自动管理 JavaScript 对象;
- 新生代适合使用 Minor GC 快速处理短命对象,老生代使用标记、清扫和选择性整理;
- 写屏障维护跨代引用,避免每次 Minor GC 遍历整个老生代;
- Orinoco 让 V8 的 GC 更多使用并行、增量和并发工作,但 GC 仍然会消耗 CPU、内存带宽和暂停时间;
- 旧文章中的堆大小、半空间大小、25% 晋升阈值等数字只能作为历史实现,不应硬编码;
- 内存泄漏的关键不是“有没有手动 free”,而是对象是否仍然从根集合可达;
- 发现问题时应使用堆快照和保留路径验证,而不是盲目增加
--max-old-space-size。