Java

Java 内存泄漏怎么定位?从 Heap Dump 到 MAT 实战

从堆转储、对象占用分析到引用链排查,系统梳理 Java 内存泄漏的定位思路、常见场景和使用 MAT 分析堆快照的方法。

  • Java
  • 内存泄漏
  • JVM
  • MAT
15 分钟阅读

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 DumpHeap 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[]
  • String
  • HashMap$Node
  • ArrayList
  • 某些业务 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 Map
  • static 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、清单一项项全走完才敢下判断。更多时候是先抓住一个异常对象簇,再确认它是不是被某个长生命周期容器一直吊着,最后回代码把那条持有关系坐实。

所以这篇真正想留下的主线,其实只有一条:

先看大头对象,再看谁在支配它,最后顺着引用链回到代码里的持有关系。

Stringbyte[]HashMap$Node 这些名字,很多时候都只是表象。真正值钱的是再往上一层看:它们是不是挂在一个没边界的缓存里,是不是被某个单例 Bean 长期拿着,是不是 ThreadLocal 在线程池里一直没清掉。

只要把这条线走通,所谓“Java 内存泄漏”通常就不会继续停留在 JVM 很玄的层面,而会落成一个很具体的代码问题:谁多拿了一手,为什么一直不放。

如果 Heap Dump 没把问题解释完整

如果你顺着 Heap Dump 看下来,发现问题已经偏到下面这些场景,就别继续在 MAT 里硬耗了:

十四、排到这里后,下一步通常只剩几种分叉

如果你已经确认这不是短时流量抖动,而是对象一直回不下来,后面通常就按手里最缺的那段证据继续补: