
排查 JVM 内存问题时堆转储heap dump是最硬核的证据。它能把你程序在某个瞬间的堆内存状态完整拍下来哪些对象占了多少内存、被谁引用、链路是怎么串起来的一眼就能看到底。问题是堆转储不是乱拍就行Java 程序在启动阶段、稳定运行阶段、高并发阶段、OOM 崩溃阶段的内存画像完全不一样拍照的时机和方式差一点结论就可能完全不同。这篇文章我会把我在实际项目里用过的各种生成方式按运行阶段拆开讲清楚。适用对象是正在和内存泄漏、Full GC、OOM 打交道的 Java 开发也适合刚接触 JVM 排查的运维同学。1. 先搞清楚堆转储到底拍到了什么1.1 堆转储不是简单的内存统计很多同学第一次接触堆转储以为它就是一个“内存占用排行表”。实际上 .hprof 文件里装的信息远比这丰富加载的类、类的实例对象、数组、基础类型字段、对象引用关系、GC Roots 的起点、线程栈里引用的局部变量甚至每个对象所在的包和类加载器都能找出来。这么说吧普通的内存监控告诉你“现在堆用了 3GB”堆转储则能告诉你“这 3GB 是被谁用的、从哪条引用链到这里的”。排查内存泄漏时光知道内存涨没用必须顺着引用链条找到那个“握着大对象不放”的根。堆转储就是把现场固定下来让你可以离线把链条一段一段捋清楚。但也要注意堆转储不等于线程转储。它虽然会记录一些线程栈信息但核心是堆内存里的对象关系。如果想查死锁、线程池膨胀、CPU 飙高那该抓的是 thread dump不是 heap dump。1.2 不同运行阶段的内存画像差异我在实际排查里发现Java 应用的内存问题往往不是均匀出现的。不同阶段的内存画像差异非常大启动阶段类加载、Spring 容器初始化、各种连接池预热、缓存拉起都在抢内存。这个阶段常见的是“元空间压力大”或者“初始化逻辑里持有大量临时对象”堆转储可以帮你确认哪些类被重复加载、哪些静态容器在启动时就被塞了不该常驻的数据。稳定运行阶段正常情况下堆里主要是业务对象、缓存、线程池缓冲区和 IO 缓冲。这时候抓堆转储特别适合做基线方便以后问题发生时对比。高峰期阶段大量请求同时涌入业务对象、队列、会话数据会在短时间内堆积。此时抓堆转储能看到瞬时压力是发生在“请求堆积”还是“缓存膨胀”上。OOM/崩溃阶段内存已经被耗尽JVM 正在挣扎或已经准备退出。这个瞬间的堆转储往往是破案关键能直接看出来是泄漏还是“一次性峰值超出了设置”。只抓一次堆转储就相当于只拍了一张照片。如果恰好是深夜低峰期拍到的那可能什么都看不出来。这也是“按运行阶段生成堆转储”的核心逻辑在不同时间点反复采样才能发现内存变化的趋势而不是只守着最后一次 OOM。2. 用 JVM 参数让堆转储在正确时机自动落盘2.1 OOM 发生时的兜底参数最推荐先配上的就是 JVM 自带的 OOM 自动堆转储。只要在启动命令里加上java -Xmx4g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dumps/order-service.hprof \ -jar order-service.jar当 JVM 抛出OutOfMemoryError时会自动把当时的堆状态写入指定路径。如果不配HeapDumpPathJDK 会在进程的工作目录下生成一个类似java_pid12345.hprof的文件位置说老实话不太好找而且如果工作目录和程序目录空间不够dump 会直接失败。这个参数我建议所有线上 Java 服务都默认加上。它平时不产生任何开销只在 OOM 时生效。真正出问题时你至少有“最后一眼”的证据不会只有一行报错日志。另外可以搭配-XX:ExitOnOutOfMemoryError让 JVM 在 OOM 后直接退出配合容器健康检查自动重启至少能避免一个“半死不活”的进程继续拖着。但注意这会让服务中断是否开启要根据业务容忍度来定。2.2 Full GC 前后自动抓拍OOM 是终点但很多时候内存问题在 OOM 之前就以“频繁 Full GC”的形式表现出来了。这时候可以启用 JVM 的 Full GC 前后堆转储参数java -Xmx4g \ -XX:HeapDumpBeforeFullGC \ -XX:HeapDumpAfterFullGC \ -XX:HeapDumpPath/data/dumps/ \ -jar order-service.jar看到没这里HeapDumpPath写的是目录生成的文件名会自动带上进程号和前后缀避免互相覆盖。HeapDumpBeforeFullGC会抓这次 GC 之前堆变成什么样HeapDumpAfterFullGC会抓 GC 之后还有多少对象顽强存活。两份对照起来看特别有价值。如果 FGC 之后老年代占用率依然很高那基本说明某些对象无法被回收引用链必然有问题如果 FGC 之后占用能降下来但很快又涨上去那可能是请求峰值或者缓存刷新策略不对。但必须泼一盆冷水这类参数在部分垃圾收集器上有兼容性限制而且每次 Full GC 都会额外产生 dump 文件IO 和停顿影响叠加到线上服务上非常危险。我通常只建议在压测环境或者维护窗口临时开启问题定位完就关掉。生产环境一直开着等于给每个 Full GC 又加了一刀。2.3 用监控程序按内存水位触发 dump如果你不想等 OOM也不想依赖 Full GC 参数还可以在应用内部挂一个“水位触发”逻辑。核心思路是用 JMX 监控堆内存使用率超过阈值就调用诊断接口生成堆转储。import com.sun.management.HotSpotDiagnosticMXBean; import java.lang.management.ManagementFactory; import java.lang.management.MemoryMXBean; import java.lang.management.MemoryUsage; public class HeapDumpTrigger { public static void main(String[] args) throws Exception { MemoryMXBean memoryMXBean ManagementFactory.getMemoryMXBean(); MemoryUsage usage memoryMXBean.getHeapMemoryUsage(); double usedRatio (double) usage.getUsed() / usage.getMax(); if (usedRatio 0.85) { dumpHeap(/data/dumps/auto- System.currentTimeMillis() .hprof, false); } } private static void dumpHeap(String filePath, boolean live) throws Exception { HotSpotDiagnosticMXBean bean ManagementFactory.getPlatformMXBean( HotSpotDiagnosticMXBean.class, com.sun.management.HotSpotDiagnosticMXBean.class); bean.dumpHeap(filePath, live); } }注意dumpHeap的第二个参数live。如果传trueJVM 会先做一次 Full GC 再抓存活对象这对生产环境伤害很大如果传false抓的是当前完整堆虽然文件更大但不额外触发 GC对线上更友好。我实际写这种触发器的时候通常还会记录一个“上次生成时间”防止内存水位在阈值附近抖动时连续生成几十个 dump 文件把磁盘打满。触发条件里也可以加上持续时间判断比如“堆占用连续 30 秒超过 85%”才抓而不是一瞬间超了就去抓。3. 手动生成运行期间按需快照自动参数适合固定场景但日常排查里更多时候是“现在内存有点不对劲我想立刻看一眼”。这种情况下需要手动生成。3.1 jmap经典命令但别瞎用jmap是 JDK 自带的老牌工具最常用的两条命令# 抓完整堆 jmap -dump:formatb,file/tmp/app-$(date %s).hprof pid # 只抓存活对象 jmap -dump:live,formatb,file/tmp/app-live.hprof pid第一眼看上去很简单但有几个点坑了很多新手。-dump:live会先触发一次 Full GC再抓存活对象。如果你在一台堆内存很大、业务量很高的机器上执行这条命令本身就可能造成长时间停顿线上事故就是这么来的。除非你非常清楚自己在做什么否则不要在生产环境用live参数直接用完整 dump。完整 dump 的文件更大、对象更多但不会主动触发 GC。分析的时候慢一点没关系不一定非要“只保留活对象”。另外jmap需要和 Java 进程同一个系统账号权限sudo -u要弄对不然会报Permission denied。3.2 jcmdJDK 里的更优雅选择JDK 8 以后官方其实更推荐用jcmd因为它能做的事情更多而且命令风格统一。生成堆转储的命令是jcmd pid GC.heap_dump /tmp/app.hprof如果需要抓完整堆可以明确加-alljcmd pid GC.heap_dump -all /tmp/app.hprofjcmd的好处是不会像jmap那样把各种参数分散开输出信息也是结构化的。如果进程是通过-XX:DisableAttachMechanism启动的那jcmd和jmap都会失败这种情况只能通过 JVM 参数自动 dump 或 JMX 方式触发。有一点要注意jcmd的 dump 路径如果写相对路径是相对于 Java 进程的工作目录而不是你当前 shell 的目录。我吃过一次亏命令执行成功但找不到文件后来发现文件被写到了 Tomcat 的 bin 目录底下。4. 三个典型运行阶段的实操脚本4.1 启动完成后的基线 dump启动阶段抓堆转储最好等应用完成初始化后再抓否则堆里全是框架启动的临时对象参考意义不大。可以写一个启动脚本等健康检查接口通了再执行 dump#!/bin/bash JAR_PATH/opt/order-service/order-service.jar PID_FILE/tmp/order-service.pid nohup java -Xmx4g -jar $JAR_PATH /var/log/order-service.log 21 PID$! echo $PID $PID_FILE # 等待健康检查通过超时 120 秒 for i in $(seq 1 120); do if curl -sf http://127.0.0.1:8080/actuator/health; then break fi sleep 1 done # 刚起来立刻抓一份基线 dump jcmd $PID GC.heap_dump /data/dumps/baseline-startup.hprof这份基线 dump 特别适合用来对比“启动后某块静态缓存是不是被固定了”。有些内存问题不是运行时才出现的而是程序一启动就把大量数据加载到静态集合里等到运行一段时间后你看完整堆才恍然大悟哦原来从启动那一刻就已经有问题了。4.2 低峰期定时采样稳定运行阶段我习惯在每天的低峰期定时生成一份堆转储作为连续观察的采样点。比如每天凌晨两点做一次0 2 * * * /usr/bin/jcmd $(pgrep -f order-service.jar | head -n 1) GC.heap_dump /data/dumps/daily-$(date \%Y\%m\%d).hprof定时 dump 要注意两点。第一选择低峰期是因为 dump 过程大概率会造成 JVM 停顿哪怕只是瞬间在白天峰值时段做容易让接口出现毛刺。第二dump 文件通常很大几百 MB 甚至几个 GB连续存一个月磁盘就爆了脚本里可以顺手做清理find /data/dumps -name daily-*.hprof -mtime 7 -delete这份连续采样数据最有价值的地方不是单独看某一天而是看趋势。比如某服务的byte[]对象占比从一个星期前开始缓慢上升说明大概率有 IO 流没关闭而不是仅有“某天大对象暴增”这种偶发问题。4.3 高峰期触发快照高峰期想稳定抓到快照靠人来盯不现实。比较靠谱的方案是结合 2.3 里的水位触发器或者用一个独立的巡检脚本去查 GC 日志和 JMX 指标。举个例子某订单服务高峰期出现停顿GC 日志显示老年代占用率达到 95%。我们可以写一个简单巡检脚本在堆使用率超过阈值时主动 dump#!/bin/bash PID$(pgrep -f order-service.jar | head -n 1) USED$(jstat -gcutil $PID | awk NR2 {print $4}) if [ -n $USED ] [ $(echo $USED 90 | bc) -eq 1 ]; then TIME$(date %Y%m%d-%H%M%S) jcmd $PID GC.heap_dump /data/dumps/peak-$TIME.hprof fi这里jstat -gcutil第四列是老年代占用百分比超过 90 就说明快喘不过气了。这种快照抓到的瞬间堆里的对象构成往往能直接反映到底是“用户并发太高请求对象没来得及释放”还是“缓存不淘汰一直在堆积”。前者一般伴随大量临时业务对象后者通常能一眼看到某些集合的大小异常。5. 堆转储分析实战从文件到结论光有文件不行还要能从中提取出结论。这一节以一个常见的 OOM 排查过程为例讲完整分析思路。5.1 用 MAT 分析一次 OOM 的完整过程某后台订单服务突然 OOM启动日志里有我们配置的自动 dump 文件。先用内存分析工具打开 .hprof我一般用 Eclipse MAT。MAT 打开文件后会先展示“Leak Suspects”也就是系统认为最可疑的泄漏点。它会判断哪组对象占据了最大堆内存并给出从 GC Root 到该对象的引用链。某次案例里Leak Suspects 指向了一个ConcurrentHashMap它被项目里的OrderCacheManager持有。顺着 Dominator Tree 往下看Map 里挂了几十万个OrderInfo对象而且这些对象都是已完成状态的订单照理不应该继续留在缓存里。接下用 MAT 自带的 OQL 可以快速验证SELECT * FROM com.example.domain.OrderInfo o WHERE o.orderStatus COMPLETED查询结果里有大量对象且所有对象都被同一个缓存 Map 引用。结论很清楚缓存 load 进去之后缺少淘汰策略或者说是每次查询都往缓存里塞数据但从不清理。修代码的方向变成给缓存加过期时间、限制容量或者在订单完成后主动移除。分析到这一步才算是把一个堆转储变成了一个有用的结论。注意 MAT 本身也是个 Java 程序打开大 hprof 文件时需要给它配置大内存否则分析工具先内存溢出了场面就比较尴尬了。5.2 常见内存问题的对应特征表分析多了以后我会根据堆里的对象特征快速判断问题类型。整理成一张表给大家当参考堆转储主要特征常见原因下一步排查方向大量char[]/String对象日志侵权、JSON 序列化积压、大文本协议未释放查日志框架缓冲、业务日志输出频率大量byte[]对象IO 流未关闭、文件读缓存、网络传输缓冲泄漏检查流资源 try-with-resources 是否覆盖所有分支大量同一业务对象缓存未淘汰、查询结果被静态集合持有看引用链确定是谁持有集合大量java.lang.Thread线程池无界增长、线程没有回收配合 thread dump 判断线程栈大量重复的 Class 对象类加载器泄漏、频繁热部署检查自定义 ClassLoader、框架 reload 逻辑这张表不能直接套用所有场景但可以帮你快速锁定分析方向。特征判断完之后记得再回头用引用链验证别看到对象多就急着下结论。5.3 拿到 dump 后的注意事项堆转储里可能包含用户手机号、登录 token、订单详情等敏感数据。这一点在取文件、传文件、存分析结果时都要格外注意不该传到公共网盘的地方就放在本地离线分析分析完及时脱敏或删除。还有一点分析时不要只拿一份 dump。我见过太多人只看 OOM 前的那一份然后用它决定所有问题。如果条件允许把启动阶段、低峰期、高峰期、OOM 前这四份放在一起对比效果完全不一样。单独看 OOM 前只看到“内存满了”但不知道它是一夜之间满的还是运行三周慢慢满的这两种情况的处理策略完全不同。6. 常见问题与避坑清单6.1 生成过程中常踩的坑每个阶段生成堆转储都有自己的坑我整理了一份速查表常见问题现象解决方案磁盘空间不足dump 文件写一半失败或根本没生成先确认HeapDumpPath目录剩余空间尽量预留比堆大小多一倍的容量路径权限不对jcmd/jmap 报 Permission denied切换到启动 Java 进程的同一用户或检查目录写权限live 参数导致长时间停顿接口超时、GC 变长线上非必要不用-dump:live用完整 dumpFull GC 自动 dump 参数误开系统莫名卡顿磁盘被一堆 dump 塞满只在压测窗口临时开启定位完关掉容器内执行 attach 失败找不到进程或 jcmd 连不上进入容器后确认 PID namespace必要时从宿主机用对应工具探测敏感信息泄露dump 文件被转发了出现用户隐私数据限制文件权限本地分析不随手传聊天工具这些坑我在真实项目里基本都碰到过。每一条看起来都挺基础但线上环境复杂一个小问题可能直接导致“想抓的时候抓不到”。6.2 一些能直接抄走的经验最后分享几个我用下来最有价值的习惯不算什么高深技术但能省很多事。第一个习惯是在所有服务启动脚本里统一加上-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath并且把路径单独挂一块大磁盘别和系统盘混在一起。很多 OOM 恰恰发生在磁盘日志暴涨的场景如果 dump 路径和日志路径同盘很可能磁盘已经先满了。第二个习惯是给堆转储文件统一命名。文件名里带上服务名、阶段、时间戳比如order-service-peak-20250115-1430.hprof不要裸谢app.hprof。因为一份 dump 可能是几个 GB你不会想打开一堆文件挨个确认“这个到底是哪个服务哪个时间点抓的”。第三个习惯是抓完之后不要只存着要顺手记一下当天的指标堆大小、GC 次数、CPU、并发量。分析 dump 时如果有这些背景指标定位会快得多。比如两份 dump 的堆占用相差不大但 GC 次数差了很多那说明问题可能主要出在 GC 频率而不是堆大小上。我在实际排查里最大的体会是堆转储生成不是一键导出那么容易的事时机、参数、命令、存储、分析每环都得对得上。按启动、稳定、峰值、OOM 这几个阶段分别把机制建好内存问题出现的时候你就不是靠猜而是有证据直接说话。