《Java 虚拟机原理与性能调优》7.1 堆转储与支配树:从 hprof 到保留大小

堆转储不是截一张内存快照就完事。本节用 500 个 64 KB 数组实测 jcmd GC.heap_dump 生成 37 MB hprof,讲清 shallow size、retained size 与支配树,给出 GC Root 路径的读法、MAT 操作步骤和 libraryd 的 dump 落盘策略。

本节目标:把「内存涨了」变成一份可分析的 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 的操作步骤是固定的:

  1. File → Open Heap Dump,选择 heap.hprof。首次打开会建索引,文件大时这一步本身要几分钟。
  2. 先看 Histogram,按 Retained Heap 降序排,而不是按 Objects 或 Shallow Heap。
  3. 找到可疑类(例如 [B 或 java.util.HashMap),右键 Merge Shortest Paths to GC Roots,在弹出框里勾选 exclude weak/soft/phantom references,先排除弱引用造成的假象。
  4. 要看单个对象的持有链,右键对象 → Path to GC Roots → exclude all phantom/weak/soft references。
  5. 要看「删掉谁能回收一大片」,打开 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 内存泄漏定位:增长曲线、直方图与引用链 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发