排查 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时,会自动把当时的堆状态写入指定路径。如果不配HeapDumpPath,JDK 会在进程的工作目录下生成一个类似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。如果传true,JVM 会先做一次 Full GC 再抓存活对象,这对生产环境伤害很大;如果传false,抓的是当前完整堆,虽然文件更大,但不额外触发 GC,对线上更友好。我实际写这种触发器的时候,通常还会记录一个“上次生成时间”,防止内存水位在阈值附近抖动时连续生成几十个 dump 文件把磁盘打满。触发条件里也可以加上持续时间判断,比如“堆占用连续 30 秒超过 85%”才抓,而不是一瞬间超了就去抓。3. 手动生成:运行期间按需快照自动参数适合固定场景,但日常排查里更多时候是“现在内存有点不对劲,我想立刻看一眼”。这种情况下需要手动生成。3.1 jmap:经典命令但别瞎用jmap是 JDK 自带的老牌工具,最常用的两条命令:# 抓完整堆 jmap -dump:format=b,file=/tmp/app-$(date +%s).hprof <pid> # 只抓存活对象 jmap -dump:live,format=b,file=/tmp/app-live.hprof <pid>第一眼看上去很简单,但有几个点坑了很多新手。-dump:live会先触发一次 Full GC,再抓存活对象。如果你在一台堆内存很大、业务量很高的机器上执行,这条命令本身就可能造成长时间停顿,线上事故就是这么来的。除非你非常清楚自己在做什么,否则不要在生产环境用live参数,直接用完整 dump。完整 dump 的文件更大、对象更多,但不会主动触发 GC。分析的时候慢一点没关系,不一定非要“只保留活对象”。另外jmap需要和 Java 进程同一个系统账号权限,sudo -u要弄对,不然会报Permission denied。3.2 jcmd:JDK 里的更优雅选择JDK 8 以后,官方其实更推荐用jcmd,因为它能做的事情更多,而且命令风格统一。生成堆转储的命令是:jcmd <pid> GC.heap_dump /tmp/app.hprof如果需要抓完整堆,可以明确加-all:jcmd <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 2>&1 & 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 'NR==2 {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 这几个阶段分别把机制建好,内存问题出现的时候,你就不是靠猜,而是有证据直接说话。
排查 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时,会自动把当时的堆状态写入指定路径。如果不配HeapDumpPath,JDK 会在进程的工作目录下生成一个类似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。如果传true,JVM 会先做一次 Full GC 再抓存活对象,这对生产环境伤害很大;如果传false,抓的是当前完整堆,虽然文件更大,但不额外触发 GC,对线上更友好。我实际写这种触发器的时候,通常还会记录一个“上次生成时间”,防止内存水位在阈值附近抖动时连续生成几十个 dump 文件把磁盘打满。触发条件里也可以加上持续时间判断,比如“堆占用连续 30 秒超过 85%”才抓,而不是一瞬间超了就去抓。3. 手动生成:运行期间按需快照自动参数适合固定场景,但日常排查里更多时候是“现在内存有点不对劲,我想立刻看一眼”。这种情况下需要手动生成。3.1 jmap:经典命令但别瞎用jmap是 JDK 自带的老牌工具,最常用的两条命令:# 抓完整堆 jmap -dump:format=b,file=/tmp/app-$(date +%s).hprof <pid> # 只抓存活对象 jmap -dump:live,format=b,file=/tmp/app-live.hprof <pid>第一眼看上去很简单,但有几个点坑了很多新手。-dump:live会先触发一次 Full GC,再抓存活对象。如果你在一台堆内存很大、业务量很高的机器上执行,这条命令本身就可能造成长时间停顿,线上事故就是这么来的。除非你非常清楚自己在做什么,否则不要在生产环境用live参数,直接用完整 dump。完整 dump 的文件更大、对象更多,但不会主动触发 GC。分析的时候慢一点没关系,不一定非要“只保留活对象”。另外jmap需要和 Java 进程同一个系统账号权限,sudo -u要弄对,不然会报Permission denied。3.2 jcmd:JDK 里的更优雅选择JDK 8 以后,官方其实更推荐用jcmd,因为它能做的事情更多,而且命令风格统一。生成堆转储的命令是:jcmd <pid> GC.heap_dump /tmp/app.hprof如果需要抓完整堆,可以明确加-all:jcmd <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 2>&1 & 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 'NR==2 {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 这几个阶段分别把机制建好,内存问题出现的时候,你就不是靠猜,而是有证据直接说话。