3. 内存泄漏的常见模式与检测:全局变量、闭包、定时器、事件监听器未清理
内存泄漏,说白了就是程序用完了内存不还。Node.js 跑着跑着,内存占用越来越高,最后进程挂了。我见过太多线上事故,都是这玩意儿搞的鬼。
今天咱们就聊聊最常见的四种泄漏模式,以及怎么用工具把它们揪出来。
3.1 全局变量:最隐蔽的“内存黑洞”
先看个例子:
// 泄漏版
function processRequest(data) {
// 不小心把数据挂到了全局
global.cache = global.cache || [];
global.cache.push(data);
}
// 正确版
const cache = [];
function processRequest(data) {
cache.push(data);
}
为什么会泄漏?因为 global 对象在整个进程生命周期内都不会被回收。你往上面挂东西,它就一直在那儿。
我个人习惯,在代码里加个 lint 规则,禁止直接使用 global。如果真需要全局缓存,用 Map 或者 WeakMap,至少能控制生命周期。
const cache)虽然也是“全局”的,但它是模块作用域,比 global 安全得多。不过如果模块一直不被卸载,它也会一直存在。
3.2 闭包:看似无害,实则“内存钉子户”
闭包是 JavaScript 的利器,但用不好就是噩梦。
function createLeaky() {
const largeData = new Array(1000000).fill('leak');
return function() {
// 这个函数引用了 largeData
console.log('hello');
};
}
const leakyFn = createLeaky();
// largeData 永远不会被回收,因为 leakyFn 还活着
我在项目中遇到过类似情况。一个定时任务里用了闭包,每次执行都创建一个大数组,结果内存涨了 200MB 才被发现。
避坑指南:闭包只引用你真正需要的东西。如果内部函数只用到了外部函数的某个参数,就别把整个外部作用域都带进来。
null 手动释放不再需要的引用。比如在闭包执行完后,把 leakyFn = null。
3.3 定时器:忘记清理的“内存定时炸弹”
定时器泄漏,我见过太多次了。
function startTimer() {
const data = { /* 一些数据 */ };
setInterval(() => {
// 这个回调引用了 data
console.log(data);
}, 1000);
// 定时器永远不会被清除!
}
startTimer();
每次调用 startTimer,都会创建一个新的定时器,而且回调里引用的 data 也永远无法被回收。定时器本身也会占用资源。
我曾经在一个 WebSocket 服务里发现,每次连接都创建了一个定时器,但断开连接时没清理。结果 1000 个连接后,内存直接爆了。
正确的做法:
function startTimer() {
const data = { /* 一些数据 */ };
const timerId = setInterval(() => {
console.log(data);
}, 1000);
// 返回 timerId,让调用者可以清理
return timerId;
}
const timerId = startTimer();
// 不需要时清理
clearInterval(timerId);
3.4 事件监听器:最容易被忽视的泄漏源
事件监听器泄漏,说白了就是“只注册,不注销”。
const EventEmitter = require('events');
class MyService {
constructor() {
this.emitter = new EventEmitter();
// 每次创建实例都注册监听器
this.emitter.on('data', (data) => {
console.log(data);
});
}
}
// 如果 MyService 实例被销毁,但 emitter 还在,监听器就不会被回收
更隐蔽的情况:
// 在循环中注册监听器
for (let i = 0; i < 1000; i++) {
someEmitter.on('event', () => {
// 每个监听器都引用了外部变量
console.log(i);
});
}
// 1000 个监听器,每个都占内存
我个人习惯,在注册监听器时,一定想好什么时候注销。可以用 once 代替 on,或者用 removeListener 手动清理。
3.5 使用 heapdump 进行堆快照分析
理论说完了,来点实战。怎么找到泄漏点?用 heapdump。
// 安装:npm install heapdump
const heapdump = require('heapdump');
// 在代码中手动触发
heapdump.writeSnapshot('./heap.heapsnapshot');
// 或者通过信号触发
// kill -USR2 <pid>
生成快照后,用 Chrome DevTools 打开。我一般这么操作:
- 先跑一次正常流程,生成一个快照(baseline)
- 再跑几次有问题的流程,生成第二个快照
- 在 DevTools 里对比两个快照
对比时,重点看 新增的对象 和 保留大小。如果某个对象数量异常多,或者保留大小特别大,那基本就是泄漏点了。
Closure 和 Array 这两个类型。闭包泄漏和数组泄漏是最常见的。
3.6 使用 Chrome DevTools 进行堆快照分析
打开 Chrome DevTools,切到 Memory 面板:
- 点击 "Take heap snapshot"
- 选择 "Comparison" 视图
- 加载两个快照进行对比
关键指标:
| 指标 | 说明 |
|---|---|
| New | 新创建的对象数量 |
| Deleted | 被回收的对象数量 |
| Delta | 变化量(New - Deleted) |
| Retained Size | 对象及其引用的总大小 |
如果某个类型的 Delta 持续为正,而且 Retained Size 越来越大,那基本可以确定泄漏了。
我曾经用这个方法,发现一个 Buffer 对象泄漏。每次请求都创建一个 Buffer,但没释放。对比快照后,Buffer 的数量从 100 涨到了 10000,问题一目了然。
--inspect 远程调试。
3.7 总结:预防胜于治疗
内存泄漏的检测,说白了就是“对比”。对比两次快照,看谁多了、谁大了。但更重要的,是预防。
- 全局变量:能用局部就别用全局,非用不可就用
WeakMap - 闭包:只引用需要的东西,用完释放
- 定时器:谁创建谁清理,用
clearInterval或clearTimeout - 事件监听器:用
once或手动removeListener
嗯,这些经验都是我用线上事故换来的。希望你能少踩几个坑。