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 heapdumpjavascript
const heapdump = require('heapdump');
// 低水位时
heapdump.writeSnapshot('./low-' + Date.now() + '.heapsnapshot');
// 高水位时
heapdump.writeSnapshot('./high-' + Date.now() + '.heapsnapshot');Step 3: Chrome DevTools 对比分析
- 打开 Chrome DevTools → Memory 面板
- 导入两份
.heapsnapshot文件 - 选择 Comparison View 对比
- 关注 Retainers (保留器) — 谁持有了不该持有的引用
常见内存泄漏源
| 嫌疑人 | 典型场景 | 解决方案 |
|---|---|---|
| 全局缓存 | Map/Object 无限增长 | 使用 LRU-Cache |
| 闭包引用 | EventEmitter 只 on 没 off | 及时 removeListener |
| 队列积压 | 消费 < 生产 | 背压控制 |
| 定时器 | setInterval 未清理 | clearInterval |
2. V8 垃圾回收机制 🔥
内存分代
┌─────────────────────────────────────────────────────┐
│ V8 Heap │
├─────────────────────┬───────────────────────────────┤
│ 新生代 │ 老生代 │
│ (New Space) │ (Old Space) │
│ │ │
│ ┌───────┬───────┐ │ │
│ │ From │ To │ │ │
│ │ Space │ Space │ │ │
│ └───────┴───────┘ │ │
│ │ │
│ 对象存活率: 低 │ 对象存活率: 高 │
│ 算法: Scavenge │ 算法: Mark-Sweep/Compact │
└─────────────────────┴───────────────────────────────┘新生代: Scavenge (Cheney) 算法
适用场景: 对象存活率低 (朝生夕死)
流程:
- 新对象分配在 From Space
- GC 时,将存活对象复制到 To Space
- From 和 To 空间互换角色
- 经历 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