PMAT实战:用heapdump与javacore定位Java内存泄漏

发布时间:2026/10/27 13:04:27
PMAT实战:用heapdump与javacore定位Java内存泄漏

简介:IBM Pattern Modeling and Analysis Tool for Java(简称GA)是一款面向Java开发者与系统管理员的GC日志分析工具,用于解读JVM垃圾回收行为、定位长时间停顿与内存分配异常,进而辅助调优JVM参数。压缩包共17个文件、约7.9MB,主体为可执行JAR文件ga441.jar;此外还包含readme.zip使用说明、license许可协议,以及中、英、日、韩、德、法、意、葡、西、波、土、捷等多语言界面的html帮助页面,便于在不同语言环境下查阅操作指引与注意事项。已有309人浏览学习。借助它对串行、并行、CMS、G1等不同收集策略的定制化解析与可视化报告,读者可快速掌握GC日志的判读方法,识别频繁回收、内存泄漏等典型瓶颈,并把分析结论落到实际参数调整上,适合需要排查Java应用性能问题、建立GC调优思路的中高级开发者参考使用。

1. 从一份 6 GB 的堆转储说起:PMAT 能回答什么、不能回答什么

线上服务跑了一天,堆占用从 2 GB 爬到 6 GB,重启能压回去,第二天照涨。这种曲线我见过太多次,最后能一锤定音的往往不是监控大盘,而是一份 Java 堆转储。PMAT(Pattern Modeling and Analysis Tool for Java)就是拆这类文件的离线分析工具:给它一份 heapdump 和一份 javacore,它给出泄漏疑点列表、对象分布与保留大小、线程栈和锁等待关系,把「谁在涨」从猜测变成可核对的数字。

它最大的价值在「离线」两个字。生产环境不敢长时间挂 profiler,但一份已经落盘的转储,你可以在自己机器上翻来覆去看一整晚,不影响任何人。它适合三类人:正在处理内存告警的后端工程师、要给出根因结论的中间件支持人员、以及需要复现偶发 OOM 的测试同学。反过来,它不擅长回答「这个接口为什么慢 30 ms」这类 CPU 侧问题,那得换采样工具。下面按我实际拆包的顺序写:先确认输入是什么、怎么采,再走一遍安装和第一次分析,然后用查询和差分把嫌疑对象确定下来,最后把踩过的坑列清楚。

2. 三种转储文件的分工:javacore、heapdump、verbosegc 怎么配齐

很多人第一次用这类工具翻车,不是因为不会点按钮,而是因为手上只有一份文件,问题根本不在它覆盖的范围内。动手之前先搞清楚每种文件能回答什么问题,比装工具重要得多。

2.1 javacore、heapdump、verbosegc 各自回答什么

我把三者的关系理解成「现场照片、账本、流水」:javacore 是某一瞬间所有线程在干什么的照片,heapdump 是那一瞬间堆里每个对象被谁持有的账本,verbosegc 是内存随时间变化的流水。

文件类型典型格式能回答的问题不能回答的问题
javacore / thread dump纯文本 .txt线程卡在哪、谁在等锁、有没有死锁、线程池是否打满哪些对象占内存、谁持有它们
heapdump二进制 .phd / .hprof哪个类实例最多、谁保留了最多内存、对象之间的引用链对象是什么时候涨起来的、线程当时在跑什么
verbosegc 日志文本 .log每次 GC 前后堆占用、停顿时间分布、老年代增长斜率具体是哪个业务对象把老年代撑满

所以单靠一份 dump 说「找到了内存泄漏」,说服力是不够的。常见做法是把 heapdump 的「谁」和 verbosegc 的「什么时候」拼起来:verbosegc 告诉你老年代在每天 10 点后开始单调上涨,heapdump 告诉你上涨的那部分里 70% 是被某个缓存类的 Map 持有,两条线一对上,结论才站得住。

提示:PMAT 的主战场是 javacore 和 heapdump,verbosegc 曲线它不做精细绘制,我一般用专门的 GC 可视化工具看曲线,再把关键时刻的堆转储丢进 PMAT 看内容,两个窗口并排放。

2.2 在生产上把文件采下来,不重启服务

这里有个必须提前知道的事实:堆转储不是想采就能采的。很多 JVM 实现需要你在启动参数里先把转储规则配置好,事后没法补;也有些实现支持用诊断命令现场触发。区别很大,别等出事才发现采不到。

# 启动参数里预置转储规则:堆转储 + javacore,落到固定大盘目录 java -Xmx4g \ -Xdump:heap:defaults:file=/data/dump/heap-%Y%m%d-%H%M%S.phd,request=exclusive+prepwalk \ -Xdump:java:defaults:file=/data/dump/javacore-%Y%m%d-%H%M%S.txt \ -jar app.jar

这段参数里有三个细节值得展开。defaults表示规则对所有触发点生效,包括 OOM、Full GC 之后、以及外部信号,不用逐个事件写一遍。file里的%Y%m%d-%H%M%S是时间戳占位符,我见过最惨的一次事故是文件名写死了,第二次 OOM 直接把第一次的现场覆盖掉,等于白采。request=exclusive+prepwalk让 JVM 在写文件前先做一次可达性预遍历,产出的堆转储结构更完整、后续解析快很多,代价是这次转储的停顿会变长,生产上要么放低峰,要么接受这几秒。

# 现场补一份堆转储,不需要提前配置;代价是一次全堆遍历引起的停顿 jcmd 12345 GC.heap_dump /data/dump/heap-$(date +%Y%m%d-%H%M%S).hprof # 只捞线程栈,代价最低,线上随时可做 # 连做三次、间隔 10 秒,能看出线程是真的卡住还是恰好在跑 for i in 1 2 3; do kill -3 12345; sleep 10; done

jcmd那条命令适合支持现场触发的 JVM 实现,方便但会带来一次明显停顿,几百 MB 的堆通常一到三秒,几个 GB 的堆十几秒起步,别在流量高峰做。kill -3那条几乎无代价,只导出线程栈和锁信息,是排查「接口卡住不动」的第一选择,连做三次的价值在于排除偶然性:如果三次里同一个线程都停在同一个栈帧上,那才是真卡住。

注意:转储文件动辄几个 GB,务必确认目标目录在独立的大容量分区上。我遇到过一次堆没采成,反而把日志分区写满导致服务不可用,比原来的内存问题严重得多。

2.3 最小采集集与命名约定

我的习惯是采一组而不是一个:一份 javacore、一份 heapdump、一段覆盖故障时间窗的 verbosegc 日志,外加一份当时的部署版本号和 JVM 启动参数。前三样给工具,后两样给自己——没有版本信息,你分析半天可能对着一份两周前就已经改过的代码。

文件命名我固定用「服务名-类型-时间戳-触发原因」四段式,比如order-svc-heap-20240612-1043-oom.phd。多份 dump 放在一起做差分的时候,这个命名能省掉大量「哪份是基准、哪份是异常」的回忆成本,也避免了靠文件修改时间猜顺序这种玄学操作。

3. 装完第一次跑通:从 workspace 卡死到泄漏疑点表

工具本身是桌面程序,解压即用,不涉及服务端部署。真正耗时间的环节在第一次解析大文件,这里有几个参数必须先调对,否则会卡在进度条上怀疑人生。

3.1 启动不了的三类原因

第一类是运行时环境不匹配。PMAT 需要一个本地 Java 运行时来跑自己的界面和解析器,它并不要求跟被测应用版本完全一致,但位数必须匹配,32 位运行时开不出 4 GB 以上的分析堆。第二类是 workspace 路径问题,路径里带中文、空格或者落在网络盘上,表现是启动后白屏或者建索引时长时间无响应。第三类是权限,解压目录只读会导致配置写不进去,起来就退出。

调优入口是一个 ini 文件,改完重启才生效:

# 分析器自身的堆上限:解析 4 GB 的堆转储,至少要给到 6 到 8 GB -vm /opt/jre-17/bin/java -vmargs -Xmx8g -XX:MaxDirectMemorySize=2g -Dosgi.workspace.location=/data/pmat-workspace

-vm那行显式指定运行时路径,比依赖 PATH 里的默认 java 稳定,机器上装了多个版本时尤其重要。-Xmx8g是分析器自己的堆,不是被测应用的堆,经验比例是被测 dump 大小的 1.5 到 2 倍,4 GB 的 dump 给 8 GB 起。MaxDirectMemorySize是给文件映射留的余量,解析大文件时这块不够会抛出一堆看不懂的 IO 异常。workspace 换成绝对路径放在本地盘,是我解决「打开就卡」最有效的一招。

提示:解析完成后工具会在 workspace 里缓存索引文件,第二次打开同一份 dump 通常只要原来的零头时间。所以别频繁清 workspace,除非你怀疑索引损坏。

3.2 导入与解析:进度条为什么长时间停在 80%

导入时工具做的事比想象中复杂:它要把二进制转储里所有对象读出来、按类分组、建立对象之间的引用图,然后反向算出每个对象的保留大小。最后这一步是全局遍历,进度条会长时间不动,CPU 和磁盘都在忙,这不是卡死。

我的判断方法很土但有效:看一眼进程的 CPU 占用,如果在跑,就等;如果一直是 0,八成是堆不够在反复 Full GC,去日志里找OutOfMemoryError字样,然后回来把-Xmx调大。一个 6 GB 的 dump 在普通笔记本上解析十几分钟是正常的,别急着杀掉重来。

解析完成后界面通常分四块:类直方图、泄漏疑点、对象查询、线程视图。新手最容易犯的错是直接冲进泄漏疑点列表,看到一个巨大的数字就开始怀疑某个框架类,然后得出「这是框架的问题」这种没有意义的结论。正确的顺序是先看直方图建立整体印象,再用支配树找持有者,疑点列表放最后做交叉验证。

3.3 泄漏疑点表的四个指标怎么读

表格通常给四个数:类名、实例数、浅大小、保留大小。浅大小是这个类的所有实例自身占的字节数,保留大小是「如果把这些实例全回收掉,能释放多少」——后者才是判断泄漏的依据,因为它包含被这些实例独占持有的下游对象。

举个真实感很强的例子:一个HashMap$Node的浅大小可能只有 32 字节,看着人畜无害,但它的保留大小是 3 GB,因为它持有的是整个缓存条目的引用链。只看浅大小排序,前几名永远是String、char[]、byte[]这些「搬运工」,它们是结果不是原因。

指标含义什么时候最有价值
实例数该类的对象个数判断有没有异常膨胀,比如本该几百个的连接对象涨到几十万
浅大小对象自身占用评估单个类的基础开销,一般不单独用于定位
保留大小回收它能释放的总量定位真正的内存持有者,优先看这个排序
支配关系谁直接持有它从「搬运工」顺藤摸瓜找到业务对象

正确姿势是反过来走:先按保留大小降序,看前十名里有没有业务包名下的类;如果前十名全是 JDK 集合类,就点开它的支配树往上看一层,看是被哪个业务类的字段持有的。这一步走通了,基本就锁定了嫌疑范围。

4. 查询与差分:把「谁在涨」从猜测变成 3 个可核对的数字

到这一步容易停在「我觉得是这个缓存类有问题」的层面,结论没法写进复盘报告。要把它变成可核对的数字,得靠查询和差分两个动作。

4.1 直方图先粗后细:先看搬运工再看持有者

直方图按类聚合,适合做第一轮筛查。我的固定动线是:先看实例数最多的前十名,找有没有明显不合理的量级;再看数组类的长度分布,超长数组往往是批量加载或者无界缓存的信号;最后看集合类的实例数,一个几十万条目的 Map 基本就说明了问题。

这一轮不追求精确,追求的是「有没有异常」。比如某个业务对象本来应该跟着请求走、请求结束就回收,结果堆里有 40 万个实例,那不管它单个多小,都值得顺着引用链往上查。

4.2 查询语句的三个固定套路

查询是这类工具最被低估的功能,比在界面上翻页效率高一个数量级。语法是类 SQL 的,字段名对不上是常态——不同 JDK 实现的内部字段名会变,所以正式写之前先到类浏览器里确认一下字段,别直接抄网上的语句。

-- 套路一:限量展开某个业务类的实例,看它持有什么 -- 先 LIMIT,别一上来 SELECT *,几十万条结果能把界面卡住 SELECT * FROM com.example.cache.Entry e LIMIT 200 -- 套路二:按容量阈值筛,专抓异常大的数组和集合 -- 这一条用来验证「有没有无界缓存」这个假设 SELECT * FROM java.util.HashMap h WHERE h.size > 100000 -- 套路三:按成员值筛,定位到某一类业务特征的对象 -- 适合已知出问题的租户/类型,直接缩小范围 SELECT * FROM com.example.order.Order o WHERE o.status = "PENDING"

第一条的价值在「看结构」,200 条足够你判断这个对象的字段里有没有不该长期存在的引用。第二条是验证无界缓存最快的办法,一次查询就能给出「有几个 Map 超过十万条目」这个数字,比翻界面快得多。第三条适合多租户场景,当怀疑是某个大客户的请求把内存撑爆时,直接按业务字段筛,命中数就是你的证据。

字段名对不上时的处理办法:在类浏览器里点开目标类,它会列出真实字段名和作用域,照着改。这一步花两分钟,比反复试错快。

4.3 差分对比:只看增量不看绝对量

这是我最想强调的一条。单份 dump 的 top N 永远是那几个 JDK 集合类,因为任何正常运行的 Java 应用里它们都占大头。只看绝对量,你会得出「所有 Java 应用都在泄漏」这种荒唐结论。

差分才是定位泄漏的正道:采两份间隔足够长的 dump,做对象数量和大小的差集,只看增量最大的那些类。一次典型的操作是,服务启动稳定后采一份基准,跑满一个完整业务周期(比如一天里最忙的那个时段)后再采一份,两份之间增量的前几名,几乎必然是泄漏的源头。

差分有个前提容易被忽略:两份 dump 必须可比。负载特征不同、时间点不同、连缓存预热状态都不同的两份文件,差分出来全是噪声。我一般要求两次采样都在低峰前一刻、间隔至少半小时、期间没有发布变更。不满足这三条,差分结果只能当参考。

4.4 javacore 那一半:线程与锁

堆转储解决「内存被谁占了」,javacore 解决「卡在哪」。工具会把线程按状态分组,重点看三类:长时间处于等待状态的线程、处于阻塞状态并且指向同一把锁的线程、以及栈顶停在同一个方法上的大批线程。

死锁检测是自动的,能直接给出环路。但更多时候没有严格死锁,只有「线程池的线程全被同一个下游调用占住」这种软性耗尽,工具不会报警,得你自己看栈顶分布:如果 200 个工作线程里有 180 个栈顶都停在同一个 HTTP 客户端的方法上,那就是下游拖垮了上游,跟内存一点关系都没有。

5. 避坑记录:五个让我重采一遍 dump 的现场

这些是实打实重采过的,不是从手册上抄的。每一条都按「现象、原因、解决」写,方便对照。

5.1 采集阶段的两个坑

坑一:采出来的文件是 0 字节。现象很直接,一次 OOM 之后兴冲冲去拿文件,发现只有名字没有内容,或者只有一份 javacore 没有 heap。原因通常是两条:目标目录所在分区满了,写出过程中断了;或者进程的运行用户对目标目录没有写权限,转储被静默丢弃。解决分两步,先df -h看分区,再确认运行用户身份和目录属主,转储目录单独挂一个大盘并提前授权。这一条的成本极低,但漏掉的代价是整个现场要等下一次复现。

坑二:转储把服务拖垮了。现象是在流量高点触发堆转储,服务僵住几十秒,健康检查直接把实例拉出集群。原因是写堆转储本质是一次全堆遍历,堆越大停顿越长,prepwalk还会再加一部分时间。解决办法是分层:真出问题时先用kill -3拿线程栈和一份 verbosegc 片段做快速判断,确认是内存问题再决定要不要采堆;采堆放在低峰,或者对实例按组轮流操作,不要一次全采。另外,如果只是要判断「有没有泄漏」,两份间隔的堆转储远比连续多份有用,别浪费停顿预算。

5.2 分析阶段的三个坑

坑三:打开 dump 就卡死,动不动抛出内存错误。现象是导入进度条走到一大半停住,日志里出现分析器自己的内存溢出。原因很明确,是分析器自身堆不够,跟被测应用无关。解决是把-Xmx调到 dump 大小的 1.5 到 2 倍,同时确认 workspace 在本地盘上。这里有个反直觉的点:给分析器 8 GB 堆比给 16 GB 更好,堆太大一来 GC 时间长,二来普通办公机根本拿不出这么多物理内存,会开始换页,表现反而更慢。

坑四:疑点列表全是框架类,找不到业务对象。现象是按默认排序看到的全是String、HashMap$Node、byte[],没有任何一个业务包名下的类。原因是看的是浅大小排序。解决是切到保留大小排序,再从HashMap$Node点进支配树,往上找一层持有者。这个过程通常一到两层就能撞见业务类的字段名,撞见之后立刻用 4.2 里的查询语句按类去查,把数量坐实。

坑五:差分结果全是噪声。现象是两份 dump 差分出来增量前几十名互相矛盾,一会儿是这个类多了一万个,一会儿是那个类少了八千。原因是两份采样不可比:间隔太短、负载不同、或者中间发生过发布重启。解决办法是定三条硬规则——间隔至少半小时、两次都在类似的负载水平、期间没有代码或配置变更。做不到就别做差分,单份 dump 配合 verbosegc 的斜率也能出结论,只是证据链弱一点。

注意:这五个坑里有两个(0 字节、拖垮服务)会导致现场彻底丢失,而它们的预防成本几乎为零。先确保转储配置正确,再讨论用什么工具分析,顺序不能反。

6. 进阶验证:把疑点变成可复现的假设,再关掉一次告警

分析到最后,一定会落到一个具体假设上,比如「这个本地缓存的 key 里混进了请求 ID,导致容量永远清不干净」。此时最大的风险是拿着假设直接去改代码,改完发现堆还是涨,白干一轮。我的做法是给每个假设配一个验证手段和通过标准,三条全过才算结案。

假设结论验证手段通过标准
某个 Map 无界增长差分两份 dump,看该类实例数增量连续两个业务周期增量接近 0
缓存 key 命中率低在 dump 里按 key 前缀分组统计前缀种类数收敛到业务实体数量级
大对象被上游长期持有支配树逐层展开,找到持有者字段持有者生命周期与业务语义一致

具体操作上,我会在改动上线后重跑一遍完整链路:先采一份启动稳定后的基准堆,跑满同样时长再采第二份,做差分,看目标类的实例数增量是不是从几千掉到了个位数。同时把 verbosegc 的老年代曲线拉出来对比,看斜率有没有变平。两个信号同时转好,才把那条告警规则关掉。

有个习惯我保持了几年:任何一次「修好了」的结论,都必须有一份改动后的 dump 作为证据,而不是「观察了几天没再报」。因为没再报的可能原因太多了——流量下降了、实例扩了、告警阈值被调了——这些都跟修复无关。在一次又一次重采 dump 的过程中,我最大的教训是:先怀疑采样条件,再怀疑代码,顺序错了会浪费整晚。从那以后我每次开分析之前,都会先把文件的时间戳、版本号、负载水平三样东西写在便签上贴在屏幕边上,对不上就先重采,不硬分析。希望帮到你。

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