Java内存泄漏无直接报错,典型信号有三:一是“GC overhead limit exceeded”,表现为GC耗时占比高但回收少,需用jstat查老年代占用及FGC原因,并dump分析Dominator Tree;二是“OOM: Java heap space”,非单纯堆小,应结合jmap -histo和MAT对比dump查对象增长及GC Roots引用链;三是内存缓慢持续上升、Full GC不回落,需排查容器配置、堆外内存及连续dump比对增长类。

一、“GC overhead limit exceeded” —— GC 卖命干活却收效甚微
这不是堆满的告警,而是 JVM 发出的求救信号:过去一段时间里,98% 的时间花在 GC 上,却只回收了不到 2% 的堆内存。
说明:对象反复被创建又长期存活(比如被静态 Map 持有),年轻代刚回收完,老年代就快满了,Full GC 接连触发,但每次只能腾出一点点空间。
- 立刻用 jstat -gcutil <pid> 1000 5 看 O(Old)列是否持续 >90%,FGC 次数是否在快速上涨
- 配合 jstat -gccause <pid> 1000 确认每次 Full GC 是因为“Allocation Failure”还是“Metadata GC Threshold”——前者指向堆内泄漏,后者可能误判为元空间问题
- 别急着调大堆,先 dump:jmap -dump:live,format=b,file=heap.hprof <pid>,重点看“Dominator Tree”里谁占老年代最多
二、“java.lang.OutOfMemoryError: Java heap space” —— 堆真撑爆了,但根因未必是“不够用”
很多人第一反应是加 -Xmx,可如果同一服务在低峰期稳如泰山,高峰期秒崩,大概率不是配置小,而是泄漏在放大流量压力。
典型泄漏模式:缓存没过期策略、监听器未注销、线程局部变量(ThreadLocal)持有大对象、日志框架循环引用上下文等。
- 确认是不是“真泄漏”:用 jmap -histo <pid> 查对象数量。比如发现 char[]、String、HashMap$Node 数量远超正常值(比如百万级),且随运行时间单调递增,基本坐实
- 对比两次 dump:用 MAT 打开两个不同时间点的 heap.hprof,用 “Leak Suspects Report” + “Compare Basket” 看哪些类实例数/总大小增长最快
- 重点关注 “Path to GC Roots” 中的强引用链:比如某个静态 HashMap → 业务 DTO → 内部 byte[] → 占几十MB,而这个 DTO 本该用完即丢
三、没报错,但内存曲线“只升不降”——最隐蔽也最危险的泄漏
监控显示堆内存使用率缓慢爬升,每天涨 200MB,Full GC 后也不回落,重启才归零。这种没触发 OOM 的慢性泄漏,最容易被忽略,也最耗运维人力。
- 先排除假象:检查 JVM 是否开了 -XX:+UseContainerSupport(K8s 环境下),并确认 -Xmx 设置没被容器内存限制覆盖
- 用 jcmd <pid> VM.native_memory summary scale=MB 看 non-heap 区(如 Internal、Thread、Class)是否异常膨胀——如果是,可能不是堆泄漏,而是堆外泄漏(比如 Netty Direct Buffer、JNI 调用未释放)
- 导出连续三个间隔 30 分钟的 heap dump,用 MAT 的 “Histogram → Group by Package” + “Merge Shortest Paths to GC Roots” 对比,找稳定增长的包名或类名
- 代码侧重点检查:全局静态集合、Spring Bean 的 @PostConstruct 初始化逻辑、监听器注册后是否配对 @PreDestroy、异步任务提交后是否漏掉 Future.cancel()


















