Skip to content

垃圾回收机制(Garbage Collection)

什么是垃圾回收?

垃圾回收(GC)是一种自动内存管理机制,用于回收程序中不再使用的内存空间。JavaScript 引擎会自动追踪内存分配和使用情况,并在适当的时候释放不再需要的内存。

垃圾回收的基本概念

可达性(Reachability)

垃圾回收器通过"可达性"来判断对象是否还在使用:

  • 可达对象:从根(全局对象、当前执行栈等)出发,可以通过引用链访问到的对象
  • 不可达对象:无法从根访问到的对象,被视为垃圾

根对象(Roots)

以下对象始终被认为是可达的:

  • 全局对象(浏览器中的 window,Node.js 中的 global
  • 当前执行栈中的局部变量和参数
  • 活动函数的闭包变量
  • 正在执行的函数

垃圾回收算法

1. 引用计数(Reference Counting)

原理:为每个对象维护一个引用计数器,记录有多少个引用指向该对象。

javascript
// 引用计数示例
let obj1 = { name: 'Alice' };  // obj1的引用计数:1
let obj2 = obj1;                // obj1的引用计数:2

obj1 = null;  // obj1的引用计数:1
obj2 = null;  // obj1的引用计数:0,可以被回收

优点

  • 实现简单
  • 回收及时

缺点

  • 无法解决循环引用问题
  • 需要额外空间存储计数器
  • 频繁更新计数器影响性能
javascript
// 循环引用问题
function circularReference() {
  let obj1 = {};
  let obj2 = {};
  
  obj1.ref = obj2;  // obj1引用obj2
  obj2.ref = obj1;  // obj2引用obj1
  
  return null;
}

circularReference();
// obj1和obj2互相引用,引用计数永远不为0
// 即使函数执行完毕,这两个对象也无法被回收

2. 标记-清除(Mark-and-Sweep)★

原理:这是 JavaScript 最常用的垃圾回收算法。

执行过程

  1. 标记阶段:从根对象开始遍历,标记所有可达对象
  2. 清除阶段:遍历堆内存,回收未被标记的对象
javascript
// 标记-清除示例
function createObjects() {
  let obj1 = { data: 'important' };  // 创建对象
  let obj2 = { data: 'temporary' };  // 创建对象
  
  globalThis.keepAlive = obj1;  // obj1可达(通过全局对象)
  
  return null;
}

createObjects();
// GC执行时:
// 1. 从根开始标记:globalThis -> keepAlive -> obj1 ✓
// 2. obj2不可达,未被标记
// 3. 清除阶段回收obj2

优点

  • 可以解决循环引用问题
  • 不需要额外的引用计数

缺点

  • 回收时会暂停程序执行(Stop-the-World)
  • 内存碎片化问题

3. 标记-整理(Mark-Compact)

原理:标记-清除的改进版,在清除后会整理内存。

执行过程

  1. 标记所有可达对象
  2. 将所有存活对象移动到内存的一端
  3. 清除边界外的内存

优点

  • 解决内存碎片问题
  • 提高内存利用率

缺点

  • 移动对象需要更新所有引用
  • 执行时间较长

4. 复制算法(Copying)

原理:将内存分为两个区域,每次只使用一个区域。

执行过程

  1. 将存活对象从当前区域复制到另一个区域
  2. 清空当前区域
  3. 交换两个区域的角色

优点

  • 没有内存碎片
  • 分配内存快速(指针碰撞)

缺点

  • 内存利用率只有50%
  • 复制对象有开销

分代回收(Generational GC)

现代 JavaScript 引擎采用分代回收策略,基于"大部分对象生命周期很短"的观察。

新生代(Young Generation/New Space)

特点

  • 存放新创建的对象
  • 空间较小(1-8MB)
  • GC 频率高
  • 采用 Scavenge 算法(复制算法的变种)
javascript
// 新生代对象示例
function processData() {
  let temp = { data: [] };  // 临时对象,生命周期短
  
  for (let i = 0; i < 1000; i++) {
    temp.data.push(i);
  }
  
  return temp.data.length;
}
// temp对象在函数执行完后很快被回收

Scavenge 算法

  • 新生代分为两个空间:From 空间和 To 空间
  • 对象首先分配在 From 空间
  • GC 时将存活对象复制到 To 空间
  • 清空 From 空间,交换两个空间

老生代(Old Generation/Old Space)

特点

  • 存放生命周期长的对象
  • 空间较大
  • GC 频率低
  • 采用标记-清除和标记-整理算法
javascript
// 老生代对象示例
const cache = new Map();  // 长期存在的缓存对象

function addToCache(key, value) {
  cache.set(key, value);  // 这些对象可能晋升到老生代
}

对象晋升条件

  1. 对象在新生代经历过一次 GC 仍然存活
  2. To 空间使用超过 25%

增量标记(Incremental Marking)

目的:减少 GC 造成的停顿时间

原理:将标记过程分解为多个小步骤,与 JavaScript 执行交替进行

JavaScript 执行 → 增量标记 → JavaScript 执行 → 增量标记 → ...

优点

  • 避免长时间停顿
  • 提高应用响应性

挑战

  • 标记过程中对象引用可能发生变化
  • 需要写屏障(Write Barrier)追踪变化

并发标记(Concurrent Marking)

原理:标记过程在后台线程中执行,与 JavaScript 主线程并发运行

优点

  • 进一步减少主线程停顿
  • 充分利用多核 CPU

空闲时间回收(Idle-time GC)

利用浏览器的空闲时间(如 requestIdleCallback)执行垃圾回收,避免影响用户交互。

内存泄漏常见场景

1. 意外的全局变量

javascript
// 不良实践
function createLeak() {
  leak = { data: new Array(1000000) };  // 缺少 var/let/const
}
// leak 成为全局变量,永远不会被回收

2. 遗忘的定时器

javascript
// 不良实践
function setupTimer() {
  let largeData = new Array(1000000);
  
  setInterval(() => {
    console.log(largeData[0]);
  }, 1000);
}
// 即使 setupTimer 执行完毕,largeData 仍被定时器引用

解决方案

javascript
// 良好实践
function setupTimer() {
  let largeData = new Array(1000000);
  
  const timer = setInterval(() => {
    console.log(largeData[0]);
  }, 1000);
  
  // 在适当时机清除定时器
  return () => clearInterval(timer);
}

const cleanup = setupTimer();
// 不再需要时
cleanup();

3. 闭包引用

javascript
// 不良实践
function createClosure() {
  let largeData = new Array(1000000);
  
  return function() {
    console.log('closure');
    // 即使不使用 largeData,它仍被闭包引用
  };
}

const fn = createClosure();
// largeData 无法被回收

解决方案

javascript
// 良好实践
function createClosure() {
  let largeData = new Array(1000000);
  let smallData = largeData[0];
  largeData = null;  // 手动释放
  
  return function() {
    console.log(smallData);
  };
}

4. DOM 引用

javascript
// 不良实践
let elements = [];

function addElements() {
  let div = document.createElement('div');
  document.body.appendChild(div);
  elements.push(div);
}

function removeElements() {
  document.body.innerHTML = '';
  // elements 数组仍然引用已删除的 DOM 节点
}

解决方案

javascript
// 良好实践
function removeElements() {
  document.body.innerHTML = '';
  elements = [];  // 清空引用数组
}

5. 事件监听器

javascript
// 不良实践
function attachListener() {
  let button = document.getElementById('btn');
  let largeData = new Array(1000000);
  
  button.addEventListener('click', function() {
    console.log(largeData.length);
  });
}
// 移除 button 后,监听器仍引用 largeData

解决方案

javascript
// 良好实践
function attachListener() {
  let button = document.getElementById('btn');
  let largeData = new Array(1000000);
  
  function handler() {
    console.log(largeData.length);
  }
  
  button.addEventListener('click', handler);
  
  // 清理函数
  return () => {
    button.removeEventListener('click', handler);
    largeData = null;
  };
}

性能优化建议

1. 及时释放引用

javascript
// 使用完大对象后及时置为 null
let largeArray = new Array(1000000);
// ... 使用 largeArray
largeArray = null;  // 帮助 GC

2. 使用对象池

javascript
// 对象池模式
class ObjectPool {
  constructor(createFn, resetFn) {
    this.createFn = createFn;
    this.resetFn = resetFn;
    this.pool = [];
  }
  
  acquire() {
    return this.pool.pop() || this.createFn();
  }
  
  release(obj) {
    this.resetFn(obj);
    this.pool.push(obj);
  }
}

// 使用示例
const vectorPool = new ObjectPool(
  () => ({ x: 0, y: 0 }),
  (v) => { v.x = 0; v.y = 0; }
);

3. 避免频繁创建临时对象

javascript
// 不良实践
function calculate(arr) {
  return arr.map(x => ({ value: x * 2 }))  // 创建大量临时对象
            .filter(obj => obj.value > 10)
            .map(obj => obj.value);
}

// 良好实践
function calculate(arr) {
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    const value = arr[i] * 2;
    if (value > 10) {
      result.push(value);
    }
  }
  return result;
}

4. 使用 WeakMap 和 WeakSet

javascript
// WeakMap 不会阻止键对象被垃圾回收
const cache = new WeakMap();

function processObject(obj) {
  if (!cache.has(obj)) {
    cache.set(obj, expensiveComputation(obj));
  }
  return cache.get(obj);
}

// 当 obj 不再被使用时,cache 中的条目会自动被清理

监控和调试工具

Chrome DevTools

  1. Performance 面板

    • 查看 GC 事件
    • 分析 GC 造成的停顿
  2. Memory 面板

    • 堆快照(Heap Snapshot)
    • 内存分配时间线
    • 对比快照查找内存泄漏

Node.js

javascript
// 获取内存使用情况
console.log(process.memoryUsage());
// {
//   rss: 25165824,        // 常驻集大小
//   heapTotal: 6537216,   // 堆总大小
//   heapUsed: 4345408,    // 已使用堆大小
//   external: 1234567,    // C++ 对象绑定的内存
//   arrayBuffers: 12345   // ArrayBuffer 和 SharedArrayBuffer
// }

// 手动触发 GC(需要 --expose-gc 启动)
if (global.gc) {
  global.gc();
}

总结

  • JavaScript 使用自动垃圾回收,主要采用标记-清除算法
  • 现代引擎使用分代回收、增量标记、并发标记等优化技术
  • 理解 GC 机制有助于写出高性能代码
  • 注意避免常见的内存泄漏场景
  • 使用 DevTools 监控和调试内存问题

参考资源

MIT Licensed | Keep Learning.