【js篇】JavaScript 垃圾回收机制:深入理解内存管理

70 阅读4分钟

JavaScript 是一门自动内存管理的语言。开发者无需手动分配和释放内存,这一切都由 垃圾回收机制(Garbage Collection, GC) 自动完成。

但“自动”不等于“无脑”。复杂的代码可能导致内存泄漏或频繁的 GC 操作,影响性能。

本文将深入剖析浏览器的垃圾回收机制,帮助你写出更高效、更健壮的代码。


一、什么是垃圾回收?

✅ 核心概念

垃圾回收:自动释放程序中不再使用的内存空间,防止内存泄漏。

JavaScript 在运行时:

  1. 分配内存:为变量、对象、函数等分配空间;
  2. 使用内存:程序执行过程中读写数据;
  3. 释放内存:当数据不再需要时,自动回收其占用的空间。

二、变量的生命周期

✅ 1. 全局变量

  • 生命周期:从脚本加载开始,到页面卸载结束;
  • 回收时机:页面关闭或刷新时;
  • 风险:容易造成内存泄漏,应尽量减少全局变量。
var globalData = { /* 大量数据 */ }; // 页面关闭前一直存在

✅ 2. 局部变量

  • 生命周期:从函数执行开始,到函数执行结束;
  • 回收时机:函数执行完毕后,若无外部引用,立即可被回收。
function getData() {
  let localVar = [1, 2, 3]; // 函数结束时,通常会被回收
  return localVar;
}

✅ 3. 闭包中的变量

  • 特殊规则:即使函数执行结束,只要闭包引用存在,变量就不会被回收。
function outer() {
  let secret = 'I am hidden';
  return function() {
    console.log(secret); // 闭包引用 secret
  };
}

const inner = outer(); // outer 执行结束
inner(); // 仍能访问 secret —— secret 不会被回收

关键点可达性(Reachability) 决定是否回收。


三、垃圾回收算法

浏览器主要采用两种算法:


✅ 1. 标记清除(Mark-and-Sweep)—— 主流算法

🔄 工作原理

  1. 标记(Mark)
    • 根对象(如 windowglobal)开始,遍历所有可达对象;
    • 给所有能访问到的对象打上“活跃”标记。
  2. 清除(Sweep)
    • 遍历整个内存,回收所有未被标记的对象。

✅ 优点

  • 能正确处理循环引用
  • 是现代浏览器(V8、SpiderMonkey 等)的默认算法。

📊 示例

let obj1 = {};
let obj2 = {};

obj1.ref = obj2;
obj2.ref = obj1; // 循环引用

obj1 = null;
obj2 = null;
// 此时两个对象都无法从根访问 → 被标记为垃圾 → 被回收

结论:标记清除算法不会因循环引用而内存泄漏。


✅ 2. 引用计数(Reference Counting)—— 已淘汰

🔄 工作原理

  • 每个对象维护一个“引用计数”;
  • 每当一个变量引用该对象,计数 +1;
  • 当引用解除,计数 -1;
  • 计数为 0 时,立即回收。

❌ 缺点:无法解决循环引用

function problematic() {
  let obj1 = {};
  let obj2 = {};

  obj1.ref = obj2;
  obj2.ref = obj1; // 循环引用,计数均为 2

  // 函数结束,局部变量 obj1、obj2 销毁
  // 但 obj1 和 obj2 仍相互引用,计数为 1 ≠ 0
  // → 内存无法回收!
}

✅ 补救措施(历史做法)

obj1.ref = null;
obj2.ref = null; // 手动断开引用,计数降为 0

⚠️ 现状:现代浏览器不再使用纯引用计数,但某些引擎可能结合使用。


四、哪些情况会导致内存无法回收?(内存泄漏)

❌ 1. 意外的全局变量

function leak() {
  leakVar = 'I am global'; // 忘记 var/let/const
}
leak(); // leakVar 成为全局变量,永不回收

❌ 2. 被遗忘的定时器或回调

setInterval(() => {
  const hugeData = fetchData(); // 每次都创建新对象
  // 如果不 clearInterval,回调一直存在 → 内存持续增长
}, 1000);

❌ 3. 闭包滥用

function outer() {
  const hugeData = new Array(1000000).fill('data');
  return function() {
    // 仅需 small part,但整个 hugeData 被保留
  };
}

❌ 4. DOM 引用未清理

const element = document.getElementById('myDiv');
const leakMap = new Map();
leakMap.set(element, 'metadata');

// 即使 DOM 被移除,element 仍被 Map 引用 → 无法回收

五、如何优化垃圾回收?—— 减少 GC 压力

✅ 1. 数组优化:清空数组

let arr = [1, 2, 3, 4, 5];

// ❌ 创建新数组,旧数组等待回收
arr = [];

// ✅ 推荐:直接清空,复用原数组
arr.length = 0;

✅ 2. 对象优化:及时断开引用

let user = { data: 'sensitive' };

// 不再使用时,主动释放
user = null;
// 或
user.data = null;

✅ 3. 函数优化:避免在循环中创建函数

// ❌ 每次循环都创建新函数
for (let i = 0; i < 1000; i++) {
  buttons[i].onclick = function() {
    console.log(i);
  };
}

// ✅ 提取函数,复用
function clickHandler(i) {
  return function() {
    console.log(i);
  };
}
for (let i = 0; i < 1000; i++) {
  buttons[i].onclick = clickHandler(i);
}

✅ 4. 使用 WeakMap / WeakSet

  • WeakMap:键是对象,且是“弱引用”,不影响垃圾回收;
  • 当键对象被回收时,WeakMap 中的对应项自动消失。
const cache = new WeakMap();

function cacheUser(user) {
  const data = computeExpensiveData(user);
  cache.set(user, data); // user 被回收时,缓存自动清理
}

六、如何监控内存使用?

✅ Chrome DevTools

  1. 打开 Memory 面板;
  2. 使用 Heap Snapshot 拍照内存;
  3. 使用 Record Allocation Timeline 监控内存分配;
  4. 对比快照,查找未释放的对象。

💡 结语

“理解垃圾回收,不是为了手动管理内存,而是为了写出更高效的代码。”

记住:

  • 标记清除 是主流,无需担心循环引用;
  • 避免内存泄漏 的关键是:及时断开不必要的引用;
  • 优化策略:复用、弱引用、及时清理。