跳到主要内容

垃圾回收机制

面试速答(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;
}

如果只靠引用计数,ab 彼此引用,计数都不为 0,就可能无法回收。

2. 标记清除

现代引擎更核心的思路是标记清除:

  1. 从根对象出发遍历
  2. 能访问到的对象都标记为“活的”
  3. 没被标记到的对象就是垃圾
  4. 清理垃圾内存

它能自然解决循环引用问题,因为只看“从根出发能不能走到”,不看引用计数是否为 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 看的是可达性,不是业务语义上的‘没用了’。
  • 现代主流思路:标记清除 + 分代回收 + 降低停顿。
  • 前端排查泄漏,本质是找“本该断开的引用链”。