Java 内存泄漏怎么定位?从 Heap Dump 到 MAT 实战
从堆转储、对象占用分析到引用链排查,系统梳理 Java 内存泄漏的定位思路、常见场景和使用 MAT 分析堆快照的方法。
Java 出现内存问题时,很多人第一反应是:是不是堆不够了,是不是 GC 参数不合适,是不是流量上来了。这些方向当然都有可能,但如果服务表现出一种更典型的状态——内存越来越高、Full GC 越来越频繁、回收之后还是降不下来——那就要开始认真怀疑另一个方向:是不是出现了内存泄漏。
内存泄漏这个词很吓人,但它并不意味着 JVM 的垃圾回收器失效了。绝大多数情况下,所谓“泄漏”的真实含义是:
- 某些对象本来应该失去引用
- 却还被别的对象继续持有
- 导致 GC 认为它们仍然是活的
- 最终这些对象越积越多,把堆内存一点点吃掉
所以内存泄漏排查真正要解决的问题,不是“GC 为什么不回收”,而是:
到底是谁还在持有这些本该被释放的对象?
后面我会沿着一次常见排查顺序往下拆:先判断这像不像泄漏,再说 Heap Dump 怎么拿、MAT 里先看什么,最后怎么顺着引用链追到代码。
如果你已经在看 Heap Dump
如果你现在已经准备开 Heap Dump 或 MAT,我更建议先补齐这几样上下文:抓 Dump 时的内存趋势、Full GC 后的基线、实例是否还在持续接流量、以及你最怀疑的对象类别或业务窗口。原因在于 MAT 很容易把人带进对象海洋里;如果没有前面的时间线和怀疑范围,你很快就会看到很多大对象,却还是判断不出它们是正常业务量,还是异常持有链。
下面这张表先帮你判断,是不是该从 Heap Dump 进入 MAT 深挖了。
| 你现在看到的现象 | 更像什么 | 下一步 |
|---|---|---|
| 内存基线持续抬高,Full GC 回不下来,已经准备抓或分析 Heap Dump | Heap Dump / MAT 深挖 | 直接进本文看 Heap Dump 和 MAT |
| 只是看到内存上涨,但还没到“必须看 dump”这一步 | 更像前兆判断 | 先看 Java 内存持续上涨但没有 OOM,先怎么排查? |
| 已经 OOM,先要分 heap / metaspace / direct memory 类型 | 更像 OOM 事故现场分型 | 先看 Java 服务 OOM 了怎么排查?先分类型,再决定往哪条线查 |
| Heap Dump 已经抓到了,但第一轮想先排掉假嫌疑对象 | 更像第一轮排除法 | 接 Heap Dump 抓到了,第一轮分析最该先排除哪几类对象? |
| 你更怀疑的是 Metaspace、类加载或 ClassLoader 泄漏 | 更像非堆分支 | 接 Metaspace 一直涨但堆没 OOM,怎么判断是不是 ClassLoader 泄漏? |
先看看是不是该进到 Heap Dump 这一步
这篇主要解决的是:你已经确定要抓或分析 Heap Dump,准备进 MAT 了,接下来怎么把对象分布、支配关系和引用链真正用起来。还没走到这一步时,先在内存上涨前兆或 OOM 分型那几篇里把现场分清;一旦 Dump 已经到手,本文才更能帮你少走弯路。
如果你现在只想知道“Heap Dump 到手第一眼先排什么”,更适合直接去第一轮分析那篇,不用一上来就把 MAT 所有视图一起吃掉。本文更像把堆快照真正啃下来的实操记录,不负责替你做最前面的分诊。
一、别急着下结论:内存高,不等于一定是内存泄漏
这是排查时最容易踩的第一个坑。
因为很多服务在高峰期出现内存高,占用看起来也很吓人,但根因未必是泄漏。例如:
- 某次批处理一次性装了大量数据
- 缓存预热时短时间对象很多
- 堆配置偏小,高峰时正常顶到边缘
- 对象创建速度过快,GC 来不及回收
这些问题也会导致:
- 堆内存上升
- Young GC 频繁
- Full GC 出现
- RT 抖动
但它们不一定是“泄漏”。
更值得警惕的泄漏信号通常是
- 服务运行越久,内存基线越高
- Full GC 后内存降不太下来
- 流量回落后,内存也不明显回落
- 问题会随着运行时间慢慢恶化,而不是只在短时高峰爆发
为什么这个判断很重要
因为如果只是参数不够、对象创建太猛或者批任务处理方式不合理,你直接按泄漏思路去做堆分析,可能会绕很久。反过来,如果明明已经出现了长期持有对象的问题,你却只在调 GC 参数,也只是延后爆炸时间。
所以第一步要先判断:
- 是短时压力问题
- 还是长期持有对象越来越多的问题
二、什么时候应该开始抓 Heap Dump
一旦你怀疑是内存泄漏,真正有价值的证据通常不是普通日志,而是堆快照。
一般可以在这些时机抓 Heap Dump
1. Full GC 后内存依然很高
这是最典型、也最值得抓的时机。
因为这时已经做过一次较重回收,如果某些对象还大量留在堆里,它们更值得被怀疑。
2. 服务内存持续增长,但还没 OOM
这个阶段是最适合排查的。因为:
- 现场还活着
- 数据还够完整
- 比等到彻底 OOM 后再补救更从容
3. 出现 OOM 前后的时间窗口
如果你来得及,也可以在 OOM 前后抓取;但很多线上场景里,真正更稳妥的策略还是在“明显异常但服务尚可操作”时就提前留证据。
三、怎么拿 Heap Dump:先保证现场可分析,再考虑影响
最常见的方式之一是:
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
例如:
jmap -dump:live,format=b,file=/tmp/heap.hprof 12345
这条命令在做什么
live:尽量只 dump 存活对象format=b:二进制格式file=/tmp/heap.hprof:输出文件路径
需要注意什么
Heap Dump 不是零成本动作:
- 文件可能很大
- 抓取时会有额外开销
- 线上机器磁盘空间要提前看
所以操作前最好先判断:
- 当前机器是否有足够磁盘空间
- 当前服务压力是否允许执行
- 是否有更适合的时机或从节点可供分析
这一步本质上是在平衡:
- 你要不要保留足够证据
- 和当下线上服务承压之间的关系
四、抓到堆快照之后,别急着乱翻,先看“大头对象”
很多人第一次打开 MAT,会被各种对象、图表和树结构吓到。其实第一步不用想太复杂,最有用的切入点通常只有一个:
先看谁占内存最多。
在 MAT 里最常先看的几个视图是
- Histogram
- Dominator Tree
- Leak Suspects Report
其中最推荐先看的通常是:
1. Histogram
可以快速看:
- 哪类对象数量最多
- 哪类对象总占用最大
这一步适合先建立直觉。
2. Dominator Tree
这是排查内存泄漏时更关键的视角。
它帮助你看到:
- 哪些对象在“支配”大量内存
- 如果它们不在了,后面那一串对象也能一起释放
比起单纯看“对象多不多”,这个视角更接近你真正想回答的问题:
谁在把这些对象留在堆里?
五、Histogram 要怎么看,才不至于看花眼
打开 Histogram 后,经常会看到这些高频对象:
byte[]char[]StringHashMap$NodeArrayList- 某些业务 DTO / VO / Entity
这时候不要犯一个常见错误
看到 byte[]、String 很大,就直接得出结论说“是字符串泄漏了”。
很多时候这些只是表现,不是根因。真正要继续问的是:
- 这些
byte[]被谁持有 - 这些
String是挂在哪个缓存、集合、上下文下面 - 数量大是因为正常业务,还是因为没有释放
所以 Histogram 更像“找到线索”,不是“直接宣布元凶”。
六、Dominator Tree 才是定位泄漏时最值钱的地方
如果你真的在找“为什么这些对象还活着”,Dominator Tree 往往比 Histogram 更关键。
怎么理解 Dominator Tree
它不是简单告诉你“谁占了多少”,而是在告诉你:
- 某个对象或某条引用链
- 正在控制着下面一大片对象的可达性
换句话说,如果你把这个“支配者”拿掉,后面很多对象理论上就都可以被回收。
这非常符合泄漏定位的核心思路:
找那个“不该继续持有、却还在持有”的源头。
排查时特别值得重点看这些结构
- 大型
HashMap - 大型
ArrayList - 本地缓存对象
- 静态字段引用链
- 线程相关对象
ThreadLocalMap- 长生命周期单例 Bean
这些都是项目里非常高频的持有源。
七、看到大对象或大集合后,下一步不是猜,而是看引用链
假设你在 MAT 里发现一个很大的 HashMap,或者某个业务对象占了很多内存。接下来最关键的问题是:
它为什么还活着?
这时就要继续看:
- Path to GC Roots
- Incoming References
这两步在干什么
它们是在帮你找:
- 当前对象是被谁引用的
- 这条引用链为什么还能一路连到 GC Root
一旦这条路径走通,你就能看到真正根因更偏向哪一类。
例如:
- 某个静态集合一直在持有数据
- 某个本地缓存没有大小上限
- 某个单例 Bean 把请求级对象留下了
- 某个 ThreadLocal 没清理
排查到这里,泄漏问题就开始从“JVM 很玄学”变成“代码里某条持有关系不合理”。
八、项目里最常见的几类内存泄漏来源
说到这一步,其实很多问题已经不只是工具层,而是会落回几个特别高频的代码模式。
1. 静态集合持续增长
例如:
static Mapstatic List- 全局注册表
这类对象生命周期几乎和应用一样长,只要你不断往里塞数据,又没有清理机制,泄漏几乎就是迟早的事。
2. 本地缓存没有边界
很多缓存问题不一定一开始就被叫做“泄漏”,但如果:
- 没有最大容量
- 没有淘汰策略
- 持续按用户或维度累积数据
结果在堆里看起来和泄漏非常像。
3. ThreadLocal 用完不清理
尤其在线程池环境里,如果:
- 请求结束后没有
remove() - 线程被复用
- 上下文对象又比较大
那这些对象就可能被线程长期带着走。
4. 监听器、回调、订阅关系没有释放
对象虽然业务上已经没用了,但因为还挂在某个监听列表里,GC 仍然会认为它是活的。
5. 单例 Bean 意外持有大量请求级对象
这类问题在 Spring 项目里也并不少见。
九、一个更接近实战的例子:怎么从 MAT 里一路找到根因
假设你在 MAT 的 Histogram 里发现:
HashMap$Node占用很大- 某类业务对象数量异常多
第一步:看 Dominator Tree
发现这些对象大部分都挂在一个本地缓存对象下面。
第二步:继续看引用链
发现这个缓存对象被某个单例 Service 长期持有。
第三步:回到代码看实现
代码里用了一个:
- 没有大小限制的
ConcurrentHashMap - key 还是按用户维度不断增长
- 也没有定期清理
第四步:得出判断
这时问题就很清楚了:
- 不是 JVM 回收器坏了
- 不是 GC 参数太差
- 而是某个应用级缓存设计没有边界
第五步:后续优化方向
- 给缓存加容量限制
- 加过期淘汰
- 评估是否改成本地 + Redis 或外部缓存
- 控制 value 对象大小
这类链路就是典型的泄漏定位方式:
- 先看大头对象
- 再看支配关系
- 最后顺着引用链追到代码
十、怎么区分“缓存膨胀”和“真正的泄漏”
这两个问题很像,但工程语义不完全一样。
更像缓存膨胀时
通常你能清楚解释:
- 为什么这些对象被持有
- 它们是设计上的缓存结果
- 只是容量控制做得差
更像泄漏时
通常会出现:
- 本来不该再被引用的对象还活着
- 业务语义上已经无用
- 但生命周期被错误拉长了
不过在实际排查里,这两者的分析方法往往差不多:
- 都要看谁在持有
- 都要看为什么没释放
- 都要回到代码设计上改边界
所以不用在术语上太纠结,先把根因抓住更重要。
十一、到这里其实已经够了:别把一次堆分析又做成一套课程
写到这里,定位动作基本已经完整了。
真到现场里,你通常也不会把 Histogram、Dominator Tree、GC Roots、误区、FAQ、清单一项项全走完才敢下判断。更多时候是先抓住一个异常对象簇,再确认它是不是被某个长生命周期容器一直吊着,最后回代码把那条持有关系坐实。
所以这篇真正想留下的主线,其实只有一条:
先看大头对象,再看谁在支配它,最后顺着引用链回到代码里的持有关系。
像 String、byte[]、HashMap$Node 这些名字,很多时候都只是表象。真正值钱的是再往上一层看:它们是不是挂在一个没边界的缓存里,是不是被某个单例 Bean 长期拿着,是不是 ThreadLocal 在线程池里一直没清掉。
只要把这条线走通,所谓“Java 内存泄漏”通常就不会继续停留在 JVM 很玄的层面,而会落成一个很具体的代码问题:谁多拿了一手,为什么一直不放。
如果 Heap Dump 没把问题解释完整
如果你顺着 Heap Dump 看下来,发现问题已经偏到下面这些场景,就别继续在 MAT 里硬耗了:
- 你还停留在“内存上涨但没 OOM”阶段:
Java 内存持续上涨但没有 OOM,先怎么排查? - 你已经进入 OOM 事故现场,需要先分类型:
Java 服务 OOM 了怎么排查?先分类型,再决定往哪条线查 - Heap Dump 已经到手,但第一轮想先排错方向:
Heap Dump 抓到了,第一轮分析最该先排除哪几类对象? - 你怀疑根因其实在 ThreadLocal 或线程复用链路:
ThreadLocal 泄漏为什么常在连接池线程里放大? - 你发现真正异常更像 Metaspace / ClassLoader:
Metaspace 一直涨但堆没 OOM,怎么判断是不是 ClassLoader 泄漏?
十四、排到这里后,下一步通常只剩几种分叉
如果你已经确认这不是短时流量抖动,而是对象一直回不下来,后面通常就按手里最缺的那段证据继续补:
- 如果你是因为 Full GC 越来越频繁才查到这里,就接着看《Full GC 频繁怎么办:先判断是不是内存泄漏》,先把 GC 回收效果和长期占用基线对上。
- 如果现场已经逼近 OOM,就先接《Java 服务 OOM 了怎么排查?一条常用思路讲清楚》,把保留现场和堆转储的节奏稳住。
- 如果你还拿不准
jmap、jstat、jstack怎么配合,再回到《线上问题排查时,jstack、jmap、jstat 分别怎么看》补工具动作。 - 如果内存问题已经把 CPU、接口时延或线程池一起带歪,再补《Java CPU 飙高怎么排查:一套线上定位顺序》《接口响应慢怎么排查?后端性能问题定位步骤》和《线程池打满以后,应该先查队列、拒绝策略还是慢任务?》。