Skip to content

Node.js 内存管理与 V8 GC

1. OOM 排查全链路 🔥

问题特征

  • 内存缓慢增长直到 OOM (Out of Memory) 崩溃
  • 需区分: Heap Used (堆内存) vs RSS (常驻内存)

Step 1: 监控确认

javascript
// 内存监控
setInterval(() => {
    const mem = process.memoryUsage();
    console.log({
        heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)} MB`,
        heapTotal: `${Math.round(mem.heapTotal / 1024 / 1024)} MB`,
        rss: `${Math.round(mem.rss / 1024 / 1024)} MB`,
        external: `${Math.round(mem.external / 1024 / 1024)} MB`
    });
}, 5000);

Step 2: 堆快照采集

bash
# 方法 A: heapdump
npm install heapdump
javascript
const heapdump = require('heapdump');

// 低水位时
heapdump.writeSnapshot('./low-' + Date.now() + '.heapsnapshot');

// 高水位时
heapdump.writeSnapshot('./high-' + Date.now() + '.heapsnapshot');

Step 3: Chrome DevTools 对比分析

  1. 打开 Chrome DevTools → Memory 面板
  2. 导入两份 .heapsnapshot 文件
  3. 选择 Comparison View 对比
  4. 关注 Retainers (保留器) — 谁持有了不该持有的引用

常见内存泄漏源

嫌疑人典型场景解决方案
全局缓存Map/Object 无限增长使用 LRU-Cache
闭包引用EventEmitter 只 onoff及时 removeListener
队列积压消费 < 生产背压控制
定时器setInterval 未清理clearInterval

2. V8 垃圾回收机制 🔥

内存分代

┌─────────────────────────────────────────────────────┐
│                    V8 Heap                          │
├─────────────────────┬───────────────────────────────┤
│     新生代           │            老生代             │
│   (New Space)       │        (Old Space)            │
│                     │                               │
│  ┌───────┬───────┐  │                               │
│  │ From  │  To   │  │                               │
│  │ Space │ Space │  │                               │
│  └───────┴───────┘  │                               │
│                     │                               │
│  对象存活率: 低      │  对象存活率: 高                │
│  算法: Scavenge     │  算法: Mark-Sweep/Compact      │
└─────────────────────┴───────────────────────────────┘

新生代: Scavenge (Cheney) 算法

适用场景: 对象存活率低 (朝生夕死)

流程:

  1. 新对象分配在 From Space
  2. GC 时,将存活对象复制到 To Space
  3. From 和 To 空间互换角色
  4. 经历 2 次 GC 仍存活 → 晋升到老生代

优点: 速度快 (只复制存活对象) 缺点: 浪费一半内存

老生代: Mark-Sweep & Mark-Compact

适用场景: 对象存活率高

阶段操作说明
Mark从根对象遍历,标记所有可达对象使用三色标记
Sweep清除未标记的对象产生内存碎片
Compact将存活对象移动到一端整理碎片 (可选)

3. 增量标记与全停顿 🔥

全停顿 (Stop-The-World)

传统 GC 会暂停 JS 主线程,对于大内存可能导致几百毫秒卡顿。

执行 JS ──────┬── GC (500ms 停顿!) ──┬── 继续执行
              │                      │
              ▼                      ▼
           用户感知到卡顿

增量标记 (Incremental Marking)

V8 将标记过程拆分成小步,穿插在 JS 执行间隙:

执行 JS ─┬─ GC ─┬─ JS ─┬─ GC ─┬─ JS ─┬─ GC ─┬─ 继续
         │ 5ms  │      │ 5ms  │      │ 5ms  │
         ▼      ▼      ▼      ▼      ▼      ▼
                  最大暂停时间显著降低

效果: 虽然总 GC 时间可能变长,但最大暂停时间大幅降低。

并发与并行 GC

策略说明
增量 (Incremental)主线程分步执行
并发 (Concurrent)辅助线程执行,主线程继续 JS
并行 (Parallel)多个辅助线程同时执行 GC

4. 堆快照关键指标 🔥

Shallow Size vs Retained Size

指标定义用途
Shallow Size对象自身占用的内存通常很小
Retained Size对象 + 其支配的所有子对象大小排查泄漏重点关注

IMPORTANT

Retained Size 表示:如果 GC 回收该对象,总共能释放多少内存。

示例

javascript
class Container {
    constructor() {
        this.data = new Array(1000000).fill('x');  // 大数组
    }
}

const leak = new Container();
// leak 的 Shallow Size ≈ 几十字节 (对象头)
// leak 的 Retained Size ≈ 几 MB (包含 data 数组)

Retainers (保留器)

查看"谁持有了这个对象的引用",追溯泄漏根源:

LeakedObject
  ↑ held by cache['key123']
    ↑ held by global.cache
      ↑ held by (global)

5. 最佳实践

避免内存泄漏

javascript
// ❌ 错误: 全局缓存无限增长
const cache = {};
function getData(key) {
    if (!cache[key]) cache[key] = fetchFromDB(key);
    return cache[key];
}

// ✅ 正确: 使用 LRU 缓存
const LRU = require('lru-cache');
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 });

及时清理监听器

javascript
// ❌ 错误: 只添加不移除
socket.on('data', handler);

// ✅ 正确: 配对使用
socket.on('data', handler);
socket.once('close', () => {
    socket.removeListener('data', handler);
});

监控 GC 活动

bash
node --trace-gc app.js
# 输出: [12345:0x...] 1234 ms: Scavenge 2.5 (3.0) -> 1.5 (4.0) MB

前端面试知识库