内存管理与垃圾回收
JavaScript 是"自动管理内存"的语言:开发者不需要手动分配和释放内存,由引擎的垃圾回收器(GC)负责。但自动不等于不会出问题——闭包、定时器、DOM 引用都可能造成内存泄漏。理解内存模型与回收算法,是写出稳定应用和排查疑难问题的基础。
一、内存模型:栈与堆
JavaScript 引擎把内存分为两块区域:
| 区域 | 存储内容 | 特点 |
|---|---|---|
| 栈(Stack) | 原始类型的值、函数调用的执行上下文 | 空间小、存取快、后进先出 |
| 堆(Heap) | 对象、数组、函数等引用类型的实际数据 | 空间大、存取慢、无固定顺序 |
const num = 42; // 42 存在栈中
const str = "hello"; // 字符串存在栈中(或引擎内部缓存)
const obj = { a: 1 }; // 对象数据在堆上,栈中只存指向堆的"引用"(地址)
let ref = obj; // ref 复制的是引用地址,与 obj 指向同一对象原始类型(number、string、boolean、null、undefined、symbol、bigint)按值存储;引用类型(object、array、function)按引用存储:
let a = 1;
let b = a; // 值拷贝:b 得到独立的 1
b = 2;
console.log(a); // 1,a 不受影响
const o1 = { v: 1 };
const o2 = o1; // 引用拷贝:o2 与 o1 指向同一对象
o2.v = 2;
console.log(o1.v); // 2,两个变量看到同一个对象二、变量生命周期
变量从创建到销毁的过程,决定它何时可以被回收:
- 创建:声明并赋值,栈上压入值或引用。
- 使用:被读取、传递、参与运算。
- 销毁:离开作用域且不再被引用,等待 GC 回收。
function demo() {
const local = { big: "占用堆空间的数据" }; // 进入函数时创建
console.log(local);
} // 函数结束:local 出栈,堆上的对象失去引用 → 可回收
let globalRef = { keep: true }; // 全局变量长期存活,直到页面关闭可达性(Reachability) 是判断对象生死的标准:从根(全局对象、当前调用栈、闭包引用链)出发,能被引用链触达的对象就是存活的,其余都可回收。
三、垃圾回收的必要性
如果内存只分配不回收,长期运行的程序会耗尽内存而崩溃。GC 的价值:
| 问题 | 手动管理(C/C++) | JS 自动 GC |
|---|---|---|
| 内存泄漏 | 忘记 free 就泄漏 | 引擎定期回收不可达对象 |
| 悬垂指针 | 释放后仍访问,崩溃 | 引用计数防止重复释放 |
| 开发成本 | 需要精心管理生命周期 | 开发者专注业务逻辑 |
代价是 GC 运行时会产生短暂停顿(Stop-the-world),且开发者的隐性引用(闭包、缓存)可能让对象"活着但不被需要"。
四、引用计数算法(Reference Counting)
原理:每个对象记录被引用的次数,引用数归零立即回收。
const obj = { v: 1 };
let ref1 = obj; // 引用计数 = 1
let ref2 = obj; // 引用计数 = 2
ref1 = null; // 引用计数 = 1
ref2 = null; // 引用计数 = 0 → 立即回收优点:回收及时、实现简单。致命缺陷是循环引用导致永远无法归零:
function cycle() {
const a = {};
const b = {};
a.b = b; // a 引用 b
b.a = a; // b 引用 a
return;
} // 函数结束,外部没有引用 a、b,但两者互相引用,计数恒为 1 → 泄漏因此现代引擎(含 V8)不采用纯引用计数,而使用标记清除。
五、标记清除算法(Mark-Sweep)
原理:分两个阶段:
| 阶段 | 动作 |
|---|---|
| 标记(Mark) | 从根出发遍历引用链,能到达的对象打上"存活"标记 |
| 清除(Sweep) | 扫描堆,回收所有未标记的对象 |
根 → 标记可达对象 → 清除不可达对象 → 内存被释放let keep = { alive: true };
let temp = { dead: true };
keep = null; // 保留 keep 指向… 等等,看下面:标记清除的语义基于"可达性"而非"计数",因此循环引用也能正确回收:
function cycle() {
const a = {};
const b = {};
a.b = b;
b.a = a;
}
cycle(); // 从根无法到达 a、b → 标记阶段不打标 → 清除阶段回收六、标记整理(Compaction)
标记清除有个副作用:清除后内存出现大量碎片,新对象可能找不到连续空间。标记整理在清除前先把存活对象挪到内存一端,压缩碎片:
清除前:[存活][空洞][存活][空洞][空洞]
整理后:[存活][存活][空余连续空间]| 算法 | 是否移动对象 | 优点 | 缺点 |
|---|---|---|---|
| 标记清除 | 否 | 简单、快 | 产生碎片 |
| 标记整理 | 是 | 消除碎片,大对象易分配 | 移动对象成本高 |
七、分代回收(Generational GC)
大量对象的生命周期很短("朝生夕死"),区分对待能大幅提升回收效率。V8 把堆分成两代:
| 代 | 存放对象 | 特点 | 回收算法 |
|---|---|---|---|
| 新生代 | 刚创建的对象 | 小、存活率低、数量多 | Scavenger |
| 老年代 | 存活时间长的对象 | 大、存活率高 | Mark-Compact + 增量标记 |
新生代中熬过多次 GC 的对象 → 晋升老年代八、V8 具体 GC 实现
8.1 Scavenger(新生代回收)
新生代分为两个半区(From 与 To)。分配时对象放 From 半区,满了就做复制回收:遍历存活对象,复制到 To 半区并清空 From,然后交换角色。
From(有数据)→ GC → 存活对象复制到 To → 清空 From → 交换存活对象连续复制到 To,天然无碎片,且只扫描存活对象,速度快。存活超过阈值或半区容量不足时晋升到老年代。
8.2 Mark-Compact(老年代回收)
老年代空间大、存活率高,用标记清除 + 标记整理组合:先标记,再清除,必要时整理压缩。为减少停顿,采用增量标记:把标记工作拆成小片,穿插在 JS 执行间隙完成,不让用户感知明显卡顿。
| V8 技术 | 应对问题 |
|---|---|
| 增量标记 | 标记阶段停顿过长 |
| 并行标记 | 多线程分担标记工作 |
| 并发回收 | 回收与 JS 执行并行 |
九、内存泄漏常见场景
内存泄漏 = 对象已无用但仍被引用,GC 无法回收。常见五类:
// 1. 意外的全局变量(严格模式可避免)
function leak() {
data = "忘了用 let/const"; // 变成 window.data,全局存活
}
// 2. 闭包持有大对象
function counter() {
let bigData = new Array(1000000);
return function () { return ++bigData[0]; };
}
const c = counter(); // bigData 被闭包长期持有,无法回收
// 3. 未清理的定时器
setInterval(() => {
// 引用 dom、数据,但从未 clearInterval
}, 1000);
// 4. 未移除的事件监听器
const btn = document.getElementById("btn");
function handler() { /* 引用大量数据 */ }
btn.addEventListener("click", handler);
btn.remove(); // 元素移除但监听器仍可达 → 元素与数据都无法回收
// 5. 持有已删除的 DOM 引用
const div = document.getElementById("app");
document.body.removeChild(div);
console.log(div); // 变量仍指向 DOM 节点,内存不释放| 泄漏类型 | 原因 | 修复 |
|---|---|---|
| 全局变量 | 隐式挂载 window | 用 let/const,开启严格模式 |
| 闭包 | 内部函数长期存活 | 用后置空引用,精简闭包捕获 |
| 定时器 | 未 clearInterval | 生命周期结束时清理 |
| 监听器 | 未 removeEventListener | 与元素同生命周期清理 |
| DOM 引用 | 变量持有已删除节点 | 置空引用,善用事件委托 |
十、内存泄漏检测
10.1 Chrome Memory 面板
- 打开 DevTools → Memory 面板。
- 录制 Heap snapshot,操作页面后再次录制,对比两次快照。
- 按 Retained Size 排序,查找持续增长且无法解释的对象(如 Detached DOM 节点)。
- 用 Allocation instrumentation on timeline 记录时间线上的内存分配,定位分配集中的函数。
Heap Snapshot:看"当前堆里有什么" → 对比找增量
Timeline:看"什么操作在分配内存" → 定位泄漏源头10.2 Node.js 检测
Node 启动时开启 inspector,用 Chrome DevTools 远程分析堆快照:
node --inspect-brk app.js
# 浏览器打开 chrome://inspect,连接后使用 Memory 面板也可以周期性打印内存占用,观察是否单调增长:
const used = process.memoryUsage();
console.log({
heapUsed: (used.heapUsed / 1024 / 1024).toFixed(2) + " MB",
heapTotal: (used.heapTotal / 1024 / 1024).toFixed(2) + " MB",
});十一、WeakMap 与 WeakRef 防泄漏
11.1 WeakMap / WeakSet
WeakMap 的键是弱引用:键对象不可达时,键值对自动被回收,不阻止 GC。适合做"辅助数据 + 原对象生命周期"绑定:
// 反例:Map 强引用键,元素删除后缓存仍在
const cache = new Map();
document.querySelectorAll("button").forEach((btn, i) => {
btn.addEventListener("click", () => cache.set(btn, i)); // 泄漏:Map 持有 btn
});
// 正例:WeakMap 弱引用键,按钮被移除后可回收
const weakCache = new WeakMap();
document.querySelectorAll("button").forEach((btn, i) => {
btn.addEventListener("click", () => weakCache.set(btn, i)); // 随按钮一起回收
});| 特性 | Map | WeakMap |
|---|---|---|
| 键引用 | 强引用 | 弱引用 |
| 可遍历 | 可以(keys/values/entries) | 不可以 |
| 键类型 | 任意 | 只能是对象 |
| 防泄漏 | 需手动删除 | 自动 |
11.2 WeakRef 与 FinalizationRegistry
WeakRef 直接持有对象的弱引用,配合 FinalizationRegistry 在对象被回收时收到通知:
const registry = new FinalizationRegistry((held) => {
console.log("对象被回收:", held);
});
registry.register(targetObj, "target");
// 需要时用 WeakRef 取出对象,若已回收则为 undefined
const ref = new WeakRef(targetObj);
const obj = ref.deref(); // 对象还在则返回它,被回收则返回 undefinedWeakRef 语义复杂(deref 结果不可控),业务代码中优先用 WeakMap,仅在缓存与资源回收场景中使用。
11.3 全局缓存防泄漏模板
const imageCache = new WeakMap();
function getImage(key) {
if (imageCache.has(key)) return imageCache.get(key);
const img = new Image();
imageCache.set(key, img); // key 存活缓存就在,key 消亡缓存自动释放
return img;
}内存管理的核心原则:引用与生命周期绑定。对象该活多久,就让引用存在多久;对象消亡,引用同步清理。配合弱引用容器与泄漏检测工具,长期运行的应用也能保持稳定内存水位。