我先把话说在前面:如果你是做Java服务端或者中间件维护的,jmap这个名字你一定不陌生。但真正到了线上出问题的那一刻,很多人脑子里能想起来的只有一条“jmap -dump:format=b,file=xxx.hprof ”,导出来一个几百MB甚至几GB的文件,然后盯着文件发呆,不知道下一步该干嘛。这篇文章不打算炒冷饭,而是把jmap拆开揉碎,讲清楚它在“查内存问题”这件事里到底是什么角色,同一条命令在不同场景下该怎么用,以及我在实际排查中遇到的那些不写进官方文档的坑。
先说清楚jmap能干什么,简而言之三件事:第一,查看整个堆的配置和实时使用情况;第二,按类统计堆里的对象数量和占用空间;第三,把当前堆内存完整导出成一个映像文件,也就是堆转储(heap dump)。这三个能力对应完全不同的排查阶段,很多人只记住了第三个,却忽略了前两个在排查效率上的价值。这篇文章适合正在学JVM调优的开发者、被线上OOM折磨过的运维、以及所有需要在出问题时“留证据”的人。
1. 使用jmap之前,先搞清楚你要解决哪一类问题
1.1 jmap到底是一个什么性质的命令
jmap不是调优工具,它更像一个法医工具。它不会帮你自动定位到某一行代码有问题,它做的是把JVM进程在某一瞬间的内存状态拍下来交给你。理解这一点非常重要,因为它的所有选项都是为了回答“内存里到底发生了什么”这个问题的,而不是为了直接给出结论。
我见过不少人拿着jmap的dump结果去问别人“这代表什么”,这其实是用错了工具。jmap导出的是一个数据文件,真正的分析要借助其他工具来完成。但这不妨碍jmap成为整个排查链路上最关键的一环——没有它,后面的一切分析都是巧妇难为无米之炊。
1.2 三个常见排查场景,对应jmap的不同用法
场景一:老年代持续增长,YGC频率越来越快。这时候你需要的不是dump整个堆,而是先用jmap -heap <pid>看一眼当前堆的分区使用率,再用jmap -histo <pid>看看哪类对象占用的空间最多。这两步是轻量级的,对线上服务的影响很小,却能在几分钟内告诉你“该往哪个方向查”。
场景二:内存直接飙升到接近OOM,服务已经出现严重卡顿。这时候需要的是jmap -dump:format=b,file=heap.hprof <pid>,把整个堆内存完整地导出到磁盘。这个操作会在JVM上产生明显的停顿,但对于一个已经快要OOM的进程来说,留住现场比保持响应更重要。
场景三:进程反复OOM崩溃,重启之后又恢复。这种时候,你不能等崩了再想办法,而是要在JVM参数里加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump,让JVM在OOM发生前自动生成堆转储文件。你甚至不需要手动执行jmap。
我每次接到线上内存问题的工单,第一步都是先确认是上面哪一种场景,再决定要不要跑jmap、跑哪一个选项。盲目地dump整个堆,既慢又容易让服务彻底瘫掉。
2. jmap三个主力选项:-heap看总览,-histo圈重点,-dump留铁证
2.1 -heap:给整个堆拍一张配置和使用率的快照
jmap -heap <pid>输出的是JVM堆的配置参数和当前各区域的使用情况。这里面有几个我每次都会重点关注的字段。
第一部分是Heap Configuration,也就是堆的初始化参数,比如MaxHeapSize(最大堆)、NewSize(新生代初始大小)、MaxNewSize(新生代最大大小)、NewRatio(老年代和新生代的比例)、SurvivorRatio(Eden区和Survivor区的比例)。NewRatio默认是2,意味着老年代占整个堆的2/3,新生代占1/3。这个比例对吞吐量和GC频率都有直接影响,如果老年代频繁打满,而新生代还有很大的余量,那说明业务对象的生命周期偏长,参数可能需要调整。
第二部分是Heap Usage,显示Eden区、两个Survivor区、老年代各自当前用了多少、容量是多少、使用率百分比。这里有个容易误判的地方:Eden区使用率高并不一定是坏事,因为每轮Minor GC之后Eden区会清空;反而老年代的使用率一旦超过70%甚至80%,就需要警惕了,说明对象大量进入老年代,可能是有对象长期持有了不该持有的引用。
jmap -heap输出的其实是JVM某个时刻的瞬时状态,仅仅看一次没有太大意义。我在实际使用中的习惯是用脚本每隔十分钟采集一次,把输出里的关键数字(老年代使用率、元空间使用率)存下来,对比两三个时间点的变化趋势。如果老年代使用率线性上升,就算现在还不到报警线,也已经能预见几小时后必然触发Full GC。
2.2 -histo:从类的维度揪出数量可疑的对象
jmap -histo <pid>输出一张表格,按对象数量或者占用字节数排序。默认是按实例个数排序,但实际排查时我更常关注字节数那一列。命令格式通常是jmap -histo <pid> | head -30,只看前几十行就够了,因为真正的内存大头几乎都排在最前面。
表格里的类名有几种很常见的缩写形式,稍不注意就会看懵:
| 类名 | 含义 |
|---|---|
| [B | byte数组 |
| [C | char数组 |
| [I | int数组 |
| [Ljava.lang.Object; | Object对象数组 |
| java.lang.String | 字符串对象本身 |
拿到这张表之后怎么判断?我有一套自己的经验规则。如果排在最前面的全是byte[]和char[],先看它们的数量和单位大小是否合理,很多情况下字符串处理类的场景就是会大量产生这种数据,不一定有问题。如果某个业务类、连接池类、缓存类对象实例数量高达几十万个,每个实例占几千字节,那几乎可以确定它们是被意外地长期持有。比如一个自定义的缓存Map没有设置过期时间,里面的Entry对象就会越堆越多。
jmap -histo还有一个容易被忽略的版本,就是jmap -histo:live <pid>。带:live的选项会先触发一次Full GC,再统计存活对象。这在本地环境验证“哪些对象真正存活”的时候很有用,但在线上环境请慎重——一次Full GC带来的停顿对高流量服务来说可能直接导致大量请求超时。我的建议是线上分析优先用不带live的版本,配合分析工具里的过滤功能排除不可达对象。
2.3 -dump:format=b:把整个堆完整复制一份留底
jmap -dump:format=b,file=/data/dump/heap.hprof <pid>是大家最熟悉的写法,这里有两个参数务必记住。format=b表示输出二进制格式的hprof文件,这也是后续MAT、jhat等工具唯一能识别的标准格式;如果漏了这个参数,在某些JDK版本上会输出文本格式,文件体积巨大且无法直接用MAT分析,等同于白导了一次。file=指定导出路径,这个看似不起眼的参数在线上却经常把人坑了。
首先是路径所在磁盘的剩余空间。一个4GB堆的进程,dump出来的文件通常在2GB到4GB之间,具体取决于堆里实际存活对象的密度。如果磁盘空间不够,导出到一半就会失败,而且不会留下任何可用的部分文件。其次是路径的目录权限,JVM进程如果是用普通用户启动的,dump到某个root-only的目录会直接报Permission denied。我在生产环境会提前创建一个专门的dump目录,权限设置好,磁盘空间用独立挂载盘,避免和日志盘抢空间。
dump命令执行之后,JVM需要把所有堆内存冻结住再写入磁盘,这个过程称为Stop-The-World。堆越大,冻结时间越长。一个8GB的堆,导出过程可能让服务卡顿几十秒到几分钟不等,具体取决于磁盘写入速度。这一点我放在后面专门讲,因为它直接决定了你该在什么时机执行dump。
新版JDK其实已经逐渐在弱化jmap的地位,JDK 8开始官方推荐的替代工具是jcmd,命令对应是jcmd <pid> GC.heap_dump /data/dump/heap.hprof。jcmd的优势是它不需要区分工具版本和进程版本的匹配问题,而且以后新特性基本都往jcmd上放。但老JDK环境依然到处是jmap,该用还得用。
3. 拿到hprof文件之后:从jhat初查到MAT找到真正持有者
3.1 老版本JDK的jhat与新版本JDK的替代思路
很多人dump完之后就懵了,文件有了,但怎么打开?最原始的工具是JDK自带的jhat,命令格式是jhat heap.hprof,启动之后会监听本机7000端口,浏览器访问http://localhost:7000就能看到一堆HTML页面。jhat本质上是把一个巨大的hprof文件转换为可浏览的对象直方图,它可以告诉你哪些对象实例数量最多、哪些类占用空间最大。但是它存在两个明显问题:第一从未针对大文件做过性能优化,几百MB的hprof加载起来非常吃力,几个GB的文件基本没法用;第二从JDK 9开始jhat已经被官方移除,只有JDK 8及以下版本还自带。
如果你不得不先用命令行工具做快速验证,我建议用jhsdb jmap --heap --pid <pid>来看堆概览,或者直接把hprof文件拖到分析工具里。现在主流的分析工具是Eclipse MAT,它也是我在实际项目里用得最多的。面对几个GB的堆转储,MAT能在可控时间内完成解析,并且提供完整的排查路径。
3.2 MAT的核心三件套:直方图、支配树、泄漏报告
MAT打开hprof文件之后,有几个固定的入口:Histogram、Dominator Tree、Leak Suspects Report。这三样东西解决不同层面的问题,但真正的排查顺序通常是从直方图开始。
直方图就是类级别的对象统计,跟jmap -histo的表格类似,但更加灵活——你可以按类名过滤,可以对某个类右键选择“List objects -> with outgoing references”查看它引用到了谁。这步操作平时很少有人讲,但恰恰是最关键的一步:它能告诉你某个对象到底是被谁持有,以及它持有了哪些对象。比如你看到有个Hashmap$Entry实例数高达几百万,点开它的outgoing引用,就能看到这些Entry的key和value分别是什么类型,从而判断这到底是一个正常的业务缓存还是无脑往Map里塞数据导致的泄漏。
Dominator Tree是MAT最核心的视图,中文叫支配树。它把堆里的对象按照“谁支配谁”的关系组织成一棵树。一个对象A支配对象B,意思是所有从GC Roots到B的路径都必须经过A,换句话说,A是B的“顶头上司”,只要A活着,B就一定会被连带持有。在实际排查中,支配树里最顶层的几个节点往往就是内存泄漏的真凶——一个巨大的HashMap、一个静态集合、一个ThreadLocal相关的对象,它们自身看起来不大,但下面挂着成片的对象树。顺着支配树一路往下看,你就能精准定位到那个不该长期存活的“根持有者”。
Leak Suspects Report是MAT的自动分析结果,它会基于支配树,自动找出几个最可疑的“大对象持有者”,并给出简要说明。这个东西适合作为排查的起点,但不能全信,因为它只是基于支配关系做启发式猜测,有时指向的并不是真正的元凶。我的做法是:先看泄漏报告,圈定三五条线索;再用支配树验证和深挖;最后用直方图反查业务类定位到具体代码。
3.3 一次完整实战:某订单服务的OOM排查链路
说一个我实际经手过的场景。某服务运行一段时间后老年代持续上涨,业务侧表现为每隔半小时就出现一次长时间卡顿,日志里大量GC超时告警。当时我做了一个非常简单的三步操作:
第一步,jmap -histo <pid> | head -40,发现排在前面的是大量自定义OrderEntity对象和一个自定义缓存类CacheWrapper$Node。这两个类在系统里本不应该同时出现这么多实例。OrderEntity的实例总数有80多万,这个数字放在一个日订单量几万的服务里完全不合理,几乎可以断定是某种缓存或者队列里的对象堆积了。
第二步,jmap -dump:format=b,file=/data/dump/order-heap.hprof <pid>,把现场完整导出来。这时候服务已经比较不稳定了,但还不到OOM的边缘,所以我挑了一个业务低峰期在凌晨执行。导出用了大约三分钟,文件将近6GB。
第三步,用MAT打开这个文件。进入支配树之后,第一层就看到了那个CacheWrapper$Node,它下面挂了40多万个OrderEntity。再对其中一个Node右键查看GC Roots路径,发现它被一个静态Map持有,而这个Map的初始化代码是在某个“全局缓存管理器”里。到了这一步,问题基本就清楚了:缓存管理器把每次订单操作的结果都放进了静态Map,但没有设置过期时间,也没有做数量上限控制。后续修复很简单,给Map加上淘汰策略就行,但如果没有dump文件,靠猜代码,这个问题的定位至少要多花一周时间。
4. 线上执行jmap必须绕开的四个坑:先停一停再动手
4.1 大堆dump造成的停顿:不只是“慢一下”的问题
很多人对jmap -dump的认知是“导个文件嘛,最多卡一两秒”。这个认知在堆比较小的场景下勉强成立,但到了生产环境的大堆场景,就是事故的开端。
JVM在导出堆时需要把整个堆的内存状态冻结,然后复制出去。这里的关键不是“导出数据量”,而是需要变量在某一瞬间保持一致——它要把所有线程暂停,确保堆里的对象引用关系不发生变化,然后遍历整个堆把对象写入文件。堆越大,遍历和序列化越久;磁盘写入越慢,整个STW时间越拉越长。一个24GB堆的服务,导一次文件可能就是上百秒,这在线上是不可接受的。
我在执行这类操作之前一定会先评估三件事:当前堆有多大、服务对停顿的容忍度是多少、有没有业务低峰窗口可以用。如果确实没有低峰期,我会把jmap -histo和jstat的趋势数据作为主要分析依据,先不动dump,等OOM自动触发或者找到能短时摘流量的窗口再导。千万不要在白天高峰期为了“留证据”把核心服务直接STW到秒级。
4.2 :live并不是救星:线上慎用Full GC触发
刚接触jmap的人很容易把-histo:live和-dump:live当成“更精准”的选项,毕竟只统计存活对象,看起来更干净。但这两个选项背后的实现机制是:执行一次Full GC之后再统计或导出。
这就有两个问题。第一,Full GC本身是线上大忌,它会触发完整的Stop-The-World,比dump的冻结时间可能更长,尤其老年代碎片化严重的时候,一次Full GC可能就拉垮整个服务。第二,Full GC之后再dump,你丢掉了一个非常重要的信息——不可达对象。很多内存问题恰恰需要看那些“即将被回收但还没被回收”的对象长什么样。你在Full GC之后看到的是“干净”的真实存活对象,但排查问题的现场已经被你亲手破坏了。
我的习惯是:线上分析一律不带live。如果觉得不可达对象干扰判断,用MAT打开文件之后可以通过过滤条件单独查看Unreachable Objects,把两者分开来看。
4.3 把出口放对位置:磁盘目录与文件命名
这条是老生常谈,但每次复盘线上dump失败案例都躲不开这个坑。有一次某服务的dump导到一半失败了,原因是根目录磁盘余量不足,而当时日志文件的盘还有很大的富余。原因很简单,jmap的file参数直接写了/tmp/heap.hprof,根目录满了自然写不进去。所以在生产环境,我会在系统初始化的时候就规划好一个独立的dump目录,比如/data/dump,挂独立的磁盘分区,目录权限与JVM进程运行用户保持一致。
文件命名也值得讲究。我见过无数名为heap.hprof或者dump.hprof的文件被反复覆盖,到最后都不知道是哪一天、哪个进程导出的。建议统一成主机名-应用名-pid-日期时间.hprof这种格式,比如prod-order-03-12345-202502121030.hprof。配合清理策略,比如保留5天的dump文件自动删除,既不会把磁盘撑爆,也能在后续排查时按时间线快速找到对应的现场。
4.4 OOM之前自动留证:让JVM自己生成堆转储
手动执行jmap有一个天然的劣势:你人不在现场,或者你发现服务出问题的时候已经晚了,进程都崩了。更稳妥的做法是提前给JVM打好预防针,在启动参数里加上两行:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump这样JVM在抛出OutOfMemoryError之前,会先自动生成一份堆转储文件。这个机制的价值在于,你不需要任何人在线操作,不需要现场实时介入,就能保住崩溃前那一瞬间的内存证据。文件默认生成在启动目录,所以最好显式指定HeapDumpPath,固定到一个磁盘空间充足的位置。
这里还有个容易被忽略的细节:OOM发生时堆里可能已经塞满了对象,生成的hprof文件往往比平时大得多,可能是最大堆的好几倍。如果HeapDumpPath指向的磁盘不足,JVM可能因为无法写文件而直接把进程干掉,导致连崩溃前最后的现场都丢了。所以HeapDumpPath的位置一定要放在独立分区,并且有容灾空间。
5. 把jmap固化进日常排查流程:一条能直接复制的证据链
5.1 手里备一套顺手的脚本,关键时刻不慌
线上排查最忌讳临时翻命令文档,手忙脚乱。我会在每台机器上放一个简单的脚本,包含三个命令的组合,按需调用。
#!/bin/bash # jmaptools.sh pid [action] PID=$1 ACTION=${2:-status} TS=$(date +%Y%m%d%H%M%S) DUMP_DIR=${DUMP_DIR:-/data/dump} mkdir -p "$DUMP_DIR" case "$ACTION" in status) jmap -heap "$PID" ;; histo) jmap -histo "$PID" | head -50 ;; dump) FREE=$(df -P "$DUMP_DIR" | awk 'NR==2 {print $4}') if [ "$FREE" -lt 1048576 ]; then echo "dump dir free space too low" exit 1 fi jmap -dump:format=b,file="$DUMP_DIR/heap-$PID-$TS.hprof" "$PID" echo "dump saved to $DUMP_DIR/heap-$PID-$TS.hprof" ;; *) echo "usage: jmaptools.sh <pid> [status|histo|dump]" ;; esac脚本本身很简单,核心价值是统一了三个命令的调用方式,并且在一开始就检查了磁盘剩余空间。你可以在没有压力的时候先把这套脚本放好,真到出问题的时候省去敲命令和查空间的环节。
5.2 证据链思维:jmap只是整条链路的起点
我现在每次排查前端服务的线上问题时,都会刻意培养一种“证据链”的思维方式。拉取的第一个证据是jstat -gcutil的历史数据,用来判断GC频率和代际使用率的趋势;第二个证据是jmap -histo的当前快照,用来快速圈定嫌疑对象;如果趋势和直方图都指向某个可疑对象,再决定是否导出完整dump。每一步都在回答不同的问题,也都在为下一步铺路。
这样做还有一个好处:万一后续需要复盘,整套证据都能按照时间线还原出当时的判断逻辑。不是拿到一个hprof文件就往里狂点鼠标,而是每一步都清楚“我在找什么、为什么找、找到了意味着什么”。
我在实际排查中还有个个人习惯,就是每次执行jmap -dump之前,都会顺手先跑一次jmap -histo,哪怕这两者隔了一分钟。因为dump出来的文件里包含了整个堆的对象,而直方图恰好告诉你从哪个类入口去切入最有价值。没有这个前置信息,直接拿6GB文件去MAT里浏览,十分低效。
5.3 一个值得养成的习惯:先定位再动手
最后说点个人经验。jmap不是万能的,它解决的是“内存现场保存”和“粗略定位”的问题,真正的根因经常要结合源代码分析才能落地。但无数案例证明,存好dump文件的人赢在了起跑线上——至少他还有分析的机会。线上问题最可怕的状态不是“有问题”,而是“问题复现不了、现场也丢了”。
我建议你从今天开始就把jmap的基本功练到肌肉记忆:拿到PID,先jmap -heap看配置和整体水位,再jmap -histo看哪类对象占据大头,有必要再jmap -dump留现场。这三个动作之间不需要思考太久,但能够帮助你在下一次线上事故里,比身边所有人更快地锁定方向。