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 打开。我一般这么操作:

  1. 先跑一次正常流程,生成一个快照(baseline)
  2. 再跑几次有问题的流程,生成第二个快照
  3. 在 DevTools 里对比两个快照

对比时,重点看 新增的对象保留大小。如果某个对象数量异常多,或者保留大小特别大,那基本就是泄漏点了。

经验之谈: 我一般会先看 ClosureArray 这两个类型。闭包泄漏和数组泄漏是最常见的。

3.6 使用 Chrome DevTools 进行堆快照分析

打开 Chrome DevTools,切到 Memory 面板:

  1. 点击 "Take heap snapshot"
  2. 选择 "Comparison" 视图
  3. 加载两个快照进行对比

关键指标:

指标 说明
New 新创建的对象数量
Deleted 被回收的对象数量
Delta 变化量(New - Deleted)
Retained Size 对象及其引用的总大小

如果某个类型的 Delta 持续为正,而且 Retained Size 越来越大,那基本可以确定泄漏了。

我曾经用这个方法,发现一个 Buffer 对象泄漏。每次请求都创建一个 Buffer,但没释放。对比快照后,Buffer 的数量从 100 涨到了 10000,问题一目了然。

注意: 堆快照分析时,Node.js 会暂停执行。生产环境要小心,最好在低峰期操作,或者用 --inspect 远程调试。

3.7 总结:预防胜于治疗

内存泄漏的检测,说白了就是“对比”。对比两次快照,看谁多了、谁大了。但更重要的,是预防。

  • 全局变量:能用局部就别用全局,非用不可就用 WeakMap
  • 闭包:只引用需要的东西,用完释放
  • 定时器:谁创建谁清理,用 clearIntervalclearTimeout
  • 事件监听器:用 once 或手动 removeListener

嗯,这些经验都是我用线上事故换来的。希望你能少踩几个坑。