JS 的垃圾回收机制是怎样的?

深入原理约 5 分钟读完

一句话回答

JS 引擎按可达性判断对象能否回收:从根(全局对象、调用栈上的变量等)出发访问不到的对象就是垃圾。主流算法是标记清除,配合标记整理解决内存碎片。V8 在此基础上做了分代回收:新生代用 Scavenge 算法复制存活对象,老生代用标记清除和标记整理,再通过增量标记、并发标记、并行回收等技术缩短全停顿的时间。

详细解析

判断标准:可达性

从一组"根"出发,沿着引用能访问到的对象是"活"的,其余的都可以回收。根主要包括:

  • 全局对象,例如浏览器中的 window
  • 当前调用栈上各个函数的局部变量和参数
  • 宿主环境内部持有的引用,例如还没执行的定时器回调

判断的是"从根能不能访问到",而不是"有没有被引用":两个对象互相引用,但从根访问不到,照样会被回收。

引用计数

记录每个对象被引用的次数,降到 0 时回收。它无法处理循环引用:

JavaScript
function f() {
  const a = {}
  const b = {}
  a.other = b
  b.other = a // 互相引用,两者的引用次数都不会降到 0
}
f() // 执行完后 a、b 都不可达,但按引用计数永远不会被回收

所以现代 JS 引擎不把它作为主要算法。

标记清除与标记整理

  1. 标记:从根出发遍历,给所有可达对象做标记
  2. 清除:回收所有没有标记的对象

清除后空闲内存是零散的,这就是内存碎片,之后分配较大的对象时可能找不到足够的连续空间。标记整理在标记之后把存活对象移动到一起、紧凑排列,让空闲空间连成一片,代价是移动对象要花更多时间。

V8 的分代回收

大多数对象创建后很快就不再使用,少数对象会存活很久。V8 据此把堆分成新生代和老生代,分别使用不同的算法:

新生代 老生代
存放 大多数新创建的对象 存活较久的对象
空间大小 小 大
特点 大多数对象很快死亡,回收频繁 存活率高,回收频率低
算法 Scavenge(复制) 标记清除 + 标记整理

Scavenge:新生代分为两个大小相同的半空间,新对象分配在其中一个(From)。回收时把 From 中的存活对象复制到另一个空闲的半空间(To),依次紧密排列,然后清空 From,两者交换角色。经过一定次数回收仍然存活的对象会晋升到老生代。老生代对象引用新生代对象的情况由写屏障记录下来,回收新生代时把它们也当作根,不需要扫描整个老生代。

减少全停顿

垃圾回收时暂停 JS 执行的这段时间叫全停顿(stop-the-world)。老生代空间大,如果一口气完成整个标记和清理,停顿时间很长,页面会明显卡顿。V8 的垃圾回收器(项目名 Orinoco)用了几种技术缩短停顿:

  • 增量标记:把标记工作拆成许多小步,和 JS 交替执行,每次只暂停很短的时间
  • 并发标记:在后台线程做标记,主线程继续执行 JS
  • 并行回收:暂停期间由主线程和多个辅助线程一起完成回收工作,缩短暂停时间

增量标记靠三色标记记录进度:白色是还没访问到的对象,灰色是自身已标记、但它引用的对象还没处理完,黑色是自身和引用都处理完了;暂停后从灰色对象继续,标记结束时仍是白色的对象就是垃圾。标记与 JS 交替或同时进行时,JS 可能修改对象之间的引用,V8 用写屏障记录这些修改,保证不会漏标仍然存活的对象。

开发者能做什么

  • 及时解除不再需要的长期引用:清除定时器、移除事件监听、清理缓存
  • 避免意外的全局变量,给缓存设置上限,常见问题见内存泄漏
  • 以对象为键关联数据时用 WeakMap,它不会阻止键对象被回收;WeakRef 可以持有不阻止回收的引用,见 Map 和 WeakMap

面试官可能追问

什么是全停顿?为什么要优化它?

回收器标记对象时,如果 JS 同时在修改引用关系,标记结果就可能出错,最简单的做法是暂停 JS,回收完再继续,这就是全停顿。暂停期间页面不能响应交互,也不能渲染;暂停一长就会掉帧、卡顿,在服务端则表现为请求延迟突然升高。所以 V8 尽量把回收工作拆小,或者放到后台线程、多个线程并行完成。

需要手动把变量置为 null 吗?

大多数情况不需要。局部变量在函数执行完后,只要没有被闭包等引用,自然就不可达了。需要手动断开的是生命周期很长的引用,比如全局变量、模块级的缓存、挂在长期存在的对象上的数据。置为 null 只是断开引用,内存什么时候真正回收仍由引擎决定。

新生代为什么适合用复制算法?

复制算法的开销取决于存活对象的数量。新生代中大多数对象很快死亡,每次只需复制少量存活对象,成本低;复制后对象紧密排列,没有碎片,分配新对象时接着往后放就行。代价是总有一半空间闲置,新生代本身很小,这点浪费可以接受。老生代对象多、存活率高、空间大,复制成本高,就不适合。

易错点

  • 判断能否回收的标准是"从根是否可达",不是"是否还被引用"
  • 把变量置为 null 只是断开一个引用,不会立刻释放内存;对象没有其他引用时,才会在之后的某次回收中被清理
  • JS 没有标准的 API 主动触发垃圾回收;Node.js 用 --expose-gc 启动后可以调用 gc(),一般只用于调试

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。