垃圾回收机制
面试速答(30 秒版 TL;DR)
- 浏览器里的 JavaScript 采用自动垃圾回收(GC, Garbage Collection),开发者不直接手动释放普通对象内存。
- 核心判断标准不是“变量还在不在作用域里”,而是对象是否仍然可达(Reachable)。
- 常见算法思想包括:引用计数和标记清除(Mark-and-Sweep),现代引擎通常以标记清除体系为主,并做分代回收、增量回收等优化。
- 真正的工程重点不是背算法名,而是知道:闭包、定时器、事件监听、全局引用、DOM 脱离文档后的残留引用 都可能导致内存泄漏。
心智模型:垃圾不是“没用”,而是“不可达”
来看一个最简单的例子:
let user = { name: 'terry' };
user = null;
这里原来的对象什么时候能被回收?
关键不在于它“业务上没用了”,而在于:
- 是否还能从根对象一路访问到它
常见根对象(Root)包括:
- 全局对象
- 当前调用栈中的局部变量
- 浏览器内部仍保留的活动引用
如果从这些根出发,已经走不到某个对象,那么这个对象才有资格被回收。
经典算法:引用计数 vs 标记清除
1. 引用计数
思路很直接:
- 一个对象被引用一次,计数加一
- 失去一次引用,计数减一
- 计数为 0 时可回收
优点:
- 实现直观
缺点:
- 解决不了循环引用
function createCycle() {
const a = {};
const b = {};
a.ref = b;
b.ref = a;
return null;
}
如果只靠引用计数,a 和 b 彼此引用,计数都不为 0,就可能无法回收。
2. 标记清除
现代引擎更核心的思路是标记清除:
- 从根对象出发遍历
- 能访问到的对象都标记为“活的”
- 没被标记到的对象就是垃圾
- 清理垃圾内存
它能自然解决循环引用问题,因为只看“从根出发能不能走到”,不看引用计数是否为 0。
为什么现代引擎还要分代回收
因为对象并不是同样“寿命长”。
经验上:
- 很多对象创建后很快就死掉
- 少部分对象会活很久
所以现代 JS 引擎通常会把堆拆成:
- 新生代:短命对象多,回收频率高
- 老生代:长寿对象多,回收成本更高
这样做的收益是:
- 短命对象不用每次都做重型全堆扫描
- 长寿对象单独处理,更高效
为什么 GC 会影响页面卡顿
GC 不是免费的。
如果一次回收需要暂停 JS 执行,就会带来停顿。为了减少长时间停顿,现代引擎会做:
- 增量标记
- 并发/并行回收
- 分代回收
但即便如此,如果页面对象创建和销毁过于频繁,或者堆内存膨胀严重,仍可能带来卡顿和掉帧。
前端最常见的内存泄漏场景
1. 意外的全局变量
function init() {
cache = new Array(100000).fill('x');
}
如果没有严格模式,这种写法可能挂到全局对象上,生命周期异常变长。
2. 定时器没清理
const timer = setInterval(() => {
console.log('tick');
}, 1000);
// 页面销毁时忘记 clearInterval(timer)
3. 事件监听没移除
function handleScroll() {}
window.addEventListener('scroll', handleScroll);
如果组件卸载后忘记 removeEventListener,相关闭包对象可能持续存活。
4. 闭包持有大对象
function createHandler(data) {
return function () {
console.log(data.length);
};
}
如果 data 很大,而闭包长期存在,这块内存就无法释放。
5. 脱离文档的 DOM 仍被 JS 引用
let detached = document.getElementById('panel');
document.body.removeChild(detached);
DOM 虽然从页面上删掉了,但只要 JS 变量还引用着它,它就仍然是可达对象。
面试里很加分的一句话
JavaScript 的垃圾回收是自动的,但“自动”不等于“我不用关心内存”。工程上真正要关注的是对象为什么一直可达,以及哪些引用链本该断却没断。
典型题 & 标准答法
Q1:JavaScript 的垃圾回收机制是什么?
答:JavaScript 使用自动垃圾回收机制。核心判断标准是对象是否仍然可达,而不是变量名是否还存在。经典算法有引用计数和标记清除,现代浏览器引擎主要基于标记清除,并结合分代回收、增量回收等策略降低停顿和提升效率。
Q2:为什么引用计数解决不了循环引用?
答:因为循环引用的对象彼此仍然持有引用,计数不会降到 0,即使它们已经无法从根对象访问。标记清除则是从根出发看可达性,因此能回收这类对象。
Q3:前端常见的内存泄漏有哪些?
答:全局变量、未清理的定时器、未移除的事件监听、闭包长期持有大对象、以及脱离文档但仍被引用的 DOM 节点,都是典型内存泄漏来源。
常见追问
- 堆和栈分别存什么?
WeakMap/WeakSet为什么能帮助做缓存?- 如何用 Chrome DevTools 排查内存泄漏?
易错点
- 不要说“变量离开作用域就一定被回收”。如果还有闭包或其它引用,未必会回收。
- 不要把“循环引用一定泄漏”当成现代 JS 的结论。现代引擎主要靠可达性判断,不是单纯引用计数。
- 不要误解
delete。它删除的是对象属性,不是“强制释放内存”命令。
速记要点
- GC 看的是可达性,不是业务语义上的‘没用了’。
- 现代主流思路:标记清除 + 分代回收 + 降低停顿。
- 前端排查泄漏,本质是找“本该断开的引用链”。