内存泄漏是缓慢积累的“慢性病”,表现为老年代使用率持续抬升、Full GC频次激增但回收量微小、堆内存呈台阶式上升、响应变慢而CPU不高;需用jstat观察OU阶梯上升趋势,结合Heap Dump与MAT分析定位静态集合、ThreadLocal等高频泄漏点。

内存泄漏在JVM中不是突然爆发的故障,而是缓慢积累的“慢性病”。它不会立刻报错,但会让老年代内存使用率持续抬升、Full GC越来越频繁却收效甚微,最终以 java.lang.OutOfMemoryError: Java heap space 收场。真正棘手的是:OOM只是结果,泄漏点往往藏在业务逻辑深处,和报错堆栈毫无关系。
看懂内存泄漏的典型信号
别等OOM才行动。以下现象出现任意一项,就该启动排查:
-
老年代使用率(OU)持续爬升:用
jstat -gcutil <pid> 2000观察,如果 O 列(Old Gen 使用率)每次 Full GC 后只回落一点点,且最低值逐轮抬高,基本可断定存在堆内泄漏; -
Full GC 频次激增但回收量很小:
jstat -gc <pid>中 FGC 次数快速增加,而 OGCMN/OGCMX 不变、OU 却逼近上限,说明老年代对象“死而不僵”; - 堆内存呈“锯齿状上升”趋势:监控图表里,每次 GC 后的内存底边像台阶一样一阶阶抬高,这是最直观的泄漏画像;
- 应用响应变慢、吞吐下降,但 CPU 并不高:GC 停顿时间(FGCT)明显拉长,线程大量阻塞在 safepoint,不是计算瓶颈,而是被 GC 拖累。
用 jstat 快速定位泄漏区域
jstat 是线上诊断的第一把快刀,无需重启、无侵入、开销极低。关键不是看单次数值,而是盯住变化趋势:
- 执行
jstat -gcutil <pid> 1000,每秒刷新一次,重点关注 E(Eden)、O(Old)、YGC、FGC 四列; - 若 E 频繁打满、YGC 很多但对象总往老年代送,可能是对象存活时间过长(如大对象直接分配、Minor GC 晋升阈值设置不合理);
- 若 O 持续上涨、FGC 越来越密、FGCT 越来越长,说明老年代里有对象长期驻留——这就是泄漏的主战场;
- 配合
jstat -gccapacity <pid>确认各代容量是否动态调整(G1 下通常固定),排除因扩容导致的假阳性。
抓取并分析堆转储(Heap Dump)
确认泄漏存在后,必须拿到“案发现场”的快照。优先使用自动触发方式,确保捕获到 OOM 前一刻的状态:
- 启动参数加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/,所有生产服务都应默认开启; - 若尚未OOM但已高度怀疑,可用
jmap -dump:live,format=b,file=heap.hprof <pid>手动抓取(注意:该操作会短暂 STW,避开业务高峰); - 用 MAT(Memory Analyzer Tool)打开 hprof 文件,首选 Leak Suspects Report —— 它会自动扫描并标出最可能的泄漏嫌疑对象及引用链;
- 深入看 Dominator Tree,按 retained heap 排序,找到占用内存最大的对象类型,再右键 → “Show Objects by Class” → 查看具体实例的引用路径,一直追溯到 GC Root。
聚焦高频泄漏场景与代码特征
80% 的泄漏集中在几类模式,看到对应代码要立刻警觉:
-
静态集合无清理:
private static Map<?, ?> cache = new HashMap<>();类型,只 put 不 remove,生命周期与 ClassLoader 绑定; -
ThreadLocal 未 remove:尤其在线程池复用场景下,
threadLocal.set(x);后没跟threadLocal.remove();,导致上一个请求的数据滞留在线程中; - 监听器/回调注册后未注销:Spring EventListener、Netty ChannelHandler、GUI 事件绑定等,注册容易,反注册常被遗忘;
-
缓存无淘汰机制:
new ConcurrentHashMap<>()或本地 Guava Cache 未设 maximumSize/expiryPolicy,数据只进不出; -
未关闭的 NIO 资源:
ByteBuffer.allocateDirect()分配的直接内存,或FileChannel/SocketChannel未显式 close,引发 Direct buffer memory OOM。

















