JavaScript 垃圾回收机制

引用计数、标记清除和分代回收的原理与区别

问题

JavaScript 的垃圾回收机制是如何工作的?引用计数、标记清除、分代回收分别是什么?

解答

引用计数(Reference Counting)

早期的垃圾回收策略。每个对象维护一个引用计数,当计数为 0 时回收。

// 引用计数示例
let obj = { name: 'test' }; // { name: 'test' } 引用计数 = 1
let copy = obj;              // { name: 'test' } 引用计数 = 2

obj = null;                  // { name: 'test' } 引用计数 = 1
copy = null;                 // { name: 'test' } 引用计数 = 0,被回收

致命问题:循环引用

function problem() {
  let objA = {};
  let objB = {};
  
  objA.ref = objB; // objB 引用计数 = 2
  objB.ref = objA; // objA 引用计数 = 2
  
  // 函数结束后,objA 和 objB 引用计数都是 1
  // 永远不会被回收 → 内存泄漏
}

标记清除(Mark-and-Sweep)

现代浏览器采用的主要算法。从根对象(全局对象、调用栈)出发,标记所有可达对象,清除未标记的对象。

// 标记清除过程示意
let root = {
  child: {
    grandchild: { value: 1 }
  }
};

// 阶段1:标记
// 从 root 开始遍历,标记 root → child → grandchild

// 阶段2:清除
// 未被标记的对象被回收

root.child = null;
// 下次 GC 时,原来的 child 和 grandchild 不可达,被回收

循环引用不再是问题:

function noLeak() {
  let objA = {};
  let objB = {};
  objA.ref = objB;
  objB.ref = objA;
}

noLeak();
// 函数执行完毕,objA 和 objB 从根对象不可达
// 即使互相引用,也会被回收

分代回收(Generational Collection)

V8 引擎的优化策略,基于”大多数对象生命周期很短”的假设。

┌─────────────────────────────────────────────────┐
│                    V8 堆内存                      │
├──────────────────────┬──────────────────────────┤
│      新生代           │         老生代            │
│   (1-8MB, 小)        │      (大, 主要空间)        │
├──────────────────────┼──────────────────────────┤
│  Scavenge 算法        │    Mark-Sweep-Compact    │
│  频繁、快速回收        │    较少触发、耗时较长      │
└──────────────────────┴──────────────────────────┘

新生代回收(Scavenge):

// 新生代使用复制算法,空间分为 From 和 To 两部分

// 1. 新对象分配在 From 空间
let newObj = { data: 'new' };

// 2. From 空间满时触发 Scavenge
//    - 存活对象复制到 To 空间
//    - From 和 To 角色互换
//    - 原 From 空间清空

// 3. 对象晋升到老生代的条件:
//    - 经历过一次 Scavenge 仍存活
//    - To 空间使用超过 25%

老生代回收(Mark-Sweep-Compact):

// 老生代使用标记清除 + 标记整理

// Mark-Sweep: 标记存活对象,清除死亡对象
// 问题:产生内存碎片

// Mark-Compact: 将存活对象移动到一端,整理碎片
// 代价:移动对象耗时

// V8 策略:主要用 Mark-Sweep,碎片过多时用 Mark-Compact

避免内存泄漏的实践

// 1. 及时清除定时器
const timer = setInterval(() => {}, 1000);
clearInterval(timer); // 不用时清除

// 2. 移除事件监听
const handler = () => {};
element.addEventListener('click', handler);
element.removeEventListener('click', handler); // 不用时移除

// 3. 避免意外的全局变量
function leak() {
  leaked = 'oops'; // 没有声明,成为全局变量
}

// 4. 使用 WeakMap/WeakSet 存储对象引用
const cache = new WeakMap();
let obj = { data: 'value' };
cache.set(obj, 'metadata');
obj = null; // obj 可被回收,WeakMap 中的条目自动移除

关键点

  • 引用计数:简单但有循环引用问题,现代浏览器已弃用
  • 标记清除:从根对象遍历标记可达对象,解决了循环引用问题
  • 分代回收:V8 将堆分为新生代和老生代,针对性优化回收效率
  • 新生代用 Scavenge:复制算法,快速回收短命对象
  • 老生代用 Mark-Sweep-Compact:标记清除 + 整理,处理长期存活对象