JavaScript 是一门自动内存管理的语言。开发者无需手动分配和释放内存,这一切都由 垃圾回收机制(Garbage Collection, GC) 自动完成。
但“自动”不等于“无脑”。复杂的代码可能导致内存泄漏或频繁的 GC 操作,影响性能。
本文将深入剖析浏览器的垃圾回收机制,帮助你写出更高效、更健壮的代码。
一、什么是垃圾回收?
✅ 核心概念
垃圾回收:自动释放程序中不再使用的内存空间,防止内存泄漏。
JavaScript 在运行时:
- 分配内存:为变量、对象、函数等分配空间;
- 使用内存:程序执行过程中读写数据;
- 释放内存:当数据不再需要时,自动回收其占用的空间。
二、变量的生命周期
✅ 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)—— 主流算法
🔄 工作原理
- 标记(Mark):
- 从根对象(如
window、global)开始,遍历所有可达对象; - 给所有能访问到的对象打上“活跃”标记。
- 从根对象(如
- 清除(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
- 打开 Memory 面板;
- 使用 Heap Snapshot 拍照内存;
- 使用 Record Allocation Timeline 监控内存分配;
- 对比快照,查找未释放的对象。
💡 结语
“理解垃圾回收,不是为了手动管理内存,而是为了写出更高效的代码。”
记住:
- 标记清除 是主流,无需担心循环引用;
- 避免内存泄漏 的关键是:及时断开不必要的引用;
- 优化策略:复用、弱引用、及时清理。