深入理解垃圾回收与内存管理
V8引擎分代回收详细机制、WeakRef/FinalizationRegistry、内存泄漏排查实战
什么是垃圾回收?
定义:垃圾回收(Garbage Collection, GC)是 JavaScript 引擎自动管理内存的机制。它负责检测不再被使用(不可达)的对象,并释放其占用的内存空间,开发者无需手动分配和释放内存。
涉及场景:
- SPA 长时间运行:单页应用在不刷新页面的情况下运行数小时,内存泄漏会逐渐拖慢页面
- 大量 DOM 操作:频繁创建和销毁 DOM 节点,分离的 DOM 节点(Detached DOM)是常见泄漏源
- 闭包与事件监听:闭包持有外部变量引用、事件监听器未清除导致对象无法回收
- 缓存管理:Map/Set 中缓存的对象若不主动删除,会一直占用内存
- Node.js 服务端:服务端进程长期运行,内存泄漏会导致 OOM(Out of Memory)崩溃
- WebWorker / SharedArrayBuffer:多线程场景下的内存共享与回收
作用:
- 自动内存管理:开发者无需像 C/C++ 一样手动
malloc/free - 防止内存泄漏:理解 GC 机制才能写出不泄漏的代码
- 性能调优:GC 暂停(Stop-The-World)影响帧率,优化 GC 行为可提升流畅度
- 面试重点:V8 分代回收机制、内存泄漏排查是中高级面试常见考题
JavaScript 内存生命周期
分配内存 → 使用内存 → 释放内存
↑ ↑ ↑
自动分配 读写变量 垃圾回收(GC)javascript
// 分配内存
const num = 42; // 栈:数字
const str = 'hello'; // 堆:字符串(较大时)
const obj = { name: '张三' }; // 堆:对象
const arr = [1, 2, 3]; // 堆:数组
const fn = () => {}; // 堆:函数
// 使用内存
console.log(obj.name);
// 释放内存(自动)
// 当没有任何引用指向对象时,GC 会回收栈内存 vs 堆内存
栈内存(Stack) 堆内存(Heap)
┌─────────────┐ ┌─────────────────────┐
│ 基本类型值 │ │ 对象、数组、函数 │
│ 函数调用帧 │ │ 大小不固定 │
│ 引用(指针) │ ──指向──→ │ GC 管理 │
│ 大小固定 │ │ │
│ 自动分配释放 │ │ │
└─────────────┘ └─────────────────────┘
// 基本类型存在栈上
let a = 10;
let b = a; // 值的拷贝
b = 20;
console.log(a); // 10(互不影响)
// 引用类型:栈上存引用,堆上存值
let obj1 = { x: 1 };
let obj2 = obj1; // 引用的拷贝(指向同一个堆对象)
obj2.x = 2;
console.log(obj1.x); // 2(共享同一对象)垃圾回收算法
1. 引用计数(Reference Counting)
最早期的 GC 算法,现代引擎已不单独使用。
javascript
// 原理:每个对象有一个引用计数,引用为0时回收
let obj = { name: '张三' }; // 引用计数 = 1
let ref = obj; // 引用计数 = 2
obj = null; // 引用计数 = 1
ref = null; // 引用计数 = 0 → 可回收
// ❌ 致命问题:循环引用无法回收
function circularRef() {
const a = {};
const b = {};
a.ref = b; // a 引用 b
b.ref = a; // b 引用 a
// 函数结束后,a 和 b 互相引用
// 引用计数都不为0,永远不会回收!
}
circularRef();2. 标记-清除(Mark-Sweep)
现代 GC 的基础算法。
阶段1:标记(Mark)
从根对象(全局对象、调用栈中的变量)出发,
遍历所有可达对象,标记为"存活"
根对象
├── window.app ──→ { data: [...] } ✅ 可达
├── 当前函数的 localVar ──→ { x: 1 } ✅ 可达
└── ...
堆中未被标记的对象 → ❌ 不可达
阶段2:清除(Sweep)
遍历堆,回收所有未被标记的对象
优点:解决了循环引用问题
缺点:
1. 需要暂停程序(Stop-The-World)
2. 清除后内存碎片化3. 标记-整理(Mark-Compact)
标记阶段同上,但清除后会整理内存:
整理前:[存活][ 空 ][存活][ 空 ][存活][ 空 ]
整理后:[存活][存活][存活][ 空闲 ]
优点:消除内存碎片
缺点:移动对象开销大V8 引擎的分代回收
V8 将堆内存分为新生代和老生代:
堆内存
┌──────────────────────────────────────────────────┐
│ 新生代(Young Generation) │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ From 空间 │ │ To 空间 │ │
│ │ (Semi-space) │ │ (Semi-space) │ │
│ │ 1-8MB │ │ 1-8MB │ │
│ └───────────────────┘ └───────────────────┘ │
│ 新创建的对象在这里,存活时间短 │
│ GC频率高,速度快 │
├──────────────────────────────────────────────────┤
│ 老生代(Old Generation) │
│ ┌──────────────────────────────────────────┐ │
│ │ Old Space(数百MB-数GB) │ │
│ │ 存活时间长的对象 │ │
│ │ GC频率低 │ │
│ └──────────────────────────────────────────┘ │
├──────────────────────────────────────────────────┤
│ 大对象空间(Large Object Space) │
│ 超过一定大小的对象直接分配在这里 │
├──────────────────────────────────────────────────┤
│ 代码空间(Code Space) │
│ JIT 编译后的机器码 │
├──────────────────────────────────────────────────┤
│ Map空间 / Cell空间 │
│ 隐藏类(Hidden Class)等内部结构 │
└──────────────────────────────────────────────────┘新生代回收:Scavenge(Minor GC)
Scavenge 算法(Cheney's Algorithm):
1. 新对象分配在 From 空间
2. From 空间满时,触发 GC
From 空间 To 空间
┌─────────┐ ┌─────────┐
│ A(存活) │ │ │
│ B(垃圾) │ ──→ │ A │ 复制存活对象到 To
│ C(存活) │ │ C │
│ D(垃圾) │ │ │
└─────────┘ └─────────┘
3. From 和 To 角色互换
晋升条件(对象从新生代→老生代):
• 经历过一次 Scavenge 仍然存活
• To 空间使用超过 25%
优点:速度极快(只复制存活对象)
缺点:空间利用率只有50%(一半是 To 空间)老生代回收(Major GC)
老生代使用 Mark-Sweep + Mark-Compact:
1. 标记阶段(增量标记 - Incremental Marking)
不是一次性标记完,而是分成小步:
JS执行 → 标记一点 → JS执行 → 标记一点 → ...
减少了 Stop-The-World 的停顿时间
2. 清除阶段(并发清除 - Concurrent Sweeping)
清除在辅助线程中进行,不阻塞主线程
3. 整理阶段(增量整理 - Incremental Compaction)
只在碎片化严重时才整理V8 GC 优化技术
1. 增量标记(Incremental Marking)
将标记工作分成多个小步,穿插在JS执行中
每步约 5ms
2. 并发标记(Concurrent Marking)
辅助线程帮助标记,主线程继续执行JS
V8 v6.4+ 支持
3. 并行清除(Parallel Sweeping)
多个辅助线程同时清除
4. 惰性清除(Lazy Sweeping)
不立即清除所有页面,按需清除
5. 写屏障(Write Barrier)
当老生代对象引用新生代对象时记录
避免 Minor GC 时扫描整个老生代WeakRef 与 FinalizationRegistry
WeakRef(ES2021)
javascript
// WeakRef 创建对象的弱引用,不阻止GC回收
let target = { name: '张三', data: new Array(1000000) };
const weakRef = new WeakRef(target);
// 通过 deref() 获取原对象
console.log(weakRef.deref()); // { name: '张三', ... }
target = null; // 移除强引用
// GC 后,deref() 可能返回 undefined
// 注意:GC 时机不确定,不能依赖具体时间
setTimeout(() => {
console.log(weakRef.deref()); // 可能是 undefined
}, 10000);FinalizationRegistry(ES2021)
javascript
// 当对象被GC回收时,执行回调
const registry = new FinalizationRegistry((heldValue) => {
console.log(`对象 "${heldValue}" 已被回收`);
// 可以在这里执行清理工作
});
let obj = { name: '张三' };
registry.register(obj, '张三的对象'); // 注册监听
obj = null; // 移除引用,等待GC
// 某次GC后输出:对象 "张三的对象" 已被回收实战:带自动清理的缓存
javascript
class AutoCleanCache {
#cache = new Map();
#registry;
constructor() {
this.#registry = new FinalizationRegistry((key) => {
// 对象被GC后,自动从缓存中移除
const ref = this.#cache.get(key);
if (ref && !ref.deref()) {
this.#cache.delete(key);
console.log(`缓存键 "${key}" 已自动清理`);
}
});
}
set(key, value) {
const ref = new WeakRef(value);
this.#cache.set(key, ref);
this.#registry.register(value, key);
}
get(key) {
const ref = this.#cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
this.#cache.delete(key);
return undefined;
}
return value;
}
get size() {
return this.#cache.size;
}
}
const cache = new AutoCleanCache();
let bigData = { data: new Array(1000000).fill('*') };
cache.set('bigData', bigData);
console.log(cache.get('bigData')); // { data: [...] }
bigData = null; // 等GC回收后,缓存会自动清理WeakMap 和 WeakSet
javascript
// WeakMap:键必须是对象,键是弱引用
const wm = new WeakMap();
let obj = { id: 1 };
wm.set(obj, '额外数据');
console.log(wm.get(obj)); // '额外数据'
obj = null; // obj 被GC后,WeakMap 中的条目自动消失
// WeakMap 不可迭代,没有 size 属性
// 常用场景:为对象附加元数据,不影响GC
// WeakSet:值必须是对象,弱引用
const ws = new WeakSet();
let el = document.querySelector('#target');
ws.add(el);
ws.has(el); // true
el = null; // GC后自动移除
// 实际应用:标记已处理的对象
const processed = new WeakSet();
function processOnce(obj) {
if (processed.has(obj)) return; // 已处理过
processed.add(obj);
// 处理逻辑...
}内存泄漏排查实战
使用 Chrome DevTools
步骤1:Performance Monitor(快速检测)
DevTools → Ctrl+Shift+P → "Performance Monitor"
观察 JS Heap Size 是否持续增长(正常应该有锯齿形波动)
步骤2:三次快照法(定位泄漏对象)
Memory 面板 → Heap Snapshot
1. 拍摄基准快照
2. 执行可能泄漏的操作(如打开/关闭弹窗)
3. 点击 GC 按钮
4. 拍摄第二个快照
5. 重复操作
6. 点击 GC,拍摄第三个快照
7. 选择 "Comparison" 视图,对比快照2和快照3
8. 查看 Delta > 0 的对象(新增但未释放的)
步骤3:Allocation Timeline(实时追踪)
Memory → "Allocation instrumentation on timeline"
蓝色条 = 仍存活的分配
选择蓝色条查看具体对象
步骤4:关键词搜索
在快照中搜索 "Detached" → 找到分离的DOM节点
这些节点已从页面移除,但仍被JS引用常见内存泄漏模式
javascript
// 1. 意外的全局变量
function leak() {
globalVar = 'oops'; // 没有 var/let/const → 全局变量
}
// 2. 被遗忘的定时器
const data = loadData();
setInterval(() => {
process(data); // data 永远不会被回收
}, 1000);
// 3. DOM引用未清除
const elements = {};
function addElement() {
const el = document.createElement('div');
document.body.appendChild(el);
elements[el.id] = el; // 缓存了DOM引用
}
function removeElement(id) {
const el = elements[id];
document.body.removeChild(el);
// ❌ 忘记 delete elements[id]
// el 仍然被 elements 对象引用
}
// 4. 闭包持有大对象
function createHandler() {
const hugeData = new Array(1000000);
return function handler() {
return hugeData.length;
};
}
// handler 闭包持有 hugeData 的引用
// 5. 事件监听器未移除
class Component {
constructor() {
this.data = new Array(1000);
window.addEventListener('resize', this.onResize);
}
onResize = () => {
console.log(this.data.length);
};
// destroy 时忘记移除监听器
}
// 6. console.log 保持引用(开发环境)
const bigObj = createBigObject();
console.log(bigObj); // DevTools 保持引用!
// 7. Map/Set 中的对象引用
const cache = new Map();
function process(obj) {
cache.set(obj.id, obj); // obj 被 cache 引用
// 即使外部不再需要 obj,cache 中仍然持有
}Node.js 内存分析
javascript
// 查看内存使用
console.log(process.memoryUsage());
// {
// rss: 30000000, // 常驻内存集(包含代码段、栈、堆)
// heapTotal: 18000000, // V8 堆总大小
// heapUsed: 12000000, // V8 堆已用大小
// external: 1000000, // C++ 对象绑定的内存
// arrayBuffers: 500000 // ArrayBuffer 和 SharedArrayBuffer
// }
// 手动触发 GC(需要 --expose-gc 参数)
// node --expose-gc app.js
global.gc();
// 使用 heapdump 生成快照
// npm install heapdump
const heapdump = require('heapdump');
heapdump.writeSnapshot('/tmp/heap-' + Date.now() + '.heapsnapshot');
// 然后用 Chrome DevTools 打开分析
// V8 内存限制
// 64位系统:约 1.4GB(老生代约 1.4GB)
// 32位系统:约 0.7GB
// 可通过启动参数调整:
// node --max-old-space-size=4096 app.js // 老生代最大 4GB
// node --max-semi-space-size=64 app.js // 新生代半空间 64MB内存优化最佳实践
javascript
// 1. 及时解除引用
let data = loadData();
processData(data);
data = null; // 不再需要时解除引用
// 2. 使用 WeakMap/WeakSet 做缓存
const metadata = new WeakMap();
function addMeta(obj, meta) {
metadata.set(obj, meta); // obj 被GC时,meta 自动清除
}
// 3. 事件监听器用 AbortController 管理
const controller = new AbortController();
element.addEventListener('click', handler, { signal: controller.signal });
element.addEventListener('scroll', handler2, { signal: controller.signal });
// 一键移除所有监听器
controller.abort();
// 4. 大数组处理完后释放
function processLargeData() {
let arr = new Array(10000000).fill(0);
const result = arr.reduce((sum, n) => sum + n, 0);
arr = null; // 显式释放
return result;
}
// 5. 对象池(复用对象,减少GC压力)
class ObjectPool {
#pool = [];
#create;
constructor(createFn, initialSize = 10) {
this.#create = createFn;
for (let i = 0; i < initialSize; i++) {
this.#pool.push(createFn());
}
}
acquire() {
return this.#pool.pop() || this.#create();
}
release(obj) {
this.#pool.push(obj);
}
}
// 6. 使用 structuredClone 代替 JSON.parse(JSON.stringify())
const clone = structuredClone(originalData);总结
垃圾回收核心知识点:
┌──────────────────────────────────────────────────────────┐
│ GC 算法 │
│ • 引用计数:简单但有循环引用问题 │
│ • 标记-清除:解决循环引用,但有碎片化 │
│ • 标记-整理:消除碎片,但开销大 │
├──────────────────────────────────────────────────────────┤
│ V8 分代回收 │
│ • 新生代:Scavenge(From/To空间复制),快速 │
│ • 老生代:增量标记 + 并发清除 + 按需整理 │
│ • 晋升条件:经历过一次 Scavenge 或 To 空间超25% │
│ • 优化:增量标记、并发标记、并行清除、写屏障 │
├──────────────────────────────────────────────────────────┤
│ 弱引用 API │
│ • WeakRef:不阻止GC,deref() 获取对象 │
│ • FinalizationRegistry:对象被回收时执行回调 │
│ • WeakMap/WeakSet:键/值是弱引用,自动清理 │
├──────────────────────────────────────────────────────────┤
│ 内存泄漏排查 │
│ • Chrome DevTools Memory 面板(三次快照法) │
│ • 搜索 "Detached" DOM 节点 │
│ • 常见原因:全局变量、定时器、闭包、DOM引用、事件监听 │
└──────────────────────────────────────────────────────────┘