技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTBR

一文搞懂 V8 引擎的垃圾回收

所属馆藏
Browser
文件格式
Markdown
原始路径
Browser/07-一文搞懂V8引擎的垃圾回收
本文目录12 个章节
  1. 引言:V8 为什么需要垃圾回收?
  2. 一、垃圾回收判断的是可达性,而不是函数是否结束
  3. 二、V8 的内存限制:历史数字不能照搬
  4. 三、V8 的分代式内存结构
  5. 四、新生代:Scavenge / Minor GC
  6. 五、老生代:Mark-Sweep、Mark-Compact 与可达性
  7. 六、现代 V8 的 Orinoco 回收器
  8. 七、避免和定位内存泄漏
  9. 八、用 global.gc() 观察 WeakMap(Node.js 示例)
  10. 九、开发者应该如何排查?
  11. 十、总结
  12. 参考资料

一文搞懂 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,但这不代表内存无限,也不代表开发者不需要关注对象生命周期。

垃圾回收器需要周期性完成三类工作:

  1. 判断哪些对象仍然存活;
  2. 回收或复用不可达对象占用的空间;
  3. 必要时整理碎片,让后续分配更容易。

现代 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 堆中的一部分。处理 BufferArrayBuffer、图片、原生模块或多个 Worker 时,还要观察 externalarrayBuffersrss

Node.js 可用 V8 选项示意

Node.js process.memoryUsage() 示意

三、V8 的分代式内存结构

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

V8 堆内存结构示意

概念上可以这样理解:

区域或分代作用
新生代保存刚创建、预计生命周期较短的对象,通常由 Minor GC/Scavenger 处理
老生代保存存活时间较长的对象,通常由 Major GC 处理
大对象区保存体积较大的对象,通常不会像普通对象那样频繁移动
代码相关区域保存编译生成的代码和相关数据
Map、Trusted、共享等内部区域保存对象布局、内部元数据或宿主/引擎专用对象,具体划分随版本变化

新版本资料还会把年轻对象进一步描述为 nursery 和 intermediate 等阶段。开发者应关注“短命对象优先在年轻代处理、长期存活对象逐步进入老生代”这个原则,不要依赖 V8 内部区域名称或固定容量。

四、新生代:Scavenge / Minor GC

4.1 两个半空间的历史模型

原文把新生代分为大小相等的 From-SpaceTo-Space。这是理解 Scavenge 的经典模型:

  • 当前分配和扫描的空间叫 From-Space
  • 空闲、用来接收存活对象的空间叫 To-Space
  • 回收时只复制存活对象;
  • 完成后两个空间交换角色。

这是一种“牺牲空间换时间”的方案。死亡对象所在空间可以整体复用,避免逐个清理空洞;如果存活对象太多,复制成本和空间压力就会上升。

4.2 Scavenge 流程图

下面保留原文的 A、B、C、D 示例。图中的空间名称是为了说明算法,实际 V8 会使用页、工作队列、转发地址和更复杂的实现。

  1. 假设 From-Space 中分配了对象 A、B、C:

From-Space 中分配对象 A、B、C

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

第一次回收发现对象 A 不可达

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

存活对象 B、C 被复制到 To-Space

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

清理原 From-Space

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

From-Space 和 To-Space 交换角色

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

下一轮分配对象 D

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

第二次回收发现对象 D 不可达

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

第二次复制存活对象 B、C

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

第二次清理死亡对象

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

第二次交换两个半空间

在真实 V8 中,标记、疏散和更新指针可能交错进行;对象移动后会留下转发地址,帮助其他引用更新到新位置。现代 V8 还会使用并行 Scavenger 和线程本地分配缓冲区降低停顿。

4.3 写屏障:为什么不用每次扫描整个老生代?

假设老生代对象 A 指向了新生代对象 B。如果每次 Minor GC 都从头扫描完整老生代才能发现 B,成本会很高。写屏障会在引用关系变化时记录“老生代指向新生代”的信息,Minor GC 再把这类跨代引用与根集合一起作为扫描入口。

写屏障需要维护额外的记录,也不是完全没有成本,但通常比每次遍历整个老生代更划算。

4.4 对象晋升不是固定次数或固定 25%

存活对象最终可能从新生代晋升到老生代。原文保留了两张常见的晋升示意图:

对象经历 Scavenge 后晋升到老生代

To-Space 空间压力导致对象晋升

旧资料经常总结为“存活一次就晋升”或“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 大致包含两个阶段:

  1. 从根集合出发遍历对象图,标记可达对象;
  2. 扫描页面,把未标记对象占用的空间加入空闲列表。

Mark-Sweep 从根集合标记可达对象

标记阶段可以用三色模型理解:白色表示未处理,灰色表示已经发现但仍有引用待扫描,黑色表示扫描完成。V8 会使用标记位图、工作列表和写屏障实现更复杂的并发标记过程。

Mark-Sweep 的优点是不需要移动所有存活对象;缺点是多次分配和释放后可能出现碎片。空闲内存总量足够,并不代表一定存在足够大的连续空间。

5.3 Mark-Compact:标记整理

当页面碎片较严重或需要整理时,V8 可能选择 Mark-Compact:

  1. 标记 A、C 等存活对象;
  2. 把适合移动的存活对象向一端疏散;
  3. 更新所有指向这些对象的引用;
  4. 释放边界外的空间。

Mark-Compact:标记 A、C

Mark-Compact:移动存活对象

Mark-Compact:更新对象位置

Mark-Compact:释放整理后的空闲区域

整理可以缓解碎片,但移动对象需要更新引用,成本通常高于简单清扫。因此现代 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、重试定时器、MutationObserverResizeObserver、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)
}

弱集合不可枚举,因此不能用来统计当前有多少对象。WeakRefFinalizationRegistry 的回收时机由引擎决定,不能依赖它们实现核心业务逻辑或确定性的资源释放。

八、用 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(),堆大小也可能因为空闲列表、碎片、其他对象和统计时机而不会精确回到原值。这个示例只能帮助理解弱引用,不能作为业务代码或性能基准。

九、开发者应该如何排查?

浏览器

  1. 打开 Chrome DevTools Memory 面板,拍摄 Heap Snapshot;
  2. 比较多次快照中的对象数量、构造函数和 Retainers/保留路径;
  3. 使用 Allocation instrumentation on timeline 观察对象分配后是否持续存活;
  4. 检查 Detached DOM tree、Event Listener、Timer、闭包和无限增长的缓存;
  5. 配合 Performance 面板观察长任务、GC 事件和用户交互是否同时变慢。

Node.js

node --inspect app.mjs
node --trace-gc app.mjs

使用 --trace-gc 时应观察长时间趋势,不要根据一次 Minor GC 或一次堆扩容就判断存在泄漏。堆增长、RSS 增长、外部内存增长和不可回收对象增长是不同问题。

十、总结

  1. V8 通过可达性而不是引用计数判断对象是否可以回收;
  2. 新生代利用对象短命特征,用 Scavenge/Minor GC 快速疏散存活对象;
  3. 老生代主要使用 Mark-Sweep、Mark-Compact 以及选择性清扫和整理;
  4. 写屏障维护老生代到新生代的引用,避免每次 Minor GC 扫描整个老生代;
  5. Orinoco 让 V8 更多使用并行、增量和并发技术,但 GC 仍然会消耗资源;
  6. “闭包、循环引用、DOM 移除”本身不等于泄漏,关键在于对象是否仍从根集合可达;
  7. 固定堆大小、晋升次数和 25% 阈值属于历史实现,不能照搬到所有版本;
  8. 发现问题时优先用堆快照、保留路径、内存趋势和最小复现验证。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS