JavaScript 垃圾回收机制详解
JavaScript 垃圾回收机制详解
在 JavaScript 中,内存管理是自动的。当变量不再被需要时,它们所占用的内存会被垃圾回收器(Garbage Collector, GC) 自动释放。理解 GC 原理有助于写出更高效、更不易内存泄漏的代码。
一、核心概念:可达性(Reachability)
垃圾回收的基本判断标准是可达性:从根(Root)对象出发,通过引用链可以访问到的对象,就是“可达的”,需要保留;无法到达的对象则被标记为垃圾,等待回收。
根对象通常包括:
- 全局对象(浏览器中为
window,Node.js 中为global) - 当前执行函数的局部变量和参数
- 调用栈上的其他函数变量
- 某些内置对象(如 DOM 树的根节点)
let user = {
name: 'Alice',
friend: {
name: 'Bob'
}
};
// user 引用对象 {name:'Alice', friend: ...} 从根可达,对象存活
user = null;
// 现在 {name:'Alice', friend:{...}} 不再可达,GC 可回收
二、常见的垃圾回收算法
1. 引用计数(Reference Counting)
原理:每个对象维护一个引用计数器,记录有多少个地方引用了它。当引用计数降为 0 时,对象立即被回收。
let a = { value: 1 }; // 对象 {value:1} 引用计数 = 1
let b = a; // 引用计数 = 2
a = null; // 引用计数 = 1
b = null; // 引用计数 = 0 → 立即回收
致命缺点:循环引用
function createCycle() {
let obj1 = {};
let obj2 = {};
obj1.ref = obj2;
obj2.ref = obj1;
return 'done';
}
createCycle();
// obj1 和 obj2 互相引用,即使函数执行完毕离开作用域,它们的引用计数仍为 1,永远无法回收。
优点:实现简单,回收及时,没有明显暂停。
缺点:无法处理循环引用;计数器维护带来额外开销(每次赋值都要更新)。现代 JS 引擎已不采用此方案作为主要算法,仅在某些场景(如 COM 对象)中保留。
2. 标记-清除(Mark-and-Sweep)
这是现代引擎的核心算法,完美解决循环引用问题。
流程:
- 标记阶段:从根对象出发,深度遍历所有可达对象,并标记为“活跃”。
- 清除阶段:遍历整个堆内存,将未被标记的对象回收,并将内存归还给空闲列表。
// 循环引用示例
function foo() {
let a = {};
let b = {};
a.b = b;
b.a = a;
}
foo();
// 执行后,a, b 无法从根对象到达 → 标记阶段不会标记它们 → 被清除
优点:能处理循环引用。
缺点:
- 回收时会产生内存碎片(空闲内存不连续)。
- 标记和清除可能导致全停顿(Stop-The-World),页面卡顿。
3. 标记-整理(Mark-Compact)
为了解决内存碎片问题而提出。它在标记阶段后增加整理阶段:将所有存活对象移动到内存一端,然后直接清理边界外的所有内存。
// 想象内存布局
// 整理前:[A][ ][B][ ][C][ ] → 整理后:[A][B][C][ ]
优点:消除碎片,分配大对象更容易。
缺点:移动对象有额外开销,停顿时间更长。通常与分代回收配合使用。
4. 分代回收(Generational Collection)
几乎所有现代 JS 引擎(V8、SpiderMonkey)都采用分代假说:
大多数对象“朝生暮死”,存活时间短;少数对象存活时间长。
引擎将内存堆划分为新生代(Young Generation) 和老生代(Old Generation),分别使用不同策略。
新生代(Young Generation)
- 存放存活时间短的对象,空间较小(通常 1~8 MB)。
- 回收算法:Scavenge(半空间复制)。
- 将新生代分为两个等大的半空间(From 和 To)。
- 分配都在 From 空间进行。
- 当 From 空间快满时触发 Minor GC:
- 标记 From 空间的存活对象。
- 将存活对象复制到 To 空间,并紧凑排列。
- 交换 From 和 To 的角色。
- 优点:速度快,只处理存活对象,复制同时完成了整理。
- 对象晋升:经过两次 Minor GC 仍存活的对象,会移动到老生代。
老生代(Old Generation)
- 存放存活时间长的对象,空间大(可达几百 MB)。
- 回收算法:标记-清除 + 标记-整理(Major GC)。
- 通常在老生代内存不足时触发,频率低,但停顿时间长。
三、V8 引擎的优化手段
为了减少全停顿带来的卡顿,V8 采用了多种技术将垃圾回收工作拆解到多个小步中执行。
| 技术 | 说明 |
|---|---|
| 增量标记(Incremental Marking) | 将标记阶段拆成许多小段,与 JS 执行交替进行,每执行一小段 JS 就标记一点,缩短连续停顿时间。 |
| 惰性清理(Lazy Sweeping) | 按需清理垃圾页,不必一次性清扫整个堆,减少清理阶段的暂停。 |
| 并发标记(Concurrent Marking) | 标记工作完全在后台线程中执行,JS 主线程可以持续运行,几乎无停顿。 |
| 并发清理(Concurrent Sweeping) | 清理也在后台线程进行,主线程仅需短暂同步。 |
| 并行回收 | 主线程和多个辅助线程同时进行回收工作,利用多核加速。 |
V8 的分代回收简图:
堆内存
├── 新生代 (Young Gen)
│ ├── From 空间 (活动区)
│ └── To 空间 (空闲区) ← Scavenge 算法
│ ↳ 两次存活 → 晋升到老生代
└── 老生代 (Old Gen)
├── 标记-清除 / 标记-整理
├── 增量标记 / 并发标记
└── 惰性清理
四、开发者如何配合 GC
虽然 GC 是自动的,但不规范的代码会造成内存泄漏,导致性能下降甚至崩溃。
常见内存泄漏及避免方法
- 全局变量意外污染
function leak() {
bar = 'this is global'; // 未声明变量,挂载到 window
}
- 遗忘的定时器或回调
const obj = {
data: new Array(10000).fill('*'),
start() {
this.timer = setInterval(() => {
console.log(this.data);
}, 1000);
}
};
// 组件卸载时必须 clearInterval(this.timer),否则 obj 无法被回收
- 闭包引用大对象
function outer() {
const bigData = new Array(1000000);
return function inner() {
console.log('hello');
// bigData 虽然未在 inner 中使用,但因闭包作用域链,可能被 V8 保留
};
}
const fn = outer(); // bigData 常驻内存
// 优化:将 bigData = null 在 return 前释放
- 游离的 DOM 引用
let btn = document.getElementById('myBtn');
btn.remove(); // 从 DOM 树移除,但 JS 变量 btn 仍引用它,GC 不回收
btn = null; // 手动清除引用
现代辅助工具
- WeakRef:创建对对象的弱引用,不阻止 GC。
- FinalizationRegistry:在对象被回收后执行回调,用于清理资源。
let obj = { data: 'important' };
const registry = new FinalizationRegistry((heldValue) => {
console.log(`对象被回收,清理资源:${heldValue}`);
});
registry.register(obj, 'some resource');
obj = null; // 未来 GC 回收后触发回调
总结
- JavaScript 垃圾回收基于可达性。
- 核心算法从引用计数演进到标记-清除,再配合分代回收和增量/并发优化以降低停顿。
- 开发者应养成良好的内存管理习惯,避免循环引用、清除定时器、及时解除 DOM 引用等,利用弱引用处理特定场景。
理解这些机制后,你不仅能写出更高效的代码,还能在排查内存问题时快速定位根因。
更多推荐
所有评论(0)