JS 的垃圾回收机制是怎样的?
一句话回答
JS 引擎按可达性判断对象能否回收:从根(全局对象、调用栈上的变量等)出发访问不到的对象就是垃圾。主流算法是标记清除,配合标记整理解决内存碎片。V8 在此基础上做了分代回收:新生代用 Scavenge 算法复制存活对象,老生代用标记清除和标记整理,再通过增量标记、并发标记、并行回收等技术缩短全停顿的时间。
详细解析
判断标准:可达性
从一组"根"出发,沿着引用能访问到的对象是"活"的,其余的都可以回收。根主要包括:
- 全局对象,例如浏览器中的
window - 当前调用栈上各个函数的局部变量和参数
- 宿主环境内部持有的引用,例如还没执行的定时器回调
判断的是"从根能不能访问到",而不是"有没有被引用":两个对象互相引用,但从根访问不到,照样会被回收。
引用计数
记录每个对象被引用的次数,降到 0 时回收。它无法处理循环引用:
function f() {
const a = {}
const b = {}
a.other = b
b.other = a // 互相引用,两者的引用次数都不会降到 0
}
f() // 执行完后 a、b 都不可达,但按引用计数永远不会被回收
所以现代 JS 引擎不把它作为主要算法。
标记清除与标记整理
- 标记:从根出发遍历,给所有可达对象做标记
- 清除:回收所有没有标记的对象
清除后空闲内存是零散的,这就是内存碎片,之后分配较大的对象时可能找不到足够的连续空间。标记整理在标记之后把存活对象移动到一起、紧凑排列,让空闲空间连成一片,代价是移动对象要花更多时间。
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 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。