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

显示模式

登录
ARCHIVE DOCUMENTBR

V8 引擎垃圾回收与内存分配

所属馆藏
Browser
文件格式
Markdown
原始路径
Browser/06-V8 引擎垃圾回收与内存分配
本文目录10 个章节
  1. 一、浏览器内核、JavaScript 引擎和 V8
  2. 二、JavaScript 内存从哪里来?
  3. 三、V8 的分代式垃圾回收
  4. 四、新生代与 Scavenge
  5. 五、老生代:标记、清除与整理
  6. 六、Node.js 中查看和调整内存
  7. 七、常见内存泄漏模式
  8. 八、如何定位内存问题?
  9. 九、小结
  10. 参考资料

V8 引擎垃圾回收与内存分配

Category(分类): Browser Status: 已更新

本文保留原文关于浏览器内核、栈与堆、弱分代假说、新生代、老生代、Scavenge、标记清除、标记整理、写屏障和 Stop-the-world 的主线。V8 的垃圾回收器一直在演进,内存大小、堆空间名称和晋升阈值都可能随版本、架构、宿主环境而变化,文中的固定数字仅作为历史背景,不能当作当前 V8 的保证。

一、浏览器内核、JavaScript 引擎和 V8

“浏览器内核”不是一个单独的程序。现代浏览器通常由渲染引擎、JavaScript 引擎、网络栈、图形系统和多个进程共同组成。常见组合包括:

浏览器或宿主渲染引擎JavaScript 引擎
Chrome、Edge 等 Chromium 浏览器BlinkV8
SafariWebKitJavaScriptCore
FirefoxGeckoSpiderMonkey
Node.js、Deno 等部分 JavaScript 宿主无浏览器渲染引擎或使用宿主自己的渲染系统V8(Node.js、Deno 的具体版本和配置可能不同)

浏览器渲染引擎与 JavaScript 引擎示意

因此,“JavaScript 垃圾回收”通常是口语说法;更严谨的说法是:JavaScript 引擎和宿主共同管理 JavaScript 对象、外部资源以及它们之间的可达关系。本文主要讨论 V8 堆中的 JavaScript 对象,不代表 Blink 的 DOM 垃圾回收或其他浏览器引擎的实现。

二、JavaScript 内存从哪里来?

JavaScript 不要求开发者手动调用 mallocfree。创建对象、数组、函数、字符串等值时,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 思路,是用一部分空间换取快速回收:

  1. 新生代被划分为当前分配区域和用于接收存活对象的区域,历史资料称它们为 From-SpaceTo-Space
  2. 回收器从根、栈以及老生代指向新生代的记录中找到存活对象;
  3. 存活对象被疏散到新的连续区域,死亡对象所在的空间可以整体复用;
  4. 两个空间交换角色,下一轮在新的 From-Space 中分配。

Scavenge 新生代回收示意

这种方式不需要逐个清理每个死亡对象的空洞,适合“绝大多数对象很快死亡”的场景。代价是需要保留备用空间,并且存活对象越多,复制成本越高。

写屏障和老生代到新生代的引用

如果新生代每次回收都遍历整个老生代,会非常浪费。V8 会在对象引用被写入时使用写屏障,记录老生代对象指向新生代对象的关系。Minor GC 再结合根和这些记录,就能找到需要保留的对象。

写屏障不是“防止垃圾回收”的开关,而是维护跨代引用信息的机制。频繁修改大量对象引用也可能带来额外的写屏障成本。

对象晋升

存活对象可能从新生代晋升到老生代。晋升不是简单的“存活一次就必然晋升”,V8 会根据对象年龄、空间压力、疏散结果和当前版本的启发式策略做决定。原文提到的“经历一次 Scavenge”或“To-Space 超过 25%”是旧实现中的常见解释,不应当作为今天所有版本的固定规则。

五、老生代:标记、清除与整理

老生代中的对象存活率更高,复制整块空间通常不划算,因此主要使用可达性分析:

  1. 从根集合出发遍历对象图;
  2. 标记能够到达的对象;
  3. 清理无法到达的对象并把空间加入空闲列表;
  4. 对碎片严重的页面选择性地整理或压缩,将部分存活对象移动到更连续的位置。

老生代空间结构示意

三色标记法

为了描述标记过程,常用白、灰、黑三种颜色:

  • 白色:尚未确认可达;
  • 灰色:已经发现,但其引用还没有完全扫描;
  • 黑色:对象本身和需要扫描的引用都已经处理完成。

三色标记法示意

真实 V8 会使用标记位图、工作队列、写屏障和多个辅助线程来实现这些步骤。三色标记是理解算法的模型,不等同于 V8 源码中的全部数据结构。

Stop-the-world、增量、并行与并发

**Stop-the-world(全停顿)**指 JavaScript 执行需要在某个阶段暂停,以保证堆状态不会被同时修改。它并不意味着现代 V8 每次 GC 都会让所有回收工作在主线程上连续执行到底。

V8 的 Orinoco 项目引入和完善了多种降低暂停影响的技术:

  • 并行(parallel):暂停 JavaScript,但让多个辅助线程共同完成 GC 工作;
  • 增量(incremental):把一项 GC 工作拆成多个小片段,片段之间允许 JavaScript 继续运行;
  • 并发(concurrent):让辅助线程在 JavaScript 运行时完成部分标记或清扫工作;
  • 延迟清扫(lazy sweeping):按需逐步准备可用空间;
  • 选择性整理:只整理碎片严重或适合移动的页面,不是每次都移动整个老生代。

V8 垃圾回收并行、增量和并发示意

这些优化可以减少主线程暂停,但不会让 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++ 资源;
  • arrayBuffersArrayBufferSharedArrayBuffer 等相关内存的统计项,具体展示依 Node.js 版本而定。

heapUsed 小并不代表整个 Node.js 进程占用小;处理图片、Buffer、原生模块或 Worker 时还要看 externalarrayBuffersrss

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. 闭包并不等于泄漏

闭包只是保留词法环境的语言机制。下面的代码会让 localreader 仍可访问时继续存活,这是预期行为:

function createReader() {
  const local = new Array(1000)
  return () => local.length
}

const reader = createReader()
reader()
// 当 reader 不再被任何可达对象引用后,闭包环境才有机会回收

只有在不再需要时仍被长生命周期对象持有,闭包才会形成泄漏风险。

5. WeakMapWeakSet 和弱引用

WeakMap 的键是弱引用:如果键对象没有其他强引用,垃圾回收器可以回收该键和关联条目。它适合保存对象元数据,不适合用来遍历或统计对象数量。

WeakRefFinalizationRegistry 的回收时机不可预测,不能用来实现业务正确性、缓存过期计时或资源释放的唯一机制。需要确定性清理时,仍应显式调用 disposeclose 或取消订阅。

八、如何定位内存问题?

可以使用以下工具和方法:

  1. Chrome DevTools 的 Memory 面板拍摄 Heap Snapshot,比较对象数量和保留路径;
  2. 使用 Allocation instrumentation on timeline 观察对象分配后是否持续存活;
  3. 检查 Event Listener、Timer、Detached DOM tree 和长生命周期缓存;
  4. Node.js 使用 --inspect 连接 DevTools,结合 process.memoryUsage() 记录趋势;
  5. 必要时使用 --trace-gc 观察 GC 频率和停顿,但不要只凭一条日志判断泄漏;
  6. 让测试运行足够长,并区分正常缓存增长、堆扩容、RSS 增长和真正不可回收对象增长。

九、小结

  • V8 通过可达性分析和分代式策略自动管理 JavaScript 对象;
  • 新生代适合使用 Minor GC 快速处理短命对象,老生代使用标记、清扫和选择性整理;
  • 写屏障维护跨代引用,避免每次 Minor GC 遍历整个老生代;
  • Orinoco 让 V8 的 GC 更多使用并行、增量和并发工作,但 GC 仍然会消耗 CPU、内存带宽和暂停时间;
  • 旧文章中的堆大小、半空间大小、25% 晋升阈值等数字只能作为历史实现,不应硬编码;
  • 内存泄漏的关键不是“有没有手动 free”,而是对象是否仍然从根集合可达;
  • 发现问题时应使用堆快照和保留路径验证,而不是盲目增加 --max-old-space-size

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS