现象

一个长驻的后端服务运行三四天后内存从 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());
}

经验总结

  • “只注册、不注销"是内存泄漏最常见的形态之一,事件总线、定时任务、回调注册都要检查配对的注销逻辑
  • 泄漏排查的关键是两个时间点的快照对比,而不是盯着单个快照看
  • 上线前用压测工具模拟持续请求流,几小时内就能暴露这类问题,不用等线上跑四天