ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java堆内存泄漏排查:hprof、MAT索引与Retained Size实战

Java堆内存泄漏排查:hprof、MAT索引与Retained Size实战

简介:面向 Java 开发与性能调优人员的 MAT(Memory Analyzer Tool)离线工具包,围绕 hprof 堆转储文件的解析与内存问题排查展开,可用于定位内存泄漏、内存占用过高及对象引用关系异常。压缩包共 2000 个文件,约 75.44MB,以 html 帮助文档、png 与 gif 界面截图、jar 插件及依赖为主,辅以 xml 配置、dita 说明、css 样式和 properties、class 等运行文件,并附带示例 hprof 数据,便于离线查阅与直接试用。已有 6621 人学习下载。包内文档覆盖支配树、直方图、泄漏嫌疑人报告、浅堆与保留堆、OQL 查询、线程堆栈等分析视图,配合截图和示例数据可快速理解各功能入口与判读思路;jar 与启动脚本也方便在本地完成安装配置,适合需要反复练习堆内存分析、对照排查真实 OOM 问题的开发者使用。

1. 一个 3GB 的 hprof 拖进 MAT 就崩,问题从来不在堆大小

线上某服务的堆内存曲线连着三天只涨不跌,运维同事丢过来一个 3.2GB 的 hprof 文件,说“你分析下”。我双击 MemoryAnalyzer 的启动图标,转了两分钟进度条,屏幕上弹出An internal error occurred during: "Parsing heap dump",错误信息里夹着一句Java heap space——分析工具把自己给 OOM 掉了。这事听着荒诞,但凡是拿 mat 工具啃过大堆的人,大概率都踩过。MAT 不是“打开文件看表格”的记事本,它是一个把 hprof 二进制流重建成完整对象图、再在自身 JVM 堆里跑支配树算法的重型分析器,它自己也吃内存。这篇从 dump 怎么触发、索引怎么生成、四个视图怎么读数,一路写到泄漏链路怎么定位和几个能让结论直接翻车的参数坑。

2. hprof 从哪来:dump 触发方式、文件结构与 MAT 索引链路

分析之前先得有一份“干净”的 hprof。这一步很多人随手一条命令就过去了,后面所有结论的可信度其实都押在这里:用了live参数的和没用的,dump 出来的是两个世界;OOM 现场自动落盘的和事后手动补一刀的,看到的对象状态也完全不同。先把来源这件事讲清楚,再谈 MAT 怎么解析。

2.1 三种 dump 方式的参数差异与代价

常见做法是三种。第一种是jmap,JDK 8 时代最顺手:

# 不带 live:dump 整个堆,包括已经不可达但还没被回收的对象 jmap -dump:format=b,file=/data/dump/heap_$(date +%s).hprof 12345 # 带 live:先强制触发一次 Full GC,只 dump 存活对象 jmap -dump:live,format=b,file=/data/dump/heap_live.hprof 12345

format=b表示二进制格式,不加就是文本格式,MAT 读不了。文件名里带时间戳是个小习惯,同一个进程多次 dump 时不会互相覆盖,后面做两份对比也用得上。关键在于live这个开关:加了它会先做一次 Full GC,好处是 dump 文件小、里面只剩存活对象,结论更接近“真实泄漏”;坏处是这次 Full GC 在生产上是实打实的长停顿,几 GB 堆停个一两秒很常见,业务侧能感知到。

第二种是jcmd,JDK 7 以后都推荐用这个:

# 推荐方式,JDK 7 及以上可用,参数比 jmap 更规范 jcmd 12345 GC.heap_dump /data/dump/heap.hprof # 加上 -all 会把不可达对象也写进去,等价于 jmap 不带 live jcmd 12345 GC.heap_dump -all /data/dump/heap_full.hprof

jcmd的优势是它走的是 JVM 内部的 attach 通道,行为和 JVM 版本绑定得更紧,不像jmap在部分 JDK 版本上被重写过实现、偶尔出现 dump 失败。JDK 9 之后jmap -dump在某些发行版上会提示要用-F强制,实际上不如直接用jcmd。

第三种是 OOM 现场自动落盘,这也是我在生产环境必配的:

# 启动参数里加上,OOM 发生的瞬间自动 dump -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/ # 可选:dump 前先做一次 Full GC,减少文件体积 -XX:+HeapDumpBeforeFullGC

HeapDumpPath指向的目录要提前确认磁盘余量,堆多大文件就差不多多大,3GB 堆打出来的文件也在 3GB 上下。线上曾经出现过 dump 把数据盘写满、连带日志写不进去的事故,血泪经验是给这个目录单独挂一块盘或者加配额。

方式是否 STW是否含不可达对象适用场景
jmap -dump是是事后补采,想看到完整现场
jmap -dump:live是,且先 Full GC否只关心存活对象,能接受一次停顿
jcmd GC.heap_dump是是JDK 7+ 首选
OOM 自动 dump发生在 OOM 时是线上必配,抓第一现场

表格里“是否含不可达对象”这一列最容易被忽略。带了不可达对象的 dump 文件会明显偏大,Histogram 里会出现一堆本该被回收的临时对象,如果拿它去判断泄漏,很容易把垃圾当成嫌疑人。

2.2 hprof 不是文本,MAT 第一次打开为什么要建索引

hprof 是一种带记录头的二进制流。文件开头是一段 header,记录版本号、标识符长度(4 或 8 字节)、时间戳;后面跟着一串 record,每条 record 有类型标记和长度。和内存分析相关的主要是这几类:

  • HEAP_DUMP/HEAP_DUMP_SEGMENT:真正的对象图数据,包含类加载、实例字段值、数组元素、GC Root 等子记录
  • LOAD_CLASS:类加载记录,把 class serial number 映射到类名
  • STACK_TRACE/STACK_FRAME:线程栈快照
  • HEAP_DUMP_END:结束标记

MAT 打开文件时做的事,是把这些顺序排列的 record 重建成一张可随机访问的对象图。它不能边读边算,因为支配树、Retained Size 这些指标都需要全局的引用关系。所以第一次打开会在 hprof 同目录下生成一个heap.hprof.index目录,里面是若干二进制索引文件:

索引文件作用
o2hobject id 到内部句柄(handle)的映射
a2s对象地址到对象大小的映射
domIn/domOut支配树的入边与出边,Retained Size 靠它算
idx分段的元信息,记录每个 segment 的偏移
inbound反向引用索引,Path to GC Roots 靠它

索引生成的时间和堆大小近似线性,一份 3GB 的 dump 在普通 SSD 上大概要两三分钟,机械盘能拖到十分钟以上。索引目录本身也不小,经验值是 hprof 文件的三分之一到一半。所以 2.2 节要强调的第一件事是:hprof 所在目录必须有写权限,并且留足空间。把它挂在只读的 NFS 上,MAT 会卡在 “Building index” 然后失败,报错信息还不一定指向权限问题。

索引生成之后,后续打开走的是索引,速度会快很多。这也解释了为什么有人换了台机器、把 hprof 拷过去但没拷.index目录,又要重新等一遍。

提示:索引文件和 hprof 是绑定关系。hprof 被截断、被重新 dump 覆盖但索引没删,MAT 会读到错乱的对象图,症状是类名乱码或者IllegalArgumentException。遇到这种情况,删掉.index目录重来。

2.3 用命令行先把索引和报告跑出来

GUI 打开大文件时卡死,很多时候不是内存不够,而是界面线程在解析过程中被阻塞,看起来像挂了。我的做法是先绕开界面,用 MAT 自带的 headless 脚本把索引和标准报告一次性跑出来:

# 进入 MAT 安装目录,ParseHeapDump 在根目录下 cd /opt/mat # 第一个参数是 hprof 路径,后面跟要生成的分析报告 # api:suspects = 泄漏嫌疑报告,api:overview = 概览,api:top_components = 大对象排行 ./ParseHeapDump.sh /data/dump/heap.hprof \ org.eclipse.mat.api:suspects \ org.eclipse.mat.api:overview \ org.eclipse.mat.api:top_components

执行完会在 hprof 同目录下产出几个 zip 包,命名类似heap_Leak_Suspects.zip、heap_System_Overview.zip,解压后是 HTML,浏览器直接打开就能看。参数说明:org.eclipse.mat.api:suspects是报告生成器的 ID,ID 写错不会报错,只是那一步被静默跳过,所以跑完要确认 zip 文件确实生成了。

这一步的隐含前提是 MAT 自己的堆够大。headless 脚本读取的是同目录下MemoryAnalyzer.ini里的-Xmx配置,默认值往往只有 1024m 或 2048m,对付几 GB 的 dump 远远不够。修改方法:

# 编辑 /opt/mat/MemoryAnalyzer.ini,找到 -vmargs 段 -vmargs -Xmx8g # 如果是 JDK 8 环境还需显式指定元空间,JDK 17 之后不需要 -XX:MaxMetaspaceSize=512m

-Xmx给多少合适,我一般按 dump 文件大小的 1.5 倍来配,3GB 的堆文件给 6~8GB。给多了浪费,给少了就会出现第 1 章那种 “工具自己 OOM”。注意-Xmx必须写在-vmargs之后,写在前面会被 Eclipse 启动器忽略,这是个很隐蔽的坑,配完可以用jcmd <mat_pid> VM.flags确认一下实际生效值。

3. 四个视图怎么读:Histogram、Dominator Tree、Retained Size 与 Leak Suspects

MAT 打开一份 dump 后,左侧面板一排功能入口,新手最容易犯的错是把 Leak Suspects 当成判决书,看到“嫌疑”两个字就开始改代码。这四个视图各自回答的是不同问题,混用会把方向带偏。先把每个视图的输入输出和计算口径讲清楚,再看怎么串起来用。

3.1 Histogram 看“谁多”,Dominator Tree 看“谁压着谁”

Histogram 是最简单的聚合视图:按类分组,列出每个类的实例数和 Shallow Heap 总量。它回答的是“哪类对象占的坑位最多”。

Class Name | Objects | Shallow Heap java.lang.String | 812,344 | 19,496,256 char[] | 795,102 | 318,040,800 java.util.HashMap$Node | 402,118 | 12,867,776 com.example.cache.LocalEntry | 388,901 | 15,556,040 java.util.concurrent.ConcurrentHashMap$Node | 201,006 | 6,432,192

char[]排在前面极其正常,String 的底层存储就是它,Java 8 之后改成 byte[] 也类似。真正要看的是第三行往后那些“业务类”,以及它们和容器类的比例关系。上面这张表里,LocalEntry有接近 39 万个实例,而 HashMap$Node 是 40 万,比例接近 1:1,说明这些 Entry 大概率就是被 HashMap 持有的缓存条目。

Histogram 的盲区在于它只统计 Shallow Heap,也就是对象自身占的字节数,不含它引用的子对象。一个ArrayList自身可能只有 24 字节,但它引用着一个几百 MB 的数组,在 Histogram 里依然排在几百名开外。

Dominator Tree 补的正是这个缺口。它按支配关系组织:如果从 GC Root 出发到达对象 B 的每一条路径都必须经过 A,那么 A 支配 B。树上的一个节点显示的是它“独占”的那部分内存,把父节点砍掉,子节点就全没了。所以 Dominator Tree 里排行靠前的,往往是真正压着大块内存的那几个对象。

实际用的时候,我的顺序是:先看 Leak Suspects 拿方向,再进 Histogram 找类的实例数排序,怀疑某个类之后右键Merge Shortest Paths to GC Roots或者直接看 Dominator Tree 确认它到底占了多少实际内存。反过来先用 Dominator Tree 也成立,但它对新手不友好,因为根节点往下第一层经常是一堆java.lang.Thread或者sun.misc.Launcher$AppClassLoader,看不出业务含义。

3.2 Retained Size 是支配树算出来的,不是加法

Retained Size 是 MAT 里被误读最多的一个指标。它的定义是:把这个对象(连同它支配的所有对象)全部回收之后,能释放的堆大小。注意“支配”两个字,它不是简单的“这个对象加上它引用的所有对象”。

举个具体例子:一个HashMap持有 100 个 Entry,每个 Entry 的 value 是个 10MB 的 byte[]。如果这 100 个 byte[] 只被这个 HashMap 引用,那么 HashMap 的 Retained Size 大约是 1000MB 加上一些零头。但如果其中 30 个 byte[] 同时被另一个全局缓存引用着,那这 30 个就不受这个 HashMap 支配,Retained Size 只有 700MB 左右。MAT 算的是后者。

这个口径直接影响判断。见过有人看到某个对象 Retained Size 只有几 MB 就放过了,实际上它持有着几百个被多处共享的大对象,只是那些对象不归它独占,单个看都不大,合起来才是问题。这种情况要看 Dominator Tree 的子树结构,或者用with outgoing references手动展开。

指标计算口径适合回答
Shallow Heap对象自身字节数,不含引用哪个类实例最多
Retained Heap该对象支配的所有对象的 Shallow 之和干掉它能释放多少
Retained Set该对象支配的对象集合本身具体是哪些对象被压着

还有一点:Retained Size 默认是懒计算的。刚打开 dump 时,Histogram 里那一列可能显示为空或者显示一个问号,需要点一下表头的 “Calculate Retained Size” 或者右键菜单里触发一次全量计算,几 GB 的堆上这一步要等十几秒到几分钟。很多人没等它算完就开始读数字,读到的是 0,结论自然错。

3.3 Leak Suspects 的判定逻辑与局限

Leak Suspects 是 MAT 自动生成的一份报告,核心逻辑是:找出那些 Retained Size 超过堆总量一定比例(默认 1% 左右,可以在 Preferences 里调)的对象集合,然后对每个可疑集合做一次“如果这是泄漏,它是从哪进来的”路径分析。

它的输出长这样:Problem Suspect 1 描述“某某类的 388,901 个实例,占用 450MB,占总堆 42%”,下面跟一条从 GC Root 到该对象的引用链。这条链的价值在于它给出了一个可能的入口,比如ThreadPoolExecutor$Worker -> Thread -> threadLocals -> ThreadLocalMap -> Entry -> value。

局限也很明显。第一,它只对“大块且集中”的泄漏敏感,那些分散在几十个类里、每个只占 0.5% 的缓慢增长它抓不到。第二,它的路径分析会包含所有引用类型,包括弱引用、软引用,所以经常给出一些看着吓人但实际上是正常设计的链路。第三,它对“不是泄漏但就是大”的缓存类结构会误报,比如一个设计上就允许占 30% 堆的本地缓存,每次都被列成头号嫌疑。

我的用法是把它当索引而不是结论:从 Suspect 列表里挑出和业务相关的那个,右键Path to GC Roots重新按强引用算一遍,确认链路成立再往下查。3.4 节会讲 OQL 和线程视图怎么补它的短板。

3.4 OQL 查询与线程视图

OQL 是 MAT 内置的对象查询语言,语法接近 SQL,可以直接对对象图做条件过滤。它的价值在于把“肉眼在几万行表格里找”变成“一条查询”。

-- 查出所有长度超过 10MB 的 byte 数组,按大小降序 SELECT a FROM byte[] a WHERE a.@length > 10485760 -- 查 HashMap 里 table 数组特别大的实例,用于定位超大容器 SELECT h FROM java.util.HashMap h WHERE h.table.@length > 100000 -- 查某个业务类的实例,并只看持有超过 1000 条记录的 SELECT c FROM com.example.cache.LocalCache c WHERE c.entries.size() > 1000

语法说明:@length是 MAT 给数组类型的内置属性,@前缀表示这是对象元信息而不是字段;h.table直接访问 HashMap 的table字段,MAT 走的是反射读取字段值,字段名要对得上编译后的实际名字;c.entries.size()调用的是方法,MAT 支持有限的方法调用,只支持无副作用的简单 getter 和集合 size 这类。

OQL 的执行是遍历对象图,几百万对象的堆上跑一条不带索引条件的查询可能要几十秒,条件写得越具体越快。查询结果可以直接右键Show Retained Set或者导出成 CSV。

线程视图在Thread Overview里,列出 dump 时刻所有线程及其栈帧,点进某个线程能看到它栈上的局部变量以及这些变量对应的对象。定位“某个线程里挂着一个巨大集合”这类问题,这个视图比 Histogram 直接得多,因为泄漏源就在栈上,一眼能看到是哪个方法没释放。

4. 顺着一条引用链找到根:Path to GC Roots 的排除项与实操

前面都是找嫌疑人,这一章讲怎么定案。Path to GC Roots 是 MAT 里最核心的定位手段,但它的默认行为会给出大量噪音路径,不设排除项的话,一条 ThreadLocal 相关的链能展开出七八条分支,看半天不知道哪条是真的。

4.1 Path to GC Roots 的四种排除组合

右键任意对象,Merge Shortest Paths to GC Roots下面有四个子菜单:

选项排除了什么什么时候用
with all references不排除想看完整引用图,噪音最多
exclude weak references弱引用基本必选,排除 WeakHashMap、ThreadLocal key
exclude weak/soft references弱引用 + 软引用排查真实强引用链的标准姿势
exclude all phantom/weak/soft etc.所有非强引用最干净,但可能丢掉线索

标准操作是选exclude weak/soft references。原因很实际:ThreadLocalMap.Entry的 key 是弱引用,value 是强引用。如果只选with all references,MAT 会给出Thread -> threadLocals -> ThreadLocalMap -> Entry -> key(弱引用)这条路径,看起来线程持有着 Entry,实际上 key 早该被回收了,真正的问题在 value 那一侧。排除弱引用之后,剩下的链路才是“真实存在、无法被 GC 回收”的引用。

exclude all phantom/weak/soft etc.用得少一些,但在排查 ClassLoader 泄漏时有用。应用热部署场景下,老的 ClassLoader 被Finalizer或者PhantomReference队列间接持有,这类引用虽然最终会被清理,但在 dump 的那一刻确实还挂着,需要单独判断。

注意:排除项只影响路径展示,不影响对象图中的实际引用关系。排除之后如果一条路径都找不到,说明这个对象其实只被弱引用持有,它不构成泄漏,这个结论本身就有价值。

4.2 ThreadLocal 与线程池的引用链怎么读

线程池 + ThreadLocal 是 Java 服务里最高频的泄漏组合。典型链路在 MAT 里长这样:

Thread (线程池里的 Worker 线程) -> java.lang.ThreadLocal$ThreadLocalMap threadLocals -> java.lang.ThreadLocal$ThreadLocalMap$Entry[] table -> java.lang.ThreadLocal$ThreadLocalMap$Entry value -> com.example.context.UserContext (持有大对象)

读这条链的关键是看最后那个value指向什么,以及Entry的 key 是什么。如果 key 是一个已经被回收的 ThreadLocal(显示为null或者 MAT 会标注referent == null),说明这个 Entry 是个“僵尸条目”:key 被弱引用回收了,value 还牢牢挂在线程的 threadLocals 上,直到线程被复用并且调用remove()或者set()触发清理。线程池里的线程生命周期和进程一样长,这些 value 就永远活着。

常见做法是在finally块里强制remove():

// 错误写法:线程池复用线程,ThreadLocal 不清理会一直挂着 private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>(); public void handle() { CTX.set(new UserContext(...)); // 大对象塞进去 doBusiness(); // 没有 remove,下一次线程复用时旧 value 还在 }
// 正确写法:无论业务是否抛异常都清理 public void handle() { try { CTX.set(new UserContext(...)); doBusiness(); } finally { CTX.remove(); // 关键一行 } }

如果线上不方便改代码,另一个观察角度是在 MAT 里直接看ThreadLocalMap$Entry的实例数。正常应该在几十到几百,如果出现几万个,基本可以确认有地方在反复 set 而不 remove。再看这些 Entry 的value类的分布,就能定位到具体是哪个组件。

4.3 两份 dump 的 histogram 差分

单份 dump 只能看到“当下谁最大”,判断不了“什么在增长”。真正的泄漏特征是单调增长,所以间隔一段时间打两份 dump 做差分,是比单点分析更可靠的方法。

MAT 本身没有直接的双文件 diff 功能,Compare Basket只能比较当前会话里的两组 Histogram 数据,不能跨文件。我的做法是导出 CSV 再用脚本差分:

# 对比两份 MAT 导出的 histogram CSV,找出实例数增长最多的类 # 用法:python diff_hist.py heap_t1.csv heap_t2.csv import csv import sys def load(path): """读取 MAT 导出的 Histogram CSV,返回 {类名: 实例数}""" result = {} with open(path, newline='', encoding='utf-8') as f: reader = csv.reader(f) next(reader, None) # 跳过表头行 for row in reader: if len(row) < 2: continue cls = row[0].strip() try: # 第二列是实例数,可能带千分位逗号 count = int(row[1].replace(',', '')) except ValueError: continue result[cls] = count return result t1 = load(sys.argv[1]) t2 = load(sys.argv[2]) # 只保留增长量大于 1000 的类,按增量降序输出 diffs = [] for cls, c2 in t2.items(): c1 = t1.get(cls, 0) delta = c2 - c1 if delta > 1000: diffs.append((delta, cls, c1, c2)) for delta, cls, c1, c2 in sorted(diffs, reverse=True)[:30]: print(f"{delta:>10d} {cls} ({c1} -> {c2})")

这段脚本做的事很直接:读两份 CSV,按类名对齐,算实例数增量,过滤掉小于 1000 的噪音,按增量排序输出前 30 行。参数就两个文件路径,导出的方式是在 MAT 的 Histogram 视图右上角菜单选Export to CSV,两份文件的格式要一致,否则列偏移会导致解析出错。

增量最大的类不一定就是泄漏源,可能是正常的缓存预热或者连接池扩容。判断方法是看这个类的实例数是不是和第二个文件的时间间隔成比例——如果两个 dump 隔了 10 分钟,实例数涨了 5 万,而且业务量没有对应增长,那基本可以锁定。这时候再回到第二份 dump 里,对这个类做一次 Path to GC Roots,链路就清晰了。

5. MAT 排查避坑:六个现场与参数级解法

这一章是纯踩坑记录。前面几章讲的是“应该怎么做”,这一章讲“做的时候会在哪里翻车”。每条都按现象、原因、解决三段写,都是我在实际排查里撞过的。

5.1 打开与索引阶段:三个直接卡死的现场

现象一:进度条走到 “Building index” 就停住,几分钟后弹 OutOfMemoryError。

原因:MAT 自身的-Xmx不够。索引阶段要把对象图和反向引用索引加载进内存,需要的空间和 hprof 大小正相关,默认的 1024m 对付 2GB 以上的 dump 必然崩。另一个隐性原因是 dump 里不可达对象太多,索引的数据量比存活对象大一圈。

解决:改MemoryAnalyzer.ini里的-Xmx,按 dump 大小的 1.5 倍配。如果机器内存有限,退而求其次的办法是先用jmap -dump:live重新采一份小一点的,或者用-Dorg.eclipse.mat.parser.maxNeededHeap之类参数限制索引缓存(这个参数在不同版本行为不一致,稳妥起见还是加内存)。改完 ini 记得确认没有第二处-Xmx覆盖。

现象二:报错Could not read heap dump或者类名显示成乱码Ljava/lang/Str...。

原因:hprof 文件被截断。常见于 dump 过程中进程被杀、磁盘写满、跨机器传输时 scp 中断。hprof 没有校验和,截断的文件在结构上仍然是“看起来合法”的,MAT 解析到一半才发现 record 长度对不上。另一种可能是用了非标准的 dump 工具,生成的文件格式和 hprof 规范有偏差。

解决:先看文件大小是不是符合预期(和堆配置对比),再用jhat或者file命令粗验一下。确认截断就只能重新 dump。传输大文件时用rsync --partial或者带校验的传输方式,别用裸 scp。

现象三:索引跑完了,但每次打开都要重新建,慢得没法用。

原因:.index目录写不进去。hprof 放在只读挂载点、或者当前用户对该目录没有写权限,MAT 静默失败,下次打开继续重建。这个坑特别隐蔽,因为 MAT 不会明确提示权限问题。

解决:chmod或者把 hprof 拷到本地可写目录再分析。另外检查磁盘剩余空间,索引目录本身要占 hprof 的三分之一左右,空间不够时也会静默失败。

5.2 结论阶段:三个把方向带偏的误判

现象四:Histogram 里某个类的 Retained Size 全是 0 或者空白,以为这个类不占内存。

原因:Retained Size 是懒计算的,刚打开 dump 时没有触发全量计算,表格里那一列是空的。很多人扫一眼就走了,错过真正的大头。

解决:在 Histogram 视图里点表头的Retained Heap列,或者右键菜单选Calculate Retained Size,等它算完(几 GB 的堆要几十秒)。算完之后数字才会填上。另外注意,Shallow Heap 很小但 Retained 很大的类是常态,别被第一列骗了。

现象五:Path to GC Roots 指向ThreadLocalMap$Entry -> key,判定为 ThreadLocal 泄漏,改完代码没效果。

原因:没有排除弱引用。Entry的 key 是弱引用,MAT 默认路径展示会把它算进去,看起来像线程持有 key,实际上 key 早就该被回收。真正的问题在value那一侧,而 value 的链路被 key 的噪音盖住了。

解决:重新用exclude weak/soft references跑一遍,看剩下的强引用链指向哪里。如果强引用链断了,说明确实是弱引用持有,不构成泄漏;如果还在,那才是真的。这个操作重复做几次不丢人,我自己也经常第一遍忘了切排除项。

现象六:OOM 自动 dump 的文件分析不出问题,Histogram 里全是本该回收的临时对象。

原因:HeapDumpOnOutOfMemoryError采的是 OOM 那一刻的完整堆,包括所有不可达但还没被 GC 扫到的对象。这些垃圾会稀释信号,让真正的泄漏对象排名下降。更麻烦的是,OOM 时刻的堆状态可能和稳定运行时有很大差异,比如正在做大批量计算。

解决:分析时在 MAT 的 Preferences 里勾上Keep unreachable objects的反选项(默认就是不保留,但索引阶段已经写进去了),或者先用jmap -dump:live补采一份对比。我的习惯是 OOM 现场那份留档,另外在业务低峰期用jcmd GC.heap_dump(不带-all)再采一份,两份对照着看,判断哪些是稳态泄漏、哪些是瞬时峰值。

6. 大堆进阶:批处理固化、报告留档与一次深刻教训

当 dump 超过 8GB,GUI 基本没法用了,翻一页表格卡三秒,OQL 跑一条查询要等一分钟。这时候唯一的出路是把分析流程批处理化,让 MAT 在后台把该算的都算完,人只看结果。

我的标准动作是这样一套脚本:

#!/bin/bash # analyze_dump.sh - 对指定 hprof 做一次完整的无界面分析 # 用法:./analyze_dump.sh /data/dump/heap.hprof 8g set -e HPROF="$1" HEAP="${2:-8g}" MAT_HOME="/opt/mat" # 临时把 MAT 的堆参数改成命令行传入的值,避免每次都手改 ini sed -i "s/^-Xmx.*/-Xmx${HEAP}/" "${MAT_HOME}/MemoryAnalyzer.ini" # 依次生成泄漏嫌疑、概览、大对象排行三份报告 "${MAT_HOME}/ParseHeapDump.sh" "${HPROF}" \ org.eclipse.mat.api:suspects \ org.eclipse.mat.api:overview \ org.eclipse.mat.api:top_components # 导出 histogram 供后续差分,-format=csv 是导出格式参数 "${MAT_HOME}/ParseHeapDump.sh" "${HPROF}" \ -Dorg.eclipse.mat.api.parse.run=histogram echo "报告已生成:$(dirname "${HPROF}")" ls -lh "$(dirname "${HPROF}")"/*.zip

脚本里sed那行是偷懒做法,生产上更稳妥的是复制一份 ini 或者用环境变量覆盖。ParseHeapDump.sh的第二个参数之后跟的都是报告生成器 ID,可以一次传多个,MAT 会在同一个会话里依次跑完,比开三次进程省时间。最后那个-D参数是给 MAT 传 JVM 系统属性,用于指定额外的导出动作,具体可用的属性名随版本变化,用之前建议先看对应版本的文档或者直接跑ParseHeapDump.sh -help。

报告固化之后,跟进方式就变成:把每次 OOM 或定期巡检的 zip 包按日期归档,配合 4.3 节的 CSV 差分脚本,形成一条增长曲线。这比每次出问题再临时抓 dump 有效得多,因为你能看到“从哪天开始涨”的拐点,而不是只知道“现在很大”。

还有一点值得单独说:堆对比时两份 dump 的 JVM 参数和类路径要一致。曾经为了省事,拿一份 JDK 8 的 dump 和一份升级到 JDK 11 之后的 dump 做差分,结果HashMap的实现在两个版本里内部结构有差异,实例数对比完全失真,白白花了一个下午追一个不存在的泄漏。

进阶技巧里最省时间的一个,是给常用查询存成 OQL 模板。MAT 支持把 OQL 保存成.oql文件放在指定目录,下次从下拉框直接选。我常驻的三条是:查超过 10MB 的 byte[]、查实例数超过 5 万的业务类、查所有ThreadLocalMap$Entry里 value 非空的条目。这三条覆盖了八成以上的排查入口,比每次手写 SQL 快得多。

最后说个教训。早期排查一次线上内存告警,我拿到 dump 就直接在 Histogram 里按 Shallow Heap 排序,看到char[]排第一就断定是字符串拼接问题,让开发去改日志拼接逻辑。改完一周后又告警了,这次老老实实算了 Retained Size,发现真正的大头是一个本地缓存组件里的ConcurrentHashMap$Node,它持有的 Entry 里包着大对象,Retained 加起来占了 60% 堆,而char[]只是这些对象的字符串字段。从那以后我每次打开 dump 都强制走一遍“先算 Retained Size、再看 Dominator Tree、最后才看 Histogram 排序”的流程,不再凭第一眼的类名排行下结论。希望这些流程和坑能帮你少走一两个下午。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进