Skip to content

深入理解垃圾回收与内存管理

V8引擎分代回收详细机制、WeakRef/FinalizationRegistry、内存泄漏排查实战

什么是垃圾回收?

定义:垃圾回收(Garbage Collection, GC)是 JavaScript 引擎自动管理内存的机制。它负责检测不再被使用(不可达)的对象,并释放其占用的内存空间,开发者无需手动分配和释放内存。

涉及场景

  • SPA 长时间运行:单页应用在不刷新页面的情况下运行数小时,内存泄漏会逐渐拖慢页面
  • 大量 DOM 操作:频繁创建和销毁 DOM 节点,分离的 DOM 节点(Detached DOM)是常见泄漏源
  • 闭包与事件监听:闭包持有外部变量引用、事件监听器未清除导致对象无法回收
  • 缓存管理:Map/Set 中缓存的对象若不主动删除,会一直占用内存
  • Node.js 服务端:服务端进程长期运行,内存泄漏会导致 OOM(Out of Memory)崩溃
  • WebWorker / SharedArrayBuffer:多线程场景下的内存共享与回收

作用

  1. 自动内存管理:开发者无需像 C/C++ 一样手动 malloc / free
  2. 防止内存泄漏:理解 GC 机制才能写出不泄漏的代码
  3. 性能调优:GC 暂停(Stop-The-World)影响帧率,优化 GC 行为可提升流畅度
  4. 面试重点: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引用、事件监听          │
└──────────────────────────────────────────────────────────┘