一次诡异的内存泄漏排查记录
现象 一个长驻的后端服务运行三四天后内存从 200MB 涨到 1.8GB,最终被 OOM Killer 杀掉。重启后一切正常,然后周而复始。 排查过程 第一步:确认是泄漏还是缓存 先看监控曲线。如果内存是"锯齿状"(涨上去又掉下来)多半是缓存策略问题;如果是"楼梯状"只涨不跌,基本就是泄漏。我们的曲线是典型的楼梯状,确认泄漏。 第二步:抓内存快照对比 在服务运行 1 小时和 24 小时分别抓堆快照,diff 对比对象数量的增长: # 运行 1 小时 jmap -dump:live,format=b,file=heap1.hprof <pid> # 运行 24 小时 jmap -dump:live,format=b,file=heap2.hprof <pid> 用 MAT 打开两个快照对比,发现 EventListenerRegistry 里的监听器对象 24 小时增长了 4 万多个——每个都持有外部对象的引用。 第三步:定位到代码 查看注册监听器的调用点,发现问题代码: // 每次请求都会 new 一个监听器注册进去,但从不注销 public void handle(Request req) { bus.subscribe(new MetricsListener(req.getSessionId()), req.getTopic()); ... } 事件总线是全局单例,监听器以强引用形式存在,请求结束后监听器及其持有的 SessionId 等对象永远无法被回收。 修复方案 短期:把强引用订阅改成弱引用(WeakReference 包装),对象无其他引用时可被回收 长期:明确订阅生命周期,请求结束时在 finally 里注销: Listener l = new MetricsListener(req.getSessionId()); bus.subscribe(l, req.getTopic()); try { ... } finally { bus.unsubscribe(l, req.getTopic()); } 经验总结 “只注册、不注销"是内存泄漏最常见的形态之一,事件总线、定时任务、回调注册都要检查配对的注销逻辑 泄漏排查的关键是两个时间点的快照对比,而不是盯着单个快照看 上线前用压测工具模拟持续请求流,几小时内就能暴露这类问题,不用等线上跑四天