判断Java内存泄漏应优先使用jstat -gcutil监控GC趋势,重点关注OU单向爬升、EU下降幅度减小、S0U/S1U长期满载;再用jmap -histo:live轻量筛查异常对象,最后用MAT手动分析GC Roots定位隐式引用链。

内存泄漏不等于内存溢出,但长期泄漏几乎必然导致溢出。 判断一个 Java 服务是否在“悄悄 leak”,关键不是等 OutOfMemoryError 报出来——那时往往已错过黄金干预窗口;而是看堆内存是否呈现「缓慢上涨、GC 后不回落、老年代持续堆积」的特征。
怎么快速确认是不是内存泄漏?看 jstat 的 GC 趋势
真正有用的信号不在日志里,而在运行时的 GC 行为。用 jstat -gcutil <pid> 2000</pid> 每两秒刷新一次,重点盯三个指标:
-
S0U/S1U:幸存区使用率,频繁波动正常;若长期接近 100% 且不回收,说明对象没被及时晋升或清理 -
EU(Eden 使用率):每次 Young GC 后应大幅下降;若下降幅度越来越小,说明有大量对象在 Eden 区“赖着不走” -
OU(老年代使用率):最危险的指标——如果它随时间单向爬升,Full GC 后也只回落一点点,基本可断定存在泄漏
注意:jstat 不需要 dump 堆,零侵入、秒级响应,是线上第一道筛查关卡。别一上来就跑 jmap -dump,那会暂停应用几秒到几十秒,尤其大堆时风险极高。
为什么 jmap -histo:live 比 dump 更适合初筛?
jmap -histo:live <pid></pid> 输出的是当前存活对象的类统计,轻量、快速、不暂停 JVM(仅短暂 STW),比生成几个 GB 的 heap.hprof 文件实用得多。
立即学习“Java免费学习笔记(深入)”;
重点关注三列:#instances(实例数)、bytes(总字节数)、class name(类名)。常见泄漏信号包括:
- 某个自定义类(如
com.example.UserData)实例数达数十万甚至百万,远超业务合理范围 -
byte[]、char[]占比异常高,可能对应未关闭的流、未释放的缓存或 Base64 解码残留 - 大量
java.util.HashMap$Node或java.util.ArrayList,暗示集合类在无节制膨胀
⚠️ 坑点:jmap -histo 默认不加 :live 会统计所有对象(含待回收的),结果失真;务必写全 :live。
用 MAT 分析 dump 文件时,别只看“Leak Suspects Report”
MAT 自动生成的泄漏嫌疑报告(Leak Suspects Report)确实方便,但它依赖启发式规则,容易漏掉隐式引用链,比如:
- 静态内部类持有了外部 Activity/Service 实例(Android 场景)
-
ThreadLocal变量在线程池中长期存活,其 value 引用的对象无法释放 - 监听器注册后未注销,而监听器是匿名内部类,隐式持有外部类强引用
更可靠的做法是手动查 Dominator Tree → 找到占用内存最大的对象 → 右键 Path to GC Roots → 勾选 exclude weak/soft references(弱/软引用通常可忽略)→ 逐层展开,直到看到你代码里的类名和字段名。真正泄漏点,往往藏在第三、第四层引用上。
Arthas 的 trace 和 watch 能定位泄漏源头吗?
能,而且非常快。Arthas 不分析内存快照,而是动态追踪对象创建与引用路径。例如:
- 用
watch com.example.CacheService addToCache returnObj监控缓存添加行为,确认是否无条件追加 - 用
trace com.example.DataProcessor process -n 5查看方法调用链和耗时,发现某次调用中意外 new 了大量对象 - 结合
ognl '@java.lang.Runtime@getRuntime().totalMemory()'实时读内存总量,交叉验证
优势在于:无需重启、无需 dump、可在线上灰度环境直接验证假设。但要注意,watch 和 trace 有性能开销,高频方法慎用,建议先用 jstat 锁定可疑时间段再精准介入。
真正的难点从来不是工具不会用,而是泄漏常常发生在你“觉得没问题”的地方:一个被复用的线程池里没清理 ThreadLocal,一个泛型擦除后丢失类型检查的 static Map,甚至是一个日志框架里被误设为 DEBUG 级别后疯狂打印对象 toString() 导致临时对象暴增。越隐蔽的地方,越要靠 Path to GC Roots 一层层剥,而不是靠经验猜。









