简介:面向IBM J9虚拟机环境下的Java开发者与性能排查人员,这份压缩包提供HeapAnalyzer堆内存分析工具,用于读取heapdump快照并定位内存泄漏、对象过度分配与内存碎片等问题。工具以可视化界面呈现对象分布、类实例数量与内存占用、对象引用关系及生命周期线索,支持内存泄漏检测、类统计和引用图查看,便于在性能瓶颈或内存异常时快速缩小排查范围。包内共3个文件,以jar主程序、xml配置和bat启动脚本为主,压缩包约5.45MB,结构精简,解压后可按说明启动分析。已有614人学习,适合希望掌握IBM Java内存诊断思路、补充GC与对象引用知识的中级开发者参考,也可作为日常内存巡检和问题复盘的辅助工具。
1. 拿到一个 PHD 转储,别急着换工具:HeapAnalyzer 到底补的是哪块板
线上服务内存涨到 90%,重启就掉回 40%,第二天又爬上去。这类问题的难点不在修,在于把现场完整留下来。J9 系虚拟机输出的堆转储是 PHD(Portable Heap Dump)格式,只记录对象类型、大小和引用关系,不存原始字节,体积可控,能直接从生产环境抓回来。可很多人拿通用分析器去开这个文件,要么提示格式不支持,要么只解析出寥寥几百个类,白折腾半天。
HeapAnalyzer 就是冲着这个格式来的堆内存分析工具,单个 jar 启动,专门吃 PHD、javacore 和系统转储,跑 OpenJ9 及前代 JVM 的服务用它做泄漏定位最省事。它不是什么通用性能平台,边界认清之后,这类开发工具在排查内存缓慢增长时非常好用。下面从转储怎么造、怎么加载、几个视图怎么读,一直讲到几个反复踩的坑。
2. 转储怎么造出来:触发路径、-Xdump 参数与格式校验
分析得再漂亮,dump 造得不干净也是白搭。堆转储的坑八成出在生成环节,而不是分析环节。这一章把触发方式、参数含义和校验手段拆开讲。
2.1 三条触发路径,先按场景选对
生产环境抓堆,常见做法无非三种:随 JVM 启动就挂上自动触发;运行中发信号手动触发;外部工具远程触发。前两种最实用,第三种在容器里经常因为权限和信号转发失败,不太推荐。
自动触发一般绑 OOM 事件,进程活着的最后一刻把现场落盘,这是最有价值的一份快照:
# 随 JVM 启动挂载:OOM 时自动落堆转储,同时落一份 javacore 做交叉验证 java -Xms4g -Xmx4g \ -Xdump:heap:events=systhrow,filter=java/lang/OutOfMemoryError,request=exclusive+compact+prepwalk,file=/data/dump/oom_%Y%m%d_%H%M%S.%pid.phd \ -Xdump:java:events=systhrow,filter=java/lang/OutOfMemoryError,file=/data/dump/javacore_%Y%m%d_%H%M%S.%pid.txt \ -jar app.jar这段命令里,-Xdump:heap声明事件类型是堆转储,events=systhrow表示由抛异常这个动作触发,filter=java/lang/OutOfMemoryError把触发条件收窄到 OOM,不加过滤器的话任何 systhrow 都会落盘,磁盘很快就满。request=exclusive+compact+prepwalk是转储前的三个动作:exclusive 暂停所有应用线程保证快照一致,compact 在转储前做一次压缩把垃圾清掉,prepwalk 预先走一遍堆把可达性标好。这三个缺一个,后面分析时就会看到一堆本该回收的对象混在统计里,结论直接偏。
file=后面是文件名模板,%Y%m%d_%H%M%S是时间占位符,%pid替换成进程号。模板里带时间是最省事的做法,不然多次触发会互相覆盖。
运行期手动触发适合“还没 OOM 但已经不对劲”的场景,比如连续观察三天内存只涨不跌,这时候没必要等到挂:
# 先确认堆转储事件绑到了 user 信号上(启动参数里配一次即可) # -Xdump:heap:events=user,request=exclusive+compact+prepwalk,file=/data/dump/user_%Y%m%d_%H%M%S.phd # 需要现场时直接发信号,进程不重启,业务无感 kill -3 12345events=user把堆转储挂到用户信号上,kill -3发的是 SIGQUIT,J9 系默认会产出 javacore,堆转储则取决于有没有配-Xdump:heap:events=user。很多团队只配了 javacore 没配 heap,发完信号只拿到一个文本文件,以为转储失败了,其实是压根没开。
2.2 -Xdump 的字段拆解与常用组合
-Xdump这一套参数不算复杂,但字段名和取值容易记混。把常用字段列成一张表,配的时候对着看:
| 字段 | 作用 | 常见取值 |
|---|---|---|
| event 类型 | 落在-Xdump:后面,决定产物形态 | heap堆转储、javajavacore、system系统转储、snap截断轨迹 |
events= | 触发事件源 | systhrow、user(信号)、gpf、vmstart、throw |
filter= | 事件过滤,只有命中才触发 | 类名全限定,如java/lang/OutOfMemoryError |
request= | 转储前动作,逗号分隔 | exclusive、compact、prepwalk、serial |
file= | 输出路径与命名模板 | 支持%Y%m%d、%H%M%S、%pid、%seq |
range= | 限制触发次数,防止刷盘 | 1..3表示只抓前三次 |
defaults | 给同类事件统一套默认参数 | 写在 event 和具体事件之间 |
range是最容易被忽略的一个。线上如果配了events=user又没限次数,运维同学手一抖连发五次信号,数据盘直接写满,服务跟着挂,这种翻车我见过不止一次。稳妥写法是range=1..3,配合定期清理脚本。
还有一个常见组合是按堆占用阈值触发。J9 支持events=slow、events=fullgc之类的事件源,配合range做低频采样:
# 每次 Full GC 后如果堆占用仍然很高,落一份转储,最多抓两次 -Xdump:heap:events=fullgc,request=exclusive+compact+prepwalk,file=/data/dump/fgc_%seq.phd,range=1..2这种配置开销不小,compact本身就是一次 GC,prepwalk还要遍历整个堆,只在排查期临时打开,定位完就撤掉。长期挂着会让 GC 停顿时间明显变长,属于典型的排查手段被当成常驻配置。
2.3 体积预估与格式校验
PHD 的体积大致是活跃对象数的函数,跟对象里存的内容长度关系不大,经验值在活跃堆的 20% 到 40% 之间。4G 的堆,转储文件通常落在 800MB 到 1.5G。心里有这个数,看到文件大小明显偏小就要警觉。
转储落地之后别急着拷走,先在机器上做两件事:看大小、看头部。
# 一看大小:和活跃堆占比对不上就是可疑 ls -lh /data/dump/*.phd # 二看头部:确认不是零字节文件或者被截断的残片 xxd -l 64 /data/dump/oom_20240101_101500.12345.phd # 三看尾部:正常结束的转储末尾应该有完整的结束标记,不是一堆 0 tail -c 64 /data/dump/oom_20240101_101500.12345.phd | xxd转储过程中最忌讳kill -9。进程被强杀,文件句柄没关,落盘的就是半截文件,大小看着像模像样,加载时直接报错。容器环境更要注意,转储期间内存峰值会上去,如果 limit 卡得紧,容器被 OOM Killer 干掉,一样是残片。我的习惯是把 dump 目录挂到独立的数据盘,并且临时把容器内存 limit 抬高 30%,抓完再降回来。
javacore 别丢。它和 dump 是互补关系:dump 告诉你对象都在哪,javacore 告诉你抓取那一刻线程在干什么、类加载了多少、GC 历史如何。两个文件同一时刻抓下来,后面分析能省很多来回。
3. 把 dump 喂进去:启动参数、加载与三类视图
文件拿到手,下一步是打开。这一步看着简单,实际上翻车率不低,主要卡在内存配比和图形环境上。
3.1 启动命令与 -Xmx 配比
HeapAnalyzer 本身是个 Java 程序,加载转储时要在内存里重建对象图和引用索引,开销是 dump 文件的好几倍。给少了直接 OOM,给多了把分析机拖死。
# 分析器自身也是 JVM 程序,堆要给足 # 一个 8G 的 dump,索引重建大概需要 20G 以上堆,按 3 倍起步给 java -Xmx24g -jar ha.jar-Xmx24g这个值不是拍脑袋。加载 PHD 时,每个对象都要建一条记录,包含类型、浅大小、引用列表,几千万个实例乘下来,内存占用轻松超过 dump 本身的两三倍。我一般按 dump 体积的 3 倍起给,4G 的 dump 给 12G,8G 的给 24G,机器内存不够就先在本地分析,别硬扛。
另一个前置条件是图形环境。这个工具是 GUI 的,纯字符界面的服务器上直接跑会抛 headless 异常。常见的三个解法:
# 解法一:远端抓、本地分析,最省事,也最不容易出幺蛾子 scp ops@host:/data/dump/oom_20240101_101500.12345.phd ./ # 解法二:确实要在服务器上看,用 X11 转发(本地需有 X Server) ssh -X ops@host java -Xmx24g -jar ha.jar # 解法三:临时装一个轻量桌面环境,用完卸掉,适合长期做这件事的排查机三种里我推荐第一种。X11 转发在跨机房、跳板机多层嵌套的情况下经常连不上,调半天连接不如花两分钟把文件拉回来。dump 文件压缩后体积能小不少,传输时间可以接受。
启动之后是加载,界面会显示进度。8G 的 dump 在普通机械盘上加载十几分钟很正常,别以为卡死了就强杀。加载期间可以看进程的内存曲线,只要在涨就是还在干活。
3.2 泄漏嫌疑与对象统计怎么读
加载完成后,第一眼看泄漏嫌疑(Leak Suspects)列表。它会把占用最大的几组对象列出来,附带一句“疑似由某处持有”的判断。这个判断是基于启发式规则的,不能当结论用,但能帮你快速锁定方向。
第二眼切到对象视图(Objects),按类聚合看三列:实例数量、浅大小(对象自身占用)、累计大小(含它引用的所有对象)。读的顺序有讲究:
先看实例数。某个业务类实例数达到几十万、上百万,大概率是缓存没设上限或者集合只增不减。再看浅大小,如果是byte[]、char[]这类数组排在前面,说明问题在数据体量而不是对象数量。最后看累计大小,它能告诉你“干掉这一组能回收多少”,这是决定要不要动手的关键指标。
举个典型读数:某个HashMap$Node实例数 120 万,浅大小 40MB,看起来不大,但累计大小 6.2G。这说明真正的负担在 Node 指向的 value 上,顺着引用链往下走,往往能落到某个byte[]或者业务 DTO。只盯浅大小会得出“这块不大”的错误结论,白跑一趟。
对象视图里还有一列不可达对象。如果转储时没开prepwalk,这列会混进大量本该回收的垃圾,读数完全失真。这也是为什么前面反复强调转储参数——分析阶段的很多困惑,根子在生成阶段。
3.3 引用链与支配树:从“谁占内存”到“谁该负责”
找到可疑类只是第一步,真正要回答的是“谁持有它、为什么释放不掉”。选中一个实例,打开引用视图(References),它会列出所有指向这个对象的引用,一层层往上追,直到 GC Root。
GC Root 就那么几类:线程栈上的局部变量、类的静态字段、JNI 局部引用、正在加载的类加载器。追到根之后,看根的类型基本就能定性:
- 根是
Thread或者ThreadLocalMap,八成是线程池里的线程持有了一份大对象没清,或者 ThreadLocal 用完没 remove。 - 根是某个类的静态字段,就是全局缓存或者单例持有,检查淘汰策略。
- 根是
ClassLoader,那是动态类生成导致的老类加载器泄漏,跟业务对象没关系,方向要换。
支配树提供另一个视角。它按“如果这个对象被回收,能连带回收多少”排序,也就是常说的 retained size。跟累计大小的区别在于,支配树会处理共享引用——两个对象共同引用一块内存时,不会重复计算。排查“到底谁是大头”的时候,支配树的排序比按类聚合更接近真相。
实际操作里我一般这么走:支配树看前 20 名,锁定两三个候选,再回到引用视图逐个追根。两边互相印证,比单看一边靠谱。追根过程中记得随手记下路径,不然追到第五层就忘了从哪来的,回头重来一遍很折磨人。
4. 避坑与排查:五个反复出现的翻车现场
这一章全是踩出来的。每条按现象、原因、解决三步写,遇到对得上的直接照方抓药。
4.1 文件大小正常,打开只有几百个类
现象:dump 文件几个 G,加载也花了时间,但对象视图里只有几百条记录,或者直接弹格式错误。
原因:两种可能。一是转储过程被打断,文件是残片,大小看着够是因为预分配了空间;二是文件根本不是 PHD,而是 HotSpot 的 hprof,格式对不上。
解决:先看文件尾部有没有完整结束标记,没有就重新抓,抓的时候确认磁盘剩余空间大于堆大小的两倍,且转储期间不要对进程做任何操作。确认是 hprof 的话,换通用分析器,别在这个工具上耗。格式校验最直接的办法是看扩展名和头部魔数,两者对不上就别往下走了。
4.2 分析器自己 OOM,或者进度条卡在 90%
现象:启动几秒后抛 OutOfMemoryError,堆栈指向对象索引构建;或者进度条长时间停在一个位置不动。
原因:-Xmx给少了,索引构建阶段内存峰值远超预期;也可能是 dump 里对象数太多,几千万实例的图结构本身就吃内存。
解决:按 dump 体积 3 倍起给,机器内存不够换一台。仍然不够的话,回到生成环节,在转储参数里加上compact先压一次,把活跃集缩小再抓。实在太大的 dump,可以按线程或按类分批看,不要指望一次加载全量。
4.3 类名全是 [B、[C、[[I
现象:按类聚合排在前几名的,类名是一串方括号加字母,看不出业务含义。
原因:PHD 里数组按 JVM 内部签名记录,[B是 byte 数组,[C是 char 数组,[[I是二维 int 数组。真正持有它们的通常是 String、缓冲区或者序列化中间结果。
解决:不要盯着数组本身,从数组往上追一层引用,看是谁持有它。字符数组多半挂在 String 上,字节数组多半是缓冲区或者报文体。追到业务类之后再判断是不是合理占用。顺带提一句,字符串相关的泄漏在业务系统里占比很高,日志上下文、拼接缓冲、缓存 key 都是高发区。
4.4 分析出来的占用和监控曲线对不上
现象:dump 显示堆占用 6G,监控面板同期只有 4G,差了一大截。
原因:三个来源。转储时刻和监控采样时刻不同步;没开compact,把可回收的垃圾也算进了统计;或者看的是 committed 而不是 used,虚拟内存和实际占用混为一谈。
解决:转储参数里把exclusive+compact+prepwalk补齐,抓取时刻和监控时间戳对齐,读数时明确看的是 used 还是 committed。这三件事做完,误差通常能压到 5% 以内。对不上就先怀疑参数,别怀疑工具。
4.5 类加载器泄漏被误判成业务对象
现象:某个业务类的实例数并不多,但整体占用居高不下,追根发现一堆 Class 对象和 ClassLoader。
原因:热部署、动态代理、脚本引擎不断生成新类,老的类加载器被线程上下文或者 ThreadLocal 引用住,无法卸载。表现是元空间和堆同时缓慢增长,重启才能缓解。
解决:引用链上看根节点是不是Thread或者ThreadLocalMap,如果是,检查线程池里的线程有没有清理上下文类加载器。再排查动态类生成的入口,给代理类加缓存,避免每次调用都新建。这类问题靠加内存解决不了,只能从代码侧断掉引用。
5. 一次模拟的堆增长复盘:从 8G dump 到那一个缓存
纸上谈兵容易,走一遍完整流程印象才深。下面用一个刻意构造的模拟程序,把从现象到定位再到验证的链路跑通。
5.1 先做减法:从总量到候选类
构造一个只写不淘汰的本地缓存,跑起来看它怎么把堆撑满:
// 模拟:一个只进不出的报表缓存,key 持续增长,value 是 100KB 的字节块 public class SimulatedLeak { private static final java.util.Map<String, byte[]> REPORT_CACHE = new java.util.HashMap<>(); public static void main(String[] args) throws Exception { long i = 0; while (true) { // key 每次都是新的,缓存没有任何淘汰策略 REPORT_CACHE.put("report-" + (i++), new byte[100 * 1024]); // 控制节奏,让堆缓慢上涨而不是瞬间打满,方便观察曲线 if (i % 100 == 0) { Thread.sleep(10); } } } }编译运行的时候把转储参数挂上,堆给 256M,几十秒就能触发 OOM:
javac SimulatedLeak.java java -Xms256m -Xmx256m \ -Xdump:heap:events=systhrow,filter=java/lang/OutOfMemoryError,request=exclusive+compact+prepwalk,file=/tmp/oom_%pid.phd \ -Xdump:java:events=systhrow,filter=java/lang/OutOfMemoryError,file=/tmp/javacore_%pid.txt \ SimulatedLeak跑完在/tmp下能拿到两个文件。用前面说的命令启动分析器,-Xmx1g足够应付这个量级。加载完切到对象视图,按累计大小排序,排前面的会是byte[]和HashMap$Node,byte[]的实例数大约两千多个,浅大小 200MB 上下——正好对上 2000 × 100KB。
这里就是第一个判断点。如果只看实例数,HashMap$Node更多;只看浅大小,byte[]更大。两个指标指向同一批对象,说明方向没错。如果两者指向完全不同的类,才需要纠结先看哪个。
5.2 沿引用链确认持有者
选中任意一个byte[]实例,打开引用视图往上追。第一层是HashMap$Node的 value 字段,第二层是HashMap的 table 数组,第三层落到SimulatedLeak.REPORT_CACHE这个静态字段。追到静态字段就到头了,GC Root 是类的静态引用。
这一步的关键是别在中间层停下来。很多人追到HashMap就下结论说“HashMap 泄漏”,其实 HashMap 只是容器,真正该改的是谁往里塞、塞完淘不淘汰。定位到具体的业务类和方法,才算追完。
拿 javacore 做交叉验证。打开文本文件,搜REPORT_CACHE相关的类信息,看这个类加载了几次、有没有重复加载。再搜线程栈,看看 OOM 那一刻主线程卡在哪个方法上。两个信息一对,SimulatedLeak.main里那行put基本跑不掉。
5.3 改完之后怎么验证
把缓存改成有界,重跑一遍,对比两次的读数:
// 改成有界缓存:容量上限 2000,超出按访问顺序淘汰最久未使用的 private static final java.util.Map<String, byte[]> REPORT_CACHE = new java.util.LinkedHashMap<String, byte[]>(16, 0.75f, true) { @Override protected boolean removeEldestEntry(java.util.Map.Entry<String, byte[]> eldest) { // 2000 * 100KB ≈ 200MB,最坏情况下的占用可以直接估算 return size() > 2000; } };验证方法是重跑同样的循环,观察堆占用曲线是否在 200MB 附近走平,而不是一路向上。再抓一份 dump,看byte[]的实例数是否稳定在两千上下。两次对比,改之前是线性增长到 OOM,改之后是锯齿状波动,结论就很硬。
这套流程换个业务系统照样能用:先按累计大小做减法锁定候选类,再沿引用链追到业务持有者,最后改代码重跑验证。三步里最容易偷工减料的是第三步,改完不验证,等于没改。
6. 进阶:把抓取和分析做成例行检查
排查一次两次靠手动没问题,但内存问题往往是慢性的,隔几个月来一次,每次都从头搭环境很浪费时间。把抓取和分析固化成例行动作,效率会高很多。
6.1 定时转储与开销控制
不要等到 OOM 才抓。对已经出现缓慢增长的服务,可以在排查期挂一个低频转储,比如每两小时一次,抓三份就够看出趋势:
# 排查期临时配置:用户信号触发,最多三次,抓完自动停止 -Xdump:heap:events=user,request=exclusive+compact+prepwalk,file=/data/dump/probe_%Y%m%d_%H%M%S.phd,range=1..3 # 配合 crontab 定时发信号,避免人工忘记 # 0 */2 * * * kill -3 $(cat /var/run/app.pid)range=1..3是这里的保险丝,三次之后自动失效,不会把数据盘写满。抓完记得把参数撤掉,compact加prepwalk的开销在低峰期不明显,高峰期会让停顿变长。
三份 dump 拿到手,对比它们的类统计:如果某个类的实例数在三份里单调递增,基本可以确认是泄漏;如果三份差不多,那只是缓存预热或者水位正常,不用动。
6.2 工具边界对照
这个工具不是万能的,把适用边界摆清楚,省得在错误的场景里死磕:
| 维度 | HeapAnalyzer | 通用堆分析器 |
|---|---|---|
| PHD 格式 | 原生支持,解析完整 | 部分版本支持,字段可能缺失 |
| hprof 格式 | 支持有限 | 原生支持 |
| 内存开销 | 相对轻,加载快 | 偏重,大 dump 吃内存 |
| 脚本化能力 | 弱,以 GUI 为主 | 多数提供 API 或命令行 |
| 适合场景 | J9 系线上快照的快速定位 | 多格式混合、需要批量处理的场景 |
选型上没那么多纠结:跑 J9 系虚拟机、抓的是 PHD,优先用它;格式混杂、要做自动化分析,换通用工具。两个都装不冲突,我本机上常年备着,按文件格式选。
6.3 一个改不掉的习惯:转储前先记三个数
最后说个细节。每次触发转储之前,我会先把三个数记在排查单上:当前堆的 used 和 committed、最近一小时 Full GC 的次数和平均停顿、活跃线程数。这三个数看着简单,后面分析时能救命——dump 只能告诉你那一刻的对象分布,告诉不了你它是怎么走到这一步的。有了 GC 次数,你能判断是分配速率太高还是回收不动;有了线程数,能排除线程暴涨导致的栈内存占用;有了 committed 和 used 的差值,能判断堆是不是虚胖。
早些年我不记这些,拿着 dump 闷头追引用链,追到一半发现是 GC 参数配错了,前面几小时全白干。从那以后我每次抓堆之前都强制走一遍这三个数,哪怕再急也先花两分钟。希望帮到你。
本文还有配套的精品资源,点击获取