内存泄漏检测
找和修应用内存随时间无界增长最终崩的内存泄漏。
为什么
内存泄漏是慢杀手——应用跑数小时或数天正常,然后突 OOM(内存不足)崩,重启丢在途请求和用户会话。在长跑服务器或 SPA 中,每个泄漏引用(未移事件监听、捕获增长数组的闭包、无驱逐缓存)累积直到进程耗尽堆。最糟的是泄漏在正常测试中不可见——须负载下跑数小时见增长,那时根因埋在百万分配中。早检测泄漏用堆快照和增长监控防 3am 崩和随后的用户可见中断。
何时使用
长跑进程(服务器、worker、SPA)监控显内存增长时。也作任何持续运行服务的发布前检查。加缓存、事件监听或订阅模式后测泄漏。
如何做
1. 用监控确认泄漏
- APM 中随时间追踪进程内存(RSS、堆用)
- 泄漏显稳增长永不平台即使空闲
- 排正常增长(缓存预热、JIT 编译)——30 分钟后平台吗?
2. 在受控环境复现泄漏
- 循环跑可疑操作(如创建和销毁 10000 请求)
- 迭代间强制垃圾回收(node --expose-gc、global.gc())
- 若 GC 后仍增长有泄漏——保留引用阻收集
3. 取堆快照比较
- Node: 用 inspector——node --inspect,然后 Chrome DevTools → Memory → Heap snapshot
- 取快照 1、跑操作 1000 次、强制 GC、取快照 2
- 比快照 2 和 1——"delta" 显分配未释放的
- 找高保留大小对象: 数组、闭包、DOM 节点、Map/Set
4. 找保留路径
- 堆快照中找泄漏对象看其 "retainers"
- 保留者是阻 GC 的引用——事件监听、缓存、闭包
- 常见模式: addEventListener 无 removeEventListener、setInterval 无 clearInterval、
无删增长 Map、捕获增长数组闭包
5. 修泄漏并验证
- 移监听、清 interval、给缓存加驱逐、弱化引用
- 重跑循环测试——内存应平台非增长
- 部署看监 控——增长曲线应平示例
// 泄漏: 事件监听未移
class EventEmitter {
constructor() {
this.handlers = [];
}
on(handler) {
this.handlers.push(handler);
}
off(handler) {
// BUG: 从不调,故 handlers 永增长
this.handlers = this.handlers.filter(h => h !== handler);
}
}
// 检测: 循环跑、强制 GC、测堆
// leak-test.js
const emitter = new EventEmitter();
function runOnce() {
const data = new Array(10000).fill("x"); // 每迭代 80KB
const handler = () => console.log(data.length);
emitter.on(handler);
emitter.emit("event");
emitter.off(handler); // 若 off 坏或忘,data 被保留
}
for (let i = 0; i < 10000; i++) {
runOnce();
if (i % 1000 === 0) {
global.gc(); // 强制 GC(--expose-gc)
const used = process.memoryUsage().heapUsed;
console.log(`迭代 ${i}: ${(used / 1024 / 1024).toFixed(1)} MB`);
}
}
// 泄漏输出: 10 → 50 → 110 → 170 MB(线性增长)
// 修复输出: 10 → 12 → 12 → 12 MB(平台)
// 修: 确保 off() 总调,或用 WeakRef / FinalizationRegistry
// 或用 Set 在 off 删,或用 AbortController 管监听生命周期
const controller = new AbortController();
emitter.on(handler, { signal: controller.signal });
// 完成时:
controller.abort(); // 自动移监听、释放闭包常见问题
- ⚠循环或每次渲染加事件监听不移——每迭代加新监听捕获其闭包,内存随每请求或渲染周期增长。
- ⚠用 Map 或对象作缓存无驱逐——缓存随每唯一键增长,一周生产流量后持百万条目耗尽堆。
- ⚠闭包无意捕获大对象——回调闭包 10MB 请求体即使只需响应状态,体保留到回调被 GC。
- ⚠setInterval 或 setTimeout 回调引用大对象且不清——定时器保闭包活,保大对象活,远超需要。
最佳实践
- ✓始终 addEventListener 配 removeEventListener,或用 AbortController 管监听生命周期——controller.abort() 自动移所有以其 signal 注册的监听。
- ✓用带最大大小的 LRU 缓存(lru-cache npm 包)替普通 Map——缓 存满时驱逐最旧条目,无论流量有界内存。
- ✓负载测试前后取堆快照比 delta——比较立即显分配未释放,分钟内定位泄漏。
- ✓生产监控进程内存对持续增长告警——永不平台的内存曲线是泄漏,10% 增长抓远易于 90% OOM 崩。