本节目标:把「内存涨了」变成一份可分析的 hprof,并分清 shallow size、retained size 与支配树各自回答什么问题。
适用版本:JDK 25(Temurin 25.0.5+8);hprof 1.0.2;MAT 只写操作步骤,本机未安装。
承接的问题
第 6 章用 GC 日志回答了「回收了多少」,但没有回答「谁没被回收」。这两件事在排障时经常被混在一起:老年代占用高,可能是流量真的上来了,也可能是某个 static Map 只进不出。前者调容量,后者改代码,走错方向会浪费一整轮扩容。
堆转储(heap dump)是把「谁没被回收」变成文件的手段。它有三个绕不开的问题:
- 文件多大,写多久,会不会把磁盘写满。
- 打开之后先看什么,直方图里的
bytes为什么不能直接当成「这个类占了多少内存」。 - 从 GC Root 到泄漏对象的那条路径,怎么在工具里一步步点出来。
这一节用一个可控进程把整条链路跑通:先分配 500 个 64 KB 数组并持有引用,再抓直方图、抓 hprof、释放引用观察回收,最后把这些动作落到 libraryd 的启动参数和落盘目录上。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1,压缩 oops,默认 MaxHeapSize 8 GB,G1 Region 4 MB。
一次可复现的堆转储
实验类只做三件事:分配、持有、睡着等人来抓。
import java.util.ArrayList;
import java.util.List;
public class HeapDumpDemo {
static final int CHUNKS = 500;
static final int CHUNK_BYTES = 64 * 1024;
public static void main(String[] args) throws Exception {
List<byte[]> holders = new ArrayList<>(CHUNKS);
for (int i = 0; i < CHUNKS; i++) {
byte[] chunk = new byte[CHUNK_BYTES];
chunk[0] = (byte) i;
chunk[CHUNK_BYTES - 1] = (byte) i;
holders.add(chunk);
}
System.out.println("pid=" + ProcessHandle.current().pid()
+ " chunks=" + holders.size()
+ " payloadBytes=" + (long) CHUNKS * CHUNK_BYTES);
Thread.sleep(600_000);
}
}
编译并启动,然后从另一个终端采集:
javac --release 25 -d out HeapDumpDemo.java
java -cp out HeapDumpDemo
本机输出:
pid=71651 chunks=500 payloadBytes=32768000
heapMax=8589934592
先看堆的整体情况,再看类分布:
jcmd 71651 GC.heap_info
jcmd 71651 GC.class_histogram | head -10
本机输出(省略地址区间):
garbage-first heap total reserved 8388608K, committed 528384K, used 37700K
region size 4096K, 9 young (36864K), 0 survivors (0K)
num #instances #bytes class name (module)
-------------------------------------------------------
1: 9072 33163504 [B (java.base@25.0.5)
2: 67 436376 [Ljdk.internal.vm.FillerElement; (java.base@25.0.5)
3: 1724 228064 java.lang.Class (java.base@25.0.5)
4: 8494 203856 java.lang.String (java.base@25.0.5)
5: 1097 138744 [Ljava.lang.Object; (java.base@25.0.5)
[B 有 9072 个实例、33163504 字节。我们自己分配的 500 个数组贡献了 32768000 字节,剩下的 395504 字节是 JVM 启动过程里的零碎数组。GC.class_histogram 只给类的汇总,不区分「谁持有」,所以下一步才是 dump。
jcmd 71651 GC.heap_dump /tmp/vol4-d/heapdump/heap.hprof
本机输出:
Dumping heap to /tmp/vol4-d/heapdump/heap.hprof ...
Heap dump file created [37209966 bytes in 0.051 secs]
37,209,966 字节,约 35.5 MiB,比 32 MB 的载荷多出约 5 MB,差额来自 hprof 的记录头、类元数据、字符串和对象图边。文件开头是固定的 hprof 标识:
file heap.hprof
head -c 64 heap.hprof | xxd | head -4
本机输出(节选):
heap.hprof: Java HPROF dump, created Wed Oct 28 03:22:30 2026
00000000: 4a41 5641 2050 524f 4649 4c45 2031 2e30 JAVA PROFILE 1.0
00000010: 2e32 0000 0000 0800 0001 a180 87de ea01 .2..............
JAVA PROFILE 1.0.2 后面是版本号、标识符长度和时间戳,再往后是一条条记录。hprof 不是文本日志,不能靠 grep 找泄漏,必须用能解析对象图的工具。
jcmd GC.heap_dump 默认会先请求一次 Full GC,只保留可达对象;加 -all 才会把不可达对象也写进去。它的 Impact 标注是 High,因为这次 Full GC 和写盘都会让应用停一下。本机 37 MB 的堆只用了 0.051 秒,堆到几 GB 时就是秒级甚至更久。
还有一个常被忽略的选项是压缩:
jcmd 71651 GC.heap_dump -gz=1 -overwrite /tmp/vol4-d/heapdump/heap.hprof.gz
本机输出:
Heap dump file created [1543001 bytes in 0.075 secs]
1.5 MB,压缩比接近 24:1。这个比例不能当成普遍规律:实验里的数组大部分是零,gzip 很容易压;真实业务对象里字符串、数字、指针混杂,压缩比通常低得多。压缩的代价是 CPU 和更长的写盘时间,适合磁盘紧张、事后分析的场景。
shallow、retained 与支配树
直方图里的 bytes 是类的所有实例的 shallow size 之和,也就是对象自身占的字节,不含它引用的对象。一个 64 KB 的 byte[],shallow size 就是 64 KB 加数组头;一个 ArrayList 的 shallow size 只有几十字节,但它引用的 500 个数组可能有 32 MB。
retained size 回答的是另一个问题:如果把这个对象删掉,能回收多少字节。它等于对象自身加上「只有通过它才能到达」的所有对象。支配树(dominator tree)就是用来算这个「只有通过它」的关系的:从 GC Root 出发,如果所有到 B 的路径都必须经过 A,那么 A 支配 B。
用一个最小实验观察这种「删掉引用就整片回收」的行为:
import java.util.ArrayList;
import java.util.List;
public class RetainDemo {
static long used() {
Runtime rt = Runtime.getRuntime();
return rt.totalMemory() - rt.freeMemory();
}
static void gcAndSettle() throws Exception {
for (int i = 0; i < 4; i++) {
System.gc();
Thread.sleep(120);
}
}
public static void main(String[] args) throws Exception {
gcAndSettle();
long baseline = used();
List<byte[]> holders = new ArrayList<>(500);
for (int i = 0; i < 500; i++) {
holders.add(new byte[64 * 1024]);
}
long loaded = used();
System.out.println("baselineUsed=" + baseline / 1024 + "K");
System.out.println("afterAlloc=" + loaded / 1024 + "K");
System.out.println("delta=" + (loaded - baseline) / 1024 + "K");
holders = null;
gcAndSettle();
long released = used();
System.out.println("afterDropRef=" + released / 1024 + "K");
System.out.println("reclaimed=" + (loaded - released) / 1024 + "K");
}
}
本机输出(单次运行,未做统计):
baselineUsed=1266K
afterAlloc=35218K
delta=33951K
afterDropRef=1298K
reclaimed=33920K
分配后多了 33951 KB,把 holders 置空并 GC 后回收了 33920 KB。ArrayList 对象本身只有几十字节,但它的 retained size 约等于整个 33 MB,因为那 500 个数组只被它支配。如果代码里还有第二个引用指向其中某个数组,那个数组就不会被回收,ArrayList 的 retained size 会相应变小。
三种口径放在一起看:
| 口径 | 回答的问题 | 本例中的值 |
|---|---|---|
| shallow size | 这个对象自身多大 | 单个 byte[] 约 64 KB |
| retained size | 删掉它能回收多少 | ArrayList 约 33 MB |
| 类直方图 bytes | 这个类的实例合计多大 | [B 33163504 字节 |
直方图的 33163504 字节既不等于某个对象的 retained size,也不告诉你谁是支配者。它能回答「哪个类在涨」,不能回答「为什么没被回收」。
从 GC Root 到泄漏对象
hprof 里的对象图是一张有向图,起点是一组 GC Root。常见的根有:
- 虚拟机栈上局部变量表里的引用。
- 类的静态字段(
static)。 - 常量池里的引用、
String常量。 - JNI 全局引用、活动线程、同步监视器。
- JVM 内部的结构,例如系统类加载器。
一个 static final Map 之所以危险,是因为它本身就是 GC Root 的直接子节点:LeakDemo.CACHE 是静态字段,Map 里的 Node.value 指向 byte[],整条链上没有任何一环会松开。第 5.1 节讲过引用类型,这里要补的是「怎么在工具里把这条链找出来」。
本机没有安装 MAT(Eclipse Memory Analyzer),所以不提供 MAT 的截图和数字。用 MAT 打开 hprof 的操作步骤是固定的:
File → Open Heap Dump,选择heap.hprof。首次打开会建索引,文件大时这一步本身要几分钟。- 先看
Histogram,按Retained Heap降序排,而不是按Objects或Shallow Heap。 - 找到可疑类(例如
[B或java.util.HashMap),右键Merge Shortest Paths to GC Roots,在弹出框里勾选exclude weak/soft/phantom references,先排除弱引用造成的假象。 - 要看单个对象的持有链,右键对象 →
Path to GC Roots→exclude all phantom/weak/soft references。 - 要看「删掉谁能回收一大片」,打开
Dominator Tree,从根往下找 retained heap 最大的分支。
MAT 的 Path to GC Roots 输出形如「Thread → ... → CACHE → HashMap$Node → byte[]」。不装 MAT 时,也可以用 jcmd GC.class_histogram -all 看包含不可达对象的直方图,但它仍然不给引用链:
jcmd 71651 GC.class_histogram -all | head -6
本机输出:
num #instances #bytes class name (module)
-------------------------------------------------------
1: 9072 33163504 [B (java.base@25.0.5)
2: 67 436376 [Ljdk.internal.vm.FillerElement; (java.base@25.0.5)
-all 只是把不可达对象也纳入统计,不能替代对象图分析。真正的引用链要么用 MAT,要么自己解析 hprof,没有第三条捷径。
dump 的代价与落盘策略
堆转储是一个「高影响」操作,生产环境要提前设计,而不是出事时临时敲命令。
第一,磁盘要留够。经验值是堆上限的 1.5 到 2 倍,压缩后可能小很多,但不能按压缩比做容量规划。dump 目录不要和数据库、日志放同一块盘,写满一块盘会连带把应用拖死。
第二,OOM 时自动落盘比人工抓更可靠:
java -Xmx2g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/lib/libraryd/dumps \
-XX:+ExitOnOutOfMemoryError \
-jar libraryd-app.jar
HeapDumpPath 指向一个目录时,JVM 会在里面生成 java_pid<pid>.hprof;目录必须存在且可写,否则 OOM 时只会多一条「Could not create heap dump」的告警。ExitOnOutOfMemoryError 让进程在 dump 后退出,交给编排系统重启,避免一个已经不可用的进程继续接流量。
第三,不要假设 jhsdb 随时可用。本机尝试用 jhsdb jmap --heap 读取运行中的进程:
jhsdb jmap --heap --pid 71651
本机输出(节选):
Attaching to process ID 71651, please wait...
ERROR: attach: task_for_pid(71651) failed: '(os/kern) failure' (5)
Error attaching to process: Can't attach to the process. Could be caused by an incorrect pid or lack of privileges.
macOS 的任务端口权限、容器里的 ptrace 限制、不同用户运行,都会让 attach 失败。jcmd 走的是 JVM 自带的 attach 机制,限制少一些,所以排障时优先用 jcmd。
第四,hprof 是完整的内存快照,里面可能有会员姓名、借阅记录、令牌。落盘目录权限要收紧,传输和归档要脱敏,保留份数和天数要写进运维约定,不能无限堆。
在 libraryd 里落地
libraryd 的启动参数不应该散落在部署脚本里。用一个 record 把「dump 放哪、OOM 后做什么」固化下来:
package com.plume.library.app;
import java.nio.file.Path;
import java.util.List;
public record HeapDumpSettings(Path dumpDir, boolean onOutOfMemory, boolean printClassHistogram) {
public static final HeapDumpSettings PRODUCTION =
new HeapDumpSettings(Path.of("/var/lib/libraryd/dumps"), true, false);
public List<String> jvmArgs() {
return List.of(
"-XX:+HeapDumpOnOutOfMemoryError",
"-XX:HeapDumpPath=" + dumpDir,
"-XX:+ExitOnOutOfMemoryError",
"-Xlog:gc*:file=" + dumpDir.resolve("gc.log") + ":time,uptime,level,tags"
);
}
}
这段代码不负责创建目录,也不负责轮转。目录由部署脚本用 install -d -m 700 建好,轮转由外部的 logrotate 或 sidecar 负责。把「参数生成」和「文件生命周期」分开,测试时可以把 dumpDir 换成临时目录,不会碰到生产路径。
第二个落点是 RuntimeProbe 之外的「现场采集脚本」。OOM 之后人工再抓已经晚了,脚本要做的是在进程还活着、但堆使用率连续超过阈值时抓一份轻量现场:
#!/bin/sh
set -eu
pid="$(jps -l | awk '/libraryd-app/ {print $1; exit}')"
[ -n "$pid" ] || exit 1
out="/var/lib/libraryd/dumps/manual-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$out"
jcmd "$pid" GC.class_histogram > "$out/histogram.txt"
jcmd "$pid" Thread.print > "$out/threads.txt"
jcmd "$pid" GC.heap_info > "$out/heap-info.txt"
先抓直方图和线程栈,确认「是不是堆问题、是不是某个线程卡住」,再决定要不要付出一次 Full GC 和几十秒停顿去抓完整 hprof。这个顺序能避免「一上来就 dump,结果发现是线程池打满」的浪费。
常见错误与边界
错误一:把直方图的 bytes 当成 retained size。 [B 的 33163504 字节是所有 byte[] 的 shallow 之和,不是某一个对象的保留大小。判断「删掉谁能回收最多」要看支配树,不是看类排行。
错误二:以为 dump 不暂停。 GC.heap_dump 默认先 Full GC,再在安全点写文件;堆越大,暂停越长。生产环境要避开高峰,或者先抓直方图缩小范围。
错误三:HeapDumpPath 指向不存在的目录。 JVM 不会自动创建目录,OOM 时只会打印告警,dump 不会生成。部署脚本要显式建目录并验证可写。
错误四:把 hprof 当普通日志传上网盘。 里面有完整对象内容,可能包含个人信息和凭证。传输前要脱敏,存储要加密,访问要审计。
边界:jhsdb 不是通用方案。 本机 attach 直接失败,容器里还受 ptrace_scope 和 PID 命名空间影响。能用 jcmd 就不要依赖 jhsdb。
边界:MAT 需要额外内存。 打开几 GB 的 hprof,MAT 自己也要几 GB 堆;在笔记本上分析生产 dump 经常先被 MAT 的 OOM 拦住。必要时在独立的分析机上做。
小结
jcmd GC.heap_dump默认先 Full GC 再写 hprof;本机 500 个 64 KB 数组生成 37,209,966 字节文件,耗时 0.051 秒。- 直方图的
bytes是类的 shallow 总和;retained size 要结合支配树算,本机ArrayList的 retained size 约 33 MB。 - GC Root 包括静态字段、线程栈、JNI 引用等;
static Map是泄漏最常见的根。 - MAT 的入口是 Histogram → Merge Shortest Paths to GC Roots → Dominator Tree;本机未安装 MAT,不提供其输出。
- OOM 自动落盘要同时配
HeapDumpOnOutOfMemoryError、HeapDumpPath和ExitOnOutOfMemoryError,目录要预先创建。 jhsdb在本机 attach 失败;排障优先用jcmd,不要把它当成必然可用的后备。
拿到了 dump,下一个问题是:增长曲线怎么判断「真泄漏」,直方图里哪个类在涨,引用链又该怎么读。下一节用 64 MB 堆把一个 static Map 逼到 OOM。
阅读导航:上一节:6.3 ZGC 与低延迟取舍:染色指针、读屏障与停顿 · 下一节:7.2 内存泄漏定位:增长曲线、直方图与引用链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。