ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

J9 PHD堆转储分析:HeapAnalyzer定位OOM与内存泄漏

J9 PHD堆转储分析:HeapAnalyzer定位OOM与内存泄漏

简介:面向IBM J9 VM开发者与Java性能诊断人员的HeapAnalyzer堆内存分析工具包,聚焦heapdump解析与内存问题定位,可用于排查内存泄漏、对象过度分配、内存碎片及类实例占用异常,适合实际性能瓶颈排查场景。压缩包共3个文件,约5.45MB,包含xml配置、jar主程序与bat启动脚本,分别承担参数配置、分析功能承载和便捷启动作用,结构精简,便于在本地快速部署并加载dump文件开展分析。工具覆盖内存泄漏检测、对象生命周期分析、内存碎片识别、对象引用关系图与类统计等维度,通过可视化视图呈现对象分布和引用结构,帮助读者理解内存组织、追踪可疑对象并辅助JVM调优。已有614人学习下载,适合需要结合真实heapdump进行内存诊断、补充Java内存模型与GC知识的初中级开发者参考,也可为代码审查和内存参数调整提供线索。

1. 从一份 2 GB 的 .phd 文件说起

线上跑在 J9 虚拟机上的一个老服务,堆内存三天涨一次,日志里只留一行 OutOfMemoryError,运维把一个 2.3 GB 的 .phd 文件丢过来,问能不能看出是谁在漏。这种时刻很多人的第一反应是上重量级分析器,结果光建索引就跑了二十分钟,分析机自己先扛不住了。HeapAnalyzer 就是为这种场景准备的:一个单 jar 的 Swing 桌面工具,专门读 J9 系虚拟机的 PHD 堆转储,打开就能看类直方图、实例列表和到 GC Root 的引用链,不需要在服务端再搭任何组件。它适合两类人:手上只有 PHD 文件、又不想为一次排查专门搭一套分析平台的后端工程师;以及要给一个跑了八年、源码都不全的遗留系统做内存检查的维护者。下面按采集、打开、定位、避坑、例行化的顺序走一遍。

2. PHD 与工具边界:先确认你手里的是哪种转储

动手之前先花十分钟把格式和工具能力边界搞清楚,比打开就点一圈按钮要省事得多。我见过太多次把 HPROF 拖进这个工具、然后抱怨「工具坏了」的场面,其实只是拿错了文件类型。这一章解决三个问题:PHD 和 HPROF 到底差在哪、这个工具能给出哪几类视图、跑它需要什么样的运行环境。

2.1 PHD 不是 HPROF:两种转储的字节结构完全不同

堆转储的本质是把整个 Java 堆的对象图序列化到磁盘,但各家虚拟机用的序列化格式并不通用。HotSpot 走的是 HPROF 二进制格式,J9 / OpenJ9 走的是 PHD(Portable Heap Dump),两者的头信息、类元数据表、对象记录编码完全不一样。HeapAnalyzer 只认 PHD,你把一个format=b出来的 hprof 拖进去,它要么直接报无效转储,要么解析出一棵空树。

判断手里的文件是哪一种,最土也最快的办法是看文件头:

格式产生方典型后缀本工具能否直接打开常见替代方案
PHDJ9 / OpenJ9 虚拟机.phd、.dmp可以装 DTFJ 适配器后的通用分析器
HPROFHotSpot 虚拟机.hprof、.bin不可以通用内存分析器原生支持
javacoreJ9 线程与系统快照.txt不可以文本编辑器直接看,查线程栈而不是查堆
system core操作系统级核心转储.dmp、core不可以需要配套的离线调试器

这张表的意义在于止损:先确认文件类型,再决定用哪个工具,别在一个格式不匹配的文件上反复调参数。javacore 和 system core 尤其容易被误当成堆转储,它们的后缀有时候一样,但内容一个是文本、一个是全套进程内存镜像。

2.2 四类视图:直方图、实例、引用链、保留集各回答什么问题

工具打开一份 dump 之后,能给出的信息基本落在四个视图里,每个视图对应一个具体的追问:

类直方图回答「哪类对象占得多」。它是按类聚合的实例数和浅大小(shallow size,对象自身占用的字节,不含它引用的对象),秒级出结果,是最先看的一屏。

实例列表回答「这些对象具体长什么样」。选中某个类之后列出它的实例,每个实例能看到字段值,这一步是判断「这是正常缓存还是失控集合」的关键。

引用链回答「谁在持有它」。从一个实例往上追到 GC Root,路径可能是静态字段、线程栈上的局部变量、JNI 全局引用,路径本身比对象大小更有信息量。

保留集回答「干掉它到底能省多少」。保留大小(retained size)是被这个对象独占、回收它就能一起释放的那部分内存总量。一个 200 MB 的HashMap可能保留集只有 200 MB,也可能因为共享了大量 value 而远小于这个数。

提示:浅大小大不代表泄漏,保留集大也不代表泄漏,只有「持续增长且没有上限」才是泄漏。单份 dump 只能给出快照,判断趋势至少要两份。

2.3 运行环境:JDK 8、64 位、独立于生产机的分析机

这个工具是十多年前的 Swing 应用,构建年代决定了它对 JDK 版本很挑。我一般在 JDK 8 上跑它,稳定、没有模块化相关的启动报错;在 JDK 11 以上的环境里,常见的是NoClassDefFoundError或者双击之后毫无反应——不是工具坏了,是它依赖的一些老库在新版 JDK 里被移出了标准发行版。

位数比版本更要紧。32 位 JDK 的进程地址空间上限摆在那儿,超过 1.5 GB 左右的 dump 基本没戏,会以各种形式失败。分析机必须是 64 位系统 + 64 位 JDK,这是硬性条件,不是优化项。

还有一点容易被忽略:不要把分析进程和生产进程放在同一台机器上。解析一份大 dump 时工具会吃掉几个 GB 的内存和大量 CPU,放在生产机上等于自己给自己制造一次容量事故。正确做法是把 dump 拷到一台独立的分析机,或者至少是同一台机器上的停机窗口。拷贝 2 GB 文件用rsync带断点续传比较稳,别用浏览器下载——截断的文件会让后面的排查全部白做。

3. 把 dump 落下来:-Xdump 参数、三种触发方式与文件校验

拿到一份能分析的 dump,比后面所有分析动作都重要。dump 采集有问题,工具再强也只能对着残缺数据猜。这一章把采集端的参数讲透:怎么让 JVM 在 OOM 那一瞬间自动落盘、手动触发的三种方式各有什么差别、以及落盘之后怎么用几条命令确认这份文件是可用的。

3.1 用 -Xdump 在 OOM 瞬间自动落盘

J9 系虚拟机的转储由一组叫 dump agent 的配置控制,语法统一是-Xdump:<类型>:<事件>,<条件>,<选项>。最实用的配置是在启动脚本里显式挂一条 heap agent:

# 启动参数:OOM 时自动落一份完整的 PHD 堆转储 JAVA_OPTS="$JAVA_OPTS \ -Xmx4g \ -Xdump:heap:events=systhrow,filter=java/lang/OutOfMemoryError,request=exclusive+prepwalk,file=/data/dump/heap-%Y%m%d-%H%M%S-%pid-%seq.phd"

逐个字段说明:

  • events=systhrow表示由虚拟机内部抛出异常这个动作触发,配合filter=java/lang/OutOfMemoryError就锁定了「堆内存溢出」这一种。写成events=throw会把所有异常都算进来,等于给自己挖坑。
  • request=exclusive+prepwalk是两个关键修饰。exclusive在落盘期间暂停其他线程,保证对象图不会被并发修改;prepwalk要求先完整遍历一遍堆再序列化,缺了它,工具那边的保留集计算可能不准,甚至在遍历时遇到「指向未知对象」的断链。这两个词是我在排查里踩过最贵的一个坑,后面第 5 章还会展开。
  • file=里的百分号是占位符:%Y%m%d日期、%H%M%S时间、%pid进程号、%seq同一次运行内的自增序号。加上时间戳之后,同一天多次 OOM 的文件不会互相覆盖,也方便后面做两份 dump 的对比。

目录一定要提前建好并确认有写权限。dump 目录不可写的时候,虚拟机的行为是静默跳过,你只会看到 OOM,看不到文件,然后以为是参数没生效。

启动后想确认配置到底有没有挂上,用-Xdump:what打印当前生效的 agent 列表,比读文档猜要快得多:

# 打印生效的 dump agent 配置,确认 heap agent 与参数拼写正确 java -Xdump:what -version 2>&1 | grep -i heap

这条命令会把每个生效的 agent 的完整配置打出来。如果 grep 不到 heap 相关的行,说明你的-Xdump:段被 shell 的变量拼接吞掉了,或者被后面另一个-Xdump:参数覆盖了——同一类型同一事件的 agent 多次定义时行为是覆盖而不是叠加。

3.2 手动触发:jcmd、信号、环境变量三条路

线上不一定会等到 OOM,很多时候是在监控上看到老年代利用率一路走高,想在可控的时刻手动抓一份。这时候走jcmd:

# 通过 attach 机制手动抓一份堆转储,不需要重启进程 jcmd 12345 GC.heap_dump /data/dump/manual-$(date +%H%M%S).phd # 低成本的先行筛查:只看类分布,判断值不值得抓 dump jmap -histo:live 12345 | head -30 # 多抓几份之后,按体积和校验和留档 md5sum /data/dump/*.phd > /data/dump/md5.txt

GC.heap_dump在 J9 上产出的是 PHD,可以直接喂给这个工具。它的代价是一次较长的停顿——大堆上十几秒甚至更久,所以别在业务高峰期手贱点一下,最好挑监控显示流量低谷的窗口。

jmap -histo:live的价值在于便宜。它只是遍历一遍堆做统计,不写文件、不停顿那么久,输出前 30 行就够判断是不是某个集合类在失控增长。我一般的习惯是先-histo看趋势,确实可疑再抓完整 dump,避免上来就产出一堆几个 G 的文件把磁盘吃满。

第二条路是信号。这里有个流传很广的误解:kill -3能抓堆。在 J9 上,SIGQUIT 默认吐出的是 javacore(线程栈与系统信息),不是堆转储。想让这个信号顺带落一份堆,必须显式再挂一个events=user的 heap agent。很多人kill -3之后去找 .phd 文件、找不到就认为工具链有问题,其实就是这个差别。

第三条路是老版本的环境变量方式,IBM_HEAPDUMP、IBM_HEAPDUMP_OUTOFMEMORY、IBM_HEAPDUMPDIR这一组。新版本统一收敛到了-Xdump,如果两套同时开着,很可能一次 OOM 落两份 dump,磁盘瞬间见底。我建议只留-Xdump,环境变量那一套当作历史包袱清掉。

3.3 落盘之后先校验,别把时间浪费在坏文件上

一份坏掉的 dump,在工具里表现为「打开到一半退出」「树是空的」「引用链断了」,而你会以为是工具的问题。用三条命令先做体检:

# 1) 体积与时间:和 -Xmx 对一下,明显偏小基本就是写失败了 ls -lh /data/dump/heap-20240101-101010-12345-00001.phd # 2) 文件头:PHD 有固定的头部标识,前一百多字节打出来能看出格式对不对 head -c 128 /data/dump/heap-20240101-101010-12345-00001.phd | strings # 3) 磁盘余量:dump 写到一半磁盘满是最常见的截断原因 df -h /data/dump
检查项命令异常表现处置
文件体积ls -lh只有几十 MB,远小于堆使用量检查 dump 目录权限和磁盘余量,重新触发
头部标识head -c 128 | strings出现的是 HPROF 相关字样文件格式不对,换工具或换采集方式
时间戳ls -l --time-style=full-iso时间与 OOM 时刻对不上说明触发的事件配错了,回查-Xdump:what
校验和md5sum拷贝前后不一致传输被截断,用 rsync 重传

体积这一项最值得展开。一份 PHD 的体积大约是堆中存活对象总量的量级,如果-Xmx4g的服务在 OOM 时只落下 30 MB 的文件,那不是「堆里没什么东西」,而是写失败了。反过来,如果文件体积接近甚至超过了-Xmx,也要留意:可能是 dump 期间对象还没来得及回收,属于正常现象,但要确认落盘时刻虚拟机确实处于内存压力下而不是被误触发。

4. 打开与定位:从类直方图走到 GC Root 的完整路径

采集和校验之后才是真正的分析。这一章按实际操作顺序走:先给工具足够的堆内存、再在类直方图里锁定嫌疑类、然后下钻到实例、最后顺着引用链找到持有者。每一步都会说清楚看什么、为什么这么看、以及看错了会往哪个方向跑偏。

4.1 启动参数:先给够堆,再谈分析

这个工具是把整份 dump 读进内存再建索引的,所以它自己的堆必须比 dump 大。经验值取文件体积的 3 到 5 倍,同时不低于原始-Xmx的 1.5 倍,两者取大。手动敲容易算错,写个小脚本更省心:

# 按 dump 体积估算工具自身的堆:取 4 倍,最低 2G DUMP=/data/dump/heap-20240101-101010-12345-00001.phd SIZE_MB=$(du -m "$DUMP" | cut -f1) HEAP_MB=$(( SIZE_MB * 4 > 2048 ? SIZE_MB * 4 : 2048 )) # 注意:不要加 -Djava.awt.headless=true,这是图形界面工具 java -Xmx${HEAP_MB}m -jar heapanalyzer.jar "$DUMP"

参数说明:-Xmx是工具进程自己的最大堆,和被测服务无关,不要被名字带偏;SIZE_MB * 4是经验倍率,2.3 GB 的 dump 会算出约 9.4 GB,此时分析机需要至少 12 GB 空闲物理内存,否则会退化到交换分区上,慢到没法用;2048是下限,小 dump 也给 2 GB,避免解析过程中频繁 GC。

直接带文件路径作为参数可以跳过打开对话框,适合把常用 dump 做成快捷方式。如果没有带参数,也可以在界面里走打开菜单选文件,效果一样。

4.2 第一屏看什么:按大小排序的类直方图与实例数

解析完成后第一屏是类列表,默认按某种顺序排列,第一件事是改成按占用大小降序。排在最前面的几类,含义大致可以归类:

排行靠前的类常见含义下一步看什么
byte[]/char[]缓冲区、大字符串、序列化中间结果看实例数和平均大小,小而多往往是字符串,大而少往往是缓冲
java.util.HashMap$Node哈希表条目顺着找它所属的 Map,再看这个 Map 被谁持有
java.lang.String业务数据、缓存键、日志拼接残留看是否有大量等值长字符串
自定义 DTO / 实体类业务对象实例数是否和某个业务量成正比且只增不减
java.lang.ref.Reference子类弱引用、软引用队列队列积压说明引用清理逻辑卡住了

这里有个非常重要的判断习惯:先看同一类在两次 dump 之间的实例数增量,而不是单次 dump 里的绝对大小。第 6 章会给出具体做法。单份 dump 里byte[]占 70% 完全可能是正常现象——任何做了 IO 缓冲、序列化、压缩的服务都这样,把它当泄漏是最常见的误判。

类直方图里还有一个常被忽略的维度:平均实例大小。同样是byte[]占 2 GB,如果是 20 万个实例、平均 10 KB,看起来像是某种批量传输的缓冲;如果是 20 个实例、平均 100 MB,那更像是几个巨型缓冲区或者一次性读进来的大文件。这两种情况的排查方向完全不同。

4.3 从实例到引用链:判断「谁在持有」

锁定嫌疑类之后,下一步是展开实例。选中某个类,从右键菜单进实例列表(不同构建版本措辞略有差异,通常叫查看实例或列出对象),列表里能看到每个实例的浅大小和对象标识。

选一个体积最大的实例,接着找引用链。工具的引用链视图会从当前对象一路向上追溯到 GC Root,中间每一跳都标出是通过哪个字段引用的。读这条链的顺序是从下往上:最下面是你关心的对象,最上面是根。

根的类型决定了问题的性质,值得单独说清楚:

  • 根是静态字段:说明是某个类的静态容器在无限累积,典型如自建的本地缓存、注册表、监听器列表。这类最容易修,加个上限或者换成带淘汰的容器就行。
  • 根是线程栈上的局部变量:说明某个正在执行的请求持有它。如果这个线程卡住了(比如等一个永不返回的远程调用),它持有的整棵对象树就都回收不掉。这种情况要去查线程状态,而不是查集合。
  • 根是JNI 全局引用或本地方法:Java 侧看不到完整的持有关系,引用链会断在某个 native 帧上。这是工具的能力边界,只能配合 native 侧的内存排查手段。
  • 根是类加载器:常见于热部署、动态脚本、插件化框架,旧的类加载器回收不掉,连带它加载的所有类元数据和静态字段一起泄漏。这类问题在类直方图里表现为某个自定义类加载器下的类实例数异常稳定地只增不减。

读链的时候不要只看第一条。工具通常给出若干条候选路径,最短的那条未必是真正的持有者,因为可能存在多条路径共享同一个对象。我的习惯是把两三条都读一遍,找出那个「本不该存在」的引用——比如一个已经下线的功能模块还在往全局注册表里塞数据。

4.4 保留集与支配关系:区分「大」和「漏」

浅大小和保留大小的差别,是新手最容易混淆的地方。一个持有 10 万个元素的ArrayList,它自身的浅大小只有几十字节(对象头加一个数组引用),真正的大头在背后的Object[]和每个元素上。保留大小才是「删掉它实际能省多少」。

在工具里计算保留大小通常是独立的一步操作,耗时和堆大小成正比,大 dump 上要等几分钟。要算之前先确认第 3 章里采集参数带上了prepwalk,否则算出来的数会偏小甚至为零。

这里有个实用技巧:不要对整个堆算保留集,先在类直方图里筛出前 20 个嫌疑类,再对这几个类的实例逐个算。全量计算在 2 GB 以上的 dump 上动辄十几分钟,而且大概率你只用得上其中几个数字。

判断「大」和「漏」的分界线是增长性,不是绝对值。一个 500 MB 的本地缓存如果稳定在这个水位,那是设计如此;一个 200 MB 的集合如果每重启一次就多 50 MB 且从不下降,那才是要修的东西。这也是为什么单份 dump 永远不够——第 6 章会把两次 dump 对比做成一个可重复的流程。

5. 避坑:打开失败、算不准、看走眼的五个排查点

工具本身不复杂,出问题的地方高度集中在几类:文件坏了、内存不够、版本不对、参数少写、结论下早了。每一条都按现象、原因、解决三段写,遇到对得上的直接照做。

5.1 打开阶段的三类失败

现象一:加载进度走到一大半直接退出,控制台里能看到java.lang.OutOfMemoryError: Java heap space。

原因很简单,工具进程自己的堆不够。它要把整份 dump 的对象图读进内存并建索引,需要的量级远大于文件本身。解决办法就是回到 4.1 的估算脚本,把-Xmx提到文件体积的 4 倍左右,同时确认分析机的物理内存真的够——如果物理内存不够,JVM 会去吃交换分区,表现为界面长时间无响应,比直接报错更折磨人。判断依据是看分析机的free -h和vmstat,出现大量 si/so 就说明已经在换页了。

现象二:提示无效转储,或者打开之后左侧树是空的、只有根节点。

原因通常是文件格式不对或者文件被截断。格式不对指的是把 HPROF 当成了 PHD,回到 2.1 用head -c 128 | strings看一眼就能确认。文件被截断更隐蔽:dump 写到一半时磁盘满了,虚拟机会留下一个体积不小但结构不完整的文件。这种情况在ls -lh上看不出来,只能靠「文件体积与-Xmx的比例是否合理」来间接判断,以及确认落盘时段磁盘余量。处置办法只有一个:清出空间重新采集,不要在坏文件上浪费时间。

现象三:在较新版本的 JDK 上启动,报NoClassDefFoundError或者干脆没有任何窗口出现。

这个工具构建年代久远,依赖的一些库在新版 JDK 的默认发行版里已经不再提供。最省事的处置是装一个 JDK 8 专供它使用,通过JAVA_HOME或者直接写全路径来调用,不影响机器上其他服务用新版本。不要试图靠一堆模块导出参数去凑,收益极低。

5.2 分析阶段的两类误判

现象四:类直方图里byte[]或char[]排第一,占比超过 60%,于是直接判定「有内存泄漏」,改了一通缓冲区大小,问题依旧。

原因是把「占用大」当成了「泄漏」。任何做网络 IO、序列化、压缩、加解密的服务,堆里都必然有大量字节数组,这是工作集,不是泄漏。要区分两者,必须看增量:隔一段时间抓两份 dump,比较这个类的实例数是否在业务量没有明显变化的情况下持续上涨。另一个辅助判断是看这些数组的平均大小是否异常——如果出现了几十个上百 MB 的巨型数组,那更可能是某处一次性把大文件读进了内存,属于设计问题而非泄漏。

现象五:引用链追到一半就断了,或者保留大小算出来是 0,看不出谁在持有。

两个可能的原因。一是采集时没加prepwalk,对象图没有完整遍历,工具在解析时遇到指向未记录对象的引用只能放弃,表现为断链。二是对象确实是被 JNI 全局引用或 native 代码持有的,这部分关系不在 Java 堆里,工具看不到,属于能力边界而非缺陷。第一种情况回到第 3 章重新配参数采集;第二种情况需要换思路,从 native 内存的角度去查,比如看进程的 RSS 与堆使用量之间的差额是否持续扩大。

还有一类容易被误判的情形:引用链的终点是类加载器。很多人看到这条链就以为是类加载器泄漏,急着去查热部署配置。实际上下钻一层往往会发现,是某个类加载器下的静态字段持有了业务集合,问题在业务代码里,类加载器只是恰好充当了根的载体。读链要多读一跳,别停在第一眼看到的那个节点上。

6. 例行化:两次 dump 做差,把一次性排查变成体检

单份 dump 只能告诉你「现在堆里有什么」,两份 dump 的差值才能告诉你「什么在涨」。这个差别在实战里是决定性的,因为绝大多数疑似泄漏的类,在单份快照里都长得人畜无害。

我的做法是在怀疑对象上做一个小时间窗采样:间隔十到十五分钟抓三份,用jmap -histo:live先看趋势,确认某一类的实例数在单调上涨之后,再抓完整 dump 做深挖。-histo便宜、快、几乎不影响业务,适合做前置筛查:

#!/bin/bash # 每隔一段时间抓一份完整 dump,命名带序号,方便回来做差 PID=$1 OUT=${2:-/data/dump} GAP=${3:-600} for i in 1 2 3; do # 抓之前先记录一次类分布,成本低,可以和 dump 互相印证 jmap -histo:live "$PID" | head -40 >> "$OUT/histo-$i.log" jcmd "$PID" GC.heap_dump "$OUT/seq-$i-$(date +%H%M%S).phd" || break echo "第 $i 份完成于 $(date '+%H:%M:%S')" sleep "$GAP" done

参数说明:PID是被测进程;OUT是落盘目录,务必确认磁盘余量够放三份,大堆上三份就是好几个 GB;GAP单位是秒,默认 600 秒。jcmd GC.heap_dump每次都会造成一次明显停顿,所以这个脚本只适合在测试环境或者业务低谷期跑,生产上请把间隔拉长并配合限流。|| break是为了在某次采集失败时立刻停下,避免后面拿到的都是坏文件。

拿到三份之后,回到工具的类直方图,把三份里同一类的实例数抄下来做差。判断标准很朴素:业务量平稳的前提下,某类的实例数在三份里单调递增、且增量与业务请求量不成比例,基本就可以定性了。反过来,如果实例数在第二份和第三份之间回落了,那只是正常的流量波动,别再往下查了——这一步能挡掉相当一部分无效排查。

顺带说一个提高效率的习惯:把jmap -histo:live的输出按字节数排序截取前 20 行,做成一个固定的小脚本放在跳板机上。养成「怀疑内存问题先跑一遍 histo」的肌肉记忆之后,很多问题在抓 dump 之前就有方向了,能省下大量拷贝和分析的时间。

注意:所有采集动作都要在变更窗口内做,并记录采集时刻。三份 dump 的时间戳是后面做差的前提,没有时间戳的 dump 等于没有。

血泪经验是有一回在生产高峰直接跑采集脚本,三份 dump 把磁盘写满,连带日志组件一起挂了,排查变成了事故。从那以后我每次做内存体检,都会先df -h看一眼余量,再手动确认一遍采集脚本里的间隔和份数,然后才敢按回车。希望这些内容能帮到你,下次再拿到一份 .phd 的时候,不用从零开始猜。

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

返回列表