JavaScript 垃圾回收机制

面试回答: JavaScript 引擎会自动管理内存。垃圾回收器从全局对象、当前调用栈等根节点出发,标记所有仍然可达的对象,再回收不可达对象。现代引擎通常还会结合分代、增量和并发回收降低停顿时间。需要注意,GC 判断的是对象是否“可达”,而不是业务上是否“还有用”;无用对象如果仍被定时器、事件监听器或全局缓存引用,依然不会被回收,这才会形成内存泄漏。

1. 内存的生命周期

JavaScript 中的内存使用可以概括为三个阶段:

  1. 分配:创建对象、数组、函数或闭包时,引擎为其分配内存。
  2. 使用:代码通过变量和属性引用这些值。
  3. 回收:当一个值无法再从任何有效入口访问时,引擎释放其占用的内存。

开发者通常不需要手动释放内存,但需要管理好对象之间的引用关系。

2. GC 如何判断对象能否回收?

核心判断标准是可达性(Reachability)

垃圾回收器会从一组天然可达的“根”开始查找,例如:

  • 全局对象以及全局变量;
  • 当前调用栈中的局部变量和函数参数;
  • 正在执行的闭包所保留的变量;
  • 浏览器宿主仍持有的回调或 DOM 引用。

从根节点沿着引用关系能够访问到的对象称为可达对象,必须保留;无法从任何根节点访问到的对象称为不可达对象,可以回收。

let user = {
  profile: {
    name: 'Ada'
  }
};

const profile = user.profile;
user = null;

// 原来的 user 对象已经不可达,可以被回收;
// profile 对象仍然被变量 profile 引用,不能回收。

因此,把某个变量赋值为 null 并不会“强制 GC”,它只是断开了一条引用。对象是否能被回收,取决于是否还存在其他可达路径。

3. 引用计数为什么解决不了循环引用?

引用计数的思路是记录一个对象被引用的次数,计数归零时立即回收。它容易理解,但单独使用时无法正确处理循环引用:

function createCycle() {
  const left = {};
  const right = {};

  left.peer = right;
  right.peer = left;
}

函数执行结束后,外部已经无法访问 leftright,但两个对象仍互相引用。如果只看引用次数,它们的计数都不为零;如果看可达性,它们已经无法从根节点访问,因此可以一起回收。

现代 JavaScript 引擎不会仅靠引用计数完成垃圾回收。

4. 标记清除的基本过程

标记清除(Mark-and-Sweep)是理解现代 GC 的基础模型:

  1. 标记:从根节点出发遍历对象图,标记所有可达对象。
  2. 清除:回收没有被标记的对象所占用的内存。

循环引用本身并不等于内存泄漏。只要整个循环引用结构已经与根节点断开,它仍然会被标记清除算法回收。

5. 现代引擎如何降低 GC 停顿?

如果每次回收都完整扫描整个堆,主线程可能出现明显停顿。现代引擎会在可达性分析的基础上组合多种优化策略。

(1) 分代回收

大多数新对象存活时间很短,因此引擎通常按对象存活时间划分内存区域:

  • 新生代:存放新创建的对象,空间较小,回收频繁;
  • 老生代:存放经历多次回收后仍然存活的对象,回收频率较低。

以 V8 为例,新生代常使用复制式回收,老生代则结合标记清除和标记整理。分代的重点不是算法名称,而是避免每次都扫描全部长期存活对象。

(2) 增量与并发回收

  • 增量标记:把一次较长的标记工作拆成多个小步骤,穿插在 JavaScript 执行之间。
  • 并发回收:将部分 GC 工作放到辅助线程执行,减少主线程承担的工作量。
  • 空闲时回收:尽量利用主线程相对空闲的时间执行合适的回收任务。

这些策略只能降低停顿影响,无法保证 GC 完全不打断程序执行。

6. GC 为什么无法自动解决内存泄漏?

GC 只知道“还能不能访问”,不知道“业务上还需不需要”。

const cache = [];

function remember(value) {
  cache.push(value);
}

只要全局变量 cache 一直存在,其中的对象就始终可达。即使业务已经不再需要这些对象,垃圾回收器也不能擅自释放它们。

这也是垃圾回收与内存泄漏的边界:

  • 垃圾回收解决不可达对象的自动释放;
  • 内存泄漏通常来自无用对象仍被长期存活的引用持有。

具体的泄漏场景和排查方法见常见内存泄漏

7. WeakMap 与弱引用

当对象关联数据不应该阻止对象被回收时,可以考虑 WeakMap

const metadata = new WeakMap();

function attachMetadata(element, data) {
  metadata.set(element, data);
}

WeakMap 不会因为键仍存在于映射中,就单独阻止该键对象被回收。它适合保存 DOM 元素、组件实例等对象的附加信息。

但弱引用不是通用缓存方案:其内容何时被回收不可预测,也不能依赖它完成必须执行的资源清理。定时器、事件监听器、网络连接和 Worker 仍然需要显式关闭或解绑。

8. 高频追问

循环引用一定会导致内存泄漏吗?

不一定。现代 GC 判断的是对象是否从根节点可达,而不是对象之间是否互相引用。只有循环引用结构仍被某个根节点间接持有时,它才无法被回收。

可以在 JavaScript 中主动触发 GC 吗?

标准 JavaScript 没有提供可靠的强制 GC API。开发者应该解除不必要的引用和外部资源,而不是依赖手动触发垃圾回收。

内存占用高就代表发生泄漏了吗?

不代表。引擎可能保留已申请的堆空间以便复用,短时间的大对象分配也会造成峰值。真正需要关注的是:重复执行同一操作并经过 GC 后,仍有不应存活的对象持续累积。

9. 总结

  • GC 的判断核心是可达性
  • 标记清除能够处理已经与根节点断开的循环引用。
  • 分代、增量和并发回收用于降低回收成本与主线程停顿。
  • GC 不理解业务语义;无用对象只要仍然可达,就不会被自动释放。