jmap+jhat可快速定位java堆内存泄漏,重点分析两次堆快照中实例暴增类及其gc roots强引用链,结合mat的dominator_tree和path to gc roots精准定位threadlocal、监听器注册未解绑、静态map缓存等隐性泄漏源。

用 jmap + jhat 快速定位存活对象膨胀
Java 进程堆持续增长、GC 后老年代不回收,大概率是对象被意外强引用住。别急着看代码,先抓快照比对更可靠。
实操建议:
- 用
jmap -dump:format=b,file=heap.hprof <pid></pid>抓两次间隔几分钟的堆快照(比如 GC 前后),避免单次快照误判 -
jhat heap.hprof启服务后访问http://localhost:7000,重点看Classes页里实例数暴增的类,再点进去看References to this object - 注意:JDK 9+
jhat已废弃,改用jvisualvm或jcmd <pid> VM.native_memory summary</pid>辅助判断是否是 native 内存泄漏
ThreadLocal 不清理导致的隐性内存泄漏
Web 容器(如 Tomcat)复用线程时,ThreadLocal 若没调用 remove(),其 value 会随线程长期持有,尤其 value 是大对象或含 ClassLoader 时,直接卡死 PermGen / Metaspace。
常见错误现象:
立即学习“Java免费学习笔记(深入)”;
- 应用重启后内存不释放,
java.lang.OutOfMemoryError: Metaspace频发 - 堆直方图里出现大量
org.apache.catalina.loader.WebappClassLoader实例
修复要点:
- 所有
ThreadLocal<t></t>声明必须配static final,避免被外部类意外持引用 - 在 Filter / Interceptor 的
finally块中显式调用threadLocal.remove(),不要依赖set(null) - 若用 Spring,优先用
@Scope("prototype")Bean 替代ThreadLocal,由容器管理生命周期
监听器/回调注册后忘记反注册
GUI、Android、Netty、Spring Event 等场景下,注册监听器却没在销毁时解绑,是最隐蔽的泄漏源——对象图里往往只差一层引用就断了,但就是不断。
典型使用场景:
- Swing 中给
JFrame添加WindowListener后未调用removeWindowListener() - Spring
@EventListener方法所在 Bean 是 prototype,但事件发布器仍强引用该实例 - Netty
ChannelHandlerContext持有 handler 引用,handler 又闭包捕获了外部大对象
排查技巧:
- 用 MAT 打开 hprof,执行
dominator_tree,按 "Retained Heap" 排序,找非预期的大对象,右键 → "Path to GC Roots" → 勾选 "exclude weak/soft references" - 重点关注路径中是否出现
java.util.ArrayList、java.util.HashMap、sun.awt.AppContext等容器类 —— 它们常是注册表
静态集合缓存未设上限或未清理
static Map<string object></string> 看似方便,但只要 key 不可控(比如 URL、用户 ID、UUID),就等于开了个内存黑洞。
参数差异与影响:
- 用
HashMap:无过期、无容量限制,key 增多直接 OOM - 用
WeakHashMap:key 是弱引用,适合缓存“被其他地方强引用”的对象,但 value 仍强引用,value 大时照样泄漏 - 用
ConcurrentHashMap+ 定时清理线程:易写错清理逻辑,且并发下可能漏删
更稳妥的做法:
- 改用
Caffeine:Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(10, TimeUnit.MINUTES) - 若必须手写,key 类型限定为枚举或固定字符串,杜绝运行时动态拼接
- 上线前加 JMX 暴露集合 size,监控突增趋势
复杂点在于:很多泄漏不是单点问题,而是多个弱引用链叠加,比如 ThreadLocal 持有监听器,监听器又持有静态 Map 的 value —— 这时候得一层层断开引用,不能只修一个。










