JavaScript 垃圾回收机制深度解析
JavaScript 作为一门高级语言,拥有自动内存管理机制,开发者无需手动分配和释放内存。然而,理解垃圾回收(Garbage Collection,GC)的工作原理,对于编写高性能、无内存泄漏的代码至关重要。
一、垃圾回收机制的核心概念
1.1 内存的生命周期
无论使用何种编程语言,内存的生命周期都遵循相同的模式:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 内存分配 │ ──▶ │ 内存使用 │ ──▶ │ 内存回收 │
│ Allocate │ │ Use │ │ Release │
└─────────────┘ └─────────────┘ └─────────────┘
- 内存分配:当声明变量、函数、对象时,系统会自动分配内存
- 内存使用:即读写内存,也就是使用变量、函数
- 内存回收:使用完毕后,由垃圾回收器自动回收不再使用的内存
1.2 什么是垃圾回收
JavaScript 中的函数、变量、对象等都需要占用一定的内存。当这些东西不再被使用时,就变成了「垃圾」。如果不进行回收,内存会被持续占用,随着程序运行,垃圾越来越多,最终导致内存耗尽。
为什么需要自动 GC?
- JavaScript 不允许开发者直接操作内存(不像 C/C++ 的
malloc/free) - 自动 GC 避免了手动内存管理导致的内存泄漏、野指针等问题
- 降低开发成本,让开发者专注于业务逻辑
1.3 垃圾的判定标准
核心原则:该内存空间是否还能被程序「访问到」
可访问场景(非垃圾):
- 全局作用域中声明的变量(如
window/global上的属性) - 函数执行栈中正在执行的函数内的局部变量、参数
- 通过「可达链」能关联到上述两类变量的对象
// 示例:可达链
const globalObj = {
// globalObj 是全局变量,可达
child: {
// child 通过 globalObj 可达
grandchild: {}, // grandchild 通过 child 可达
},
};
不可访问场景(垃圾):
- 函数执行结束后,局部变量未被外部引用,且无法通过任何可达链关联
- 手动将变量赋值为
null/undefined后,若没有其他引用,原对象成为垃圾
二、JavaScript 中常见的垃圾回收算法
2.1 引用计数法(Reference Counting)
核心原理
引用计数法的核心逻辑是:为每个内存对象维护一个「引用计数器」,记录当前有多少个活跃引用指向该对象。
- 当对象被引用时(如被变量赋值、作为参数传递等),计数器 +1
- 当引用失效时(如变量被重新赋值、离开作用域等),计数器 -1
- 当计数器变为 0 时,引擎判定该对象为垃圾,立即回收
let obj = { name: 'Alice' }; // { name: 'Alice' } 引用计数 = 1
let ref = obj; // 引用计数 = 2
obj = null; // 引用计数 = 1
ref = null; // 引用计数 = 0 → 被回收
优点
| 优点 | 说明 |
|---|---|
| 即时回收 | 引用计数为 0 时立即回收,避免内存堆积 |
| 实现简单 | 逻辑直观,只需维护计数器增减操作 |
| 减少卡顿 | 回收过程分散在程序运行中,不会长时间停顿 |
致命缺陷:循环引用
当两个或多个对象相互引用时,即使它们已无外部引用,计数器也始终大于 0,导致内存泄漏:
function createCycle() {
let objA = {};
let objB = {};
// 形成循环引用
objA.ref = objB;
objB.ref = objA;
}
createCycle();
// 函数执行结束后,objA、objB 局部变量销毁
// 但两者的属性互相引用,计数器均为 1
// 引用计数法无法识别,这部分内存永远无法释放!
由于这个致命缺陷,现代 JavaScript 引擎已不再使用纯引用计数法。
2.2 标记清除法(Mark-and-Sweep)
核心原理
标记清除法基于「可达性」判定垃圾,分为标记和清除两个阶段,定期执行(非实时)。
┌─────────────────────────────────────────────────────────────┐
│ 标记阶段 (Mark) │
│ 从根对象出发,遍历所有可达对象,标记为「活跃」 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 清除阶段 (Sweep) │
│ 遍历整个堆内存,回收所有未被标记的对象 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 整理阶段 (Compact,可选) │
│ 将存活对象内存地址连续排列,减少内存碎片 │
└─────────────────────────────────────────────────────────────┘
执行流程详解:
- 标记阶段:从「根对象」(全局对象、执行栈中的变量)出发,递归遍历所有可达对象,标记为「活跃对象」
- 清除阶段:遍历整个内存空间,将未被标记的对象判定为垃圾,释放其内存
- 整理阶段(可选):将活跃对象内存地址连续排列,减少内存碎片
优点
- 解决循环引用问题:只要对象从根不可达,无论是否存在循环引用,都会被回收
- 无额外空间开销:不需要为每个对象维护计数器
缺点
- 非实时性:GC 执行时会中断主线程(「Stop-The-World」),若内存量大,可能造成明显卡顿
- 内存碎片:释放的内存地址不连续,可能导致后续大对象无法分配
三、V8 引擎的现代 GC 优化
现代 JavaScript 引擎(如 Chrome 的 V8)在标记清除法基础上,引入了多项优化技术。
3.1 分代收集(Generational Collection)
V8 将堆内存分为新生代和老生代两个区域,基于一个重要的观察:大多数对象的生命周期都非常短。
┌─────────────────────────────────────────────────────────────┐
│ 堆内存 (Heap) │
├───────────────────────┬─────────────────────────────────────┤
│ 新生代 (Young) │ 老生代 (Old) │
│ ┌───────┬───────┐ │ │
│ │ From │ To │ │ 存活时间长的对象 │
│ │ Space │ Space │ │ GC 频率低 │
│ └───────┴───────┘ │ 标记-清除/标记-整理 │
│ 新创建的对象 │ │
│ GC 频率高 │ │
│ Scavenge 算法 │ │
└───────────────────────┴─────────────────────────────────────┘
| 区域 | 特点 | GC 算法 |
|---|---|---|
| 新生代 | 空间小,存放新对象,GC 频繁 | Scavenge(复制算法) |
| 老生代 | 空间大,存放长期存活对象 | Mark-Sweep + Mark-Compact |
对象晋升:在新生代中存活过一定次数 GC 的对象,会被「晋升」到老生代。
3.2 并行、增量、并发回收
为了减少 GC 带来的程序暂停(Stop-The-World),现代 GC 使用了多种技术:
| 技术 | 说明 |
|---|---|
| 并行(Parallel) | 主线程和辅助线程同时执行 GC 工作 |
| 增量(Incremental) | 将 GC 工作拆分成多个小任务,穿插在 JS 任务之间执行 |
| 并发(Concurrent) | 辅助线程在后台执行 GC,主线程继续运行 JS |
传统 GC:
JS 执行 ──────┤ GC 停顿(长)├────── JS 执行
增量 GC:
JS 执行 ──┤GC├── JS ──┤GC├── JS ──┤GC├── JS 执行
(短) (短) (短)
四、如何避免内存泄漏
理解 GC 原理后,我们可以采取措施避免内存泄漏。
4.1 常见内存泄漏场景
1. 意外的全局变量
// ❌ 错误:未声明的变量会成为全局变量
function leak() {
leakedVar = 'I am global!'; // 没有 let/const/var
}
// ✅ 正确:使用严格模式
('use strict');
function safe() {
let localVar = 'I am local';
}
2. 被遗忘的定时器和回调
// ❌ 错误:定时器未清除
const timer = setInterval(() => {
// 持有外部引用的闭包
console.log(someData);
}, 1000);
// ✅ 正确:不需要时清除定时器
clearInterval(timer);
3. 脱离 DOM 的引用
// ❌ 错误:DOM 元素被移除,但 JS 仍持有引用
const button = document.getElementById('myButton');
document.body.removeChild(button);
// button 变量仍然引用该 DOM 节点,无法被 GC
// ✅ 正确:手动置空引用
button = null;
4. 闭包使用不当
// ❌ 潜在问题:闭包持有大量数据
function createClosure() {
const largeData = new Array(1000000).fill('x');
return function () {
// 即使只用了 largeData 的一小部分
// 整个 largeData 都无法被回收
console.log(largeData[0]);
};
}
// ✅ 优化:只保留需要的数据
function createOptimizedClosure() {
const largeData = new Array(1000000).fill('x');
const neededData = largeData[0]; // 只取需要的
return function () {
console.log(neededData);
};
}
4.2 检测内存泄漏的工具
| 工具 | 说明 |
|---|---|
| Chrome DevTools - Memory | 可以拍摄堆快照,对比内存变化 |
| Chrome DevTools - Performance | 可以观察内存使用趋势 |
| Node.js --inspect | Node.js 应用的内存分析 |
五、面试要点总结
GC 的核心判定标准:可达性(是否能从根对象访问到)
两种主要算法对比:
- 引用计数:即时回收,但无法处理循环引用
- 标记清除:可处理循环引用,但会造成停顿
V8 的优化策略:
- 分代收集:新生代频繁 GC,老生代低频 GC
- 增量/并行/并发:减少 GC 停顿时间
常见内存泄漏场景:
- 意外全局变量
- 未清除的定时器/事件监听器
- 脱离 DOM 的引用
- 闭包使用不当
防范措施:
- 使用
'use strict' - 及时清除定时器和事件监听器
- 移除 DOM 后置空相关引用
- 合理使用闭包,避免持有不必要的大对象
- 使用
总结
JavaScript 的垃圾回收机制是语言运行时的核心组成部分。理解其工作原理,不仅有助于编写高性能代码,也是前端面试的必考知识点。关键要记住:
- 可达性是判定垃圾的核心标准
- 现代引擎使用标记清除 + 分代收集组合策略
- 开发者需要主动避免常见的内存泄漏场景
掌握这些知识,你就能写出更加健壮、高效的 JavaScript 代码。