1. 堆内存诊断到底在诊断什么
线上服务跑着跑着突然变慢,接口响应从几十毫秒飙到几秒,监控面板上内存曲线一路向上不回头,这时候你登录机器,第一反应往往是“是不是堆爆了”。但“堆爆了”这三个字太笼统,它可能是老年代被慢速泄漏的对象一点点填满,也可能是某次批量任务瞬间创建了海量临时对象把新生代冲垮,还可能是元空间加载了太多动态生成的类。不同的病因对应完全不同的处理方式,而jmap就是帮你把“堆爆了”这个模糊判断拆解成具体证据的那把手术刀。
jmap是 JDK 自带的一个命令行工具,全称 Java Memory Map,主要用来查看 Java 进程的堆内存使用概况、生成堆转储快照(heap dump)、查看类加载统计和 finalizer 队列信息。它不需要你提前在代码里埋点,也不需要重启服务,只要目标进程还在运行,并且你有对应的操作系统权限,就能直接连上去取数据。这个特性决定了它在故障排查现场的地位——当服务已经出问题、你又不能随便重启的时候,jmap往往是少数几个能立刻用上的手段。
适合读这篇内容的人,我大致分三类。第一类是后端开发,平时写业务代码多,对 JVM 调优只停留在“知道有这回事”的阶段,遇到线上内存告警需要自己上手排查。第二类是运维和 SRE,日常要处理各种服务异常,需要一套可复现的诊断流程来判断该扩容、该重启还是该找开发改代码。第三类是对 JVM 底层有兴趣、想通过实际工具去理解堆内存结构的同学。不管哪一类,只要你能登录到目标机器、能执行命令行,这篇里的步骤都可以直接照着做。
需要先建立一个认知:jmap是诊断工具,不是治疗工具。它能告诉你堆里现在有什么、哪些对象占了大头、类加载有没有异常增长,但它不会帮你把内存降下来。真正的解决动作——改代码、调参数、重启——都在拿到jmap的输出之后。所以用jmap的核心目标只有一个:在尽可能短的时间内,拿到足够定位根因的证据,然后决定下一步动作。理解这一点,后面的所有操作才有方向。
2. 动手之前必须搞清楚的几件事
2.1 工具从哪来、版本怎么选
jmap就在 JDK 的 bin 目录下,和java、javac、jstack放在一起。你只要装了 JDK 而不是单独的 JRE,就能找到它。这里有个容易被忽略的点:执行jmap的 JDK 版本最好和目标进程运行的 JDK 版本一致。我遇到过用高版本 JDK 的jmap去连低版本 JVM 进程,结果报 “Protocol not supported” 的情况。原因是jmap和 JVM 之间通过 attach 机制通信,不同大版本之间的协议可能有差异。
如果你不确定目标进程用的是哪个 JDK,可以先执行jcmd -l或者ps -ef | grep java看启动命令里的路径。拿到路径后,用那个路径下的jmap去操作,基本不会出兼容问题。这个细节看起来小,但在紧急排查时能帮你省掉“工具连不上”的无效折腾。
2.2 权限与用户一致性
jmap连接目标 JVM 依赖操作系统的进程间通信机制。在 Linux 上,它通常通过/tmp下的 attach socket 或者进程的临时目录来建立连接。这意味着两件事:第一,执行jmap的用户必须和目标 Java 进程的启动用户一致,或者有足够的权限(比如 root);第二,如果目标进程的临时目录被清理或者权限异常,attach 会失败。
实际场景里最常见的问题是:服务用某个专用账号启动,你图省事用 root 去执行jmap,有时候能成功,有时候会报 “Unable to open socket file”。稳妥的做法是su到目标用户再执行,或者用sudo -u 目标用户 jmap ...。另外,容器化环境里如果/tmp被挂载成了只读或者空间极小,attach 也可能出问题,这时候需要检查容器的临时目录配置。
2.3 执行时机对服务的影响
这是必须重点强调的一点:jmap在生成堆转储(-dump)时会触发 STW(Stop-The-World)。也就是说,整个 JVM 会暂停所有应用线程,等堆快照写完才恢复。暂停时间取决于堆的大小和磁盘写入速度。堆越大、磁盘越慢,暂停越久。我见过几个 G 的堆 dump 到机械盘上,服务停了十几秒的情况,对于高并发接口来说这十几秒可能就是一次故障。
所以执行jmap -dump的时机要慎重。如果服务是多实例部署,最好先从负载均衡里摘掉一台再操作,避免影响线上流量。如果只有单实例且不能停,那就要评估暂停带来的影响和继续带着内存问题跑下去哪个风险更大。相比之下,jmap -heap和jmap -histo的暂停时间要短得多,-histo甚至可以通过:live参数控制是否只统计存活对象,日常巡检用这两个更安全。
提示:生产环境执行
jmap -dump前,先确认目标目录有足够磁盘空间。堆转储文件大小通常接近堆的实际使用量,如果堆用了 4G,dump 文件可能就有 3G 到 4G。磁盘写满会引发更严重的连锁问题。
3. 核心命令逐个拆解与实操
3.1 先看堆的整体布局:jmap -heap
排查内存问题,第一步不是急着 dump,而是先看堆的整体使用情况。jmap -heap <pid>会输出堆的配置和使用概况,包括新生代、老年代各自用了多少、还剩多少、用什么垃圾回收器。这个命令暂停时间很短,适合作为第一手信息。
输出里我重点看几个数字。一是used和capacity的比例,如果老年代 used 已经接近 capacity,说明离 Full GC 不远了,甚至可能已经在频繁 Full GC。二是新生代的 eden、from、to 三个区的比例,如果 eden 区使用率长期很高,说明对象创建速率快,可能是正常的业务高峰,也可能是某段代码在疯狂创建临时对象。三是看用的哪种 GC,不同 GC 的堆布局和参数含义不一样,比如 G1 的 region 划分和传统的分代布局在输出上就有区别。
这里有个经验:单看一次-heap的输出意义有限,因为内存是动态变化的。更有价值的做法是隔几分钟连续执行几次,观察 used 的增长趋势。如果老年代 used 在几次采样之间持续上升且不回落,那基本可以判断有对象在被持续引用、无法回收,泄漏的可能性就很大了。
3.2 找出谁在占内存:jmap -histo
jmap -histo <pid>会按对象占用内存大小排序列出所有类的实例数量和总字节数。这是定位“谁在吃内存”最直接的手段。输出格式是几列:序号、实例数、字节数、类名。默认按字节数从大到小排,所以排在最前面的就是占用最多的类。
我一般会加:live参数,也就是jmap -histo:live <pid>。区别在于,不带live会把所有对象都统计进去,包括那些已经垃圾但还没被回收的;带live会先触发一次 Full GC,只统计存活对象。带live的结果更能反映真实的内存占用,但代价是那次 Full GC 会带来暂停。所以如果是紧急排查、服务已经很卡,我可能先用不带live的快速看一眼,再决定要不要用live精确统计。
看-histo输出有个技巧:不要只盯着第一名的类。排在前面的如果是[B(byte 数组)、[C(char 数组)、java.lang.String这类,往往说明有大量字符串或字节数据在内存里,可能是缓存没设上限、日志缓冲堆积、或者某次查询返回了超大结果集。如果是自定义的业务类排在前列,那就要去看这个类在哪里被创建、被谁持有。[I是 int 数组,[Ljava.lang.Object;是 Object 数组,这些基础类型的数组占用高,通常意味着某个集合类里塞了太多元素。
3.3 拿到完整现场:jmap -dump
当-histo告诉你“有大量某类对象”,但你还需要知道“这些对象被谁引用、为什么没被回收”时,就需要生成堆转储文件,用 MAT 或 JProfiler 这类工具做引用链分析。命令是jmap -dump:format=b,file=路径 <pid>,format=b表示二进制格式,这是标准格式,分析工具都认。
如果只想 dump 存活对象,加live参数:jmap -dump:live,format=b,file=路径 <pid>。加live会先做一次 Full GC,dump 出来的文件更小、更聚焦于真正泄漏的对象。但同样,这次 Full GC 有暂停代价。在内存已经吃紧的情况下,Full GC 可能耗时很长,要有心理准备。
dump 文件的命名我习惯带上时间戳和进程标识,比如heap-20240115-1430-pid12345.hprof。因为排查往往要做多次 dump 对比,没有清晰命名的话,过两天自己都分不清哪个文件对应哪个时间点。dump 完成后,文件通常会比较大,传输到本地分析时注意压缩,.hprof文件压缩率一般不错。
3.4 类加载与 finalizer:容易被忽视的两个视角
jmap -clstats <pid>输出类加载器的统计信息,包括每个类加载器加载了多少个类、占了多少内存。这个命令在排查元空间(Metaspace)溢出或者类加载器泄漏时特别有用。如果看到某个类加载器加载的类数量异常多,或者有大量重复的类加载器实例,那可能是动态代理、热部署、脚本引擎之类的机制在不停创建新的类加载器而没有释放。
jmap -finalizerinfo <pid>则查看等待 finalizer 线程执行finalize()方法的对象。如果一个对象重写了finalize()方法,它的回收会走一条特殊路径,先进入 finalizer 队列等待处理。如果这个队列堆积了很多对象,说明 finalizer 线程处理不过来,这些对象会一直占着内存。虽然现在不推荐用finalize(),但老代码里可能还有,排查时看一眼没坏处。
4. 一套完整的线上排查流程
4.1 从告警到定位的决策路径
假设你收到一条内存使用率超过阈值的告警,接下来怎么走?我梳理了一套自己常用的顺序,你可以参考。
第一步,确认现象。登录机器,用jmap -heap <pid>看老年代使用率。如果老年代 used 超过 90% 且多次采样不下降,进入下一步。如果老年代还好,但新生代频繁满,那可能是对象创建速率问题,方向不同。
第二步,快速定位占用大头。执行jmap -histo <pid> | head -30,看排在前面的类。这一步不触发 Full GC,对服务影响小。如果看到明显的业务类或者异常大的数组,记下来。
第三步,判断是否需要精确 dump。如果-histo已经能看出问题类,且你能从代码层面推断出泄漏点,可能不需要 dump 就能改。如果看不出来,或者需要确认引用链,就执行jmap -dump:live,format=b,file=... <pid>。执行前评估暂停影响,必要时先摘流量。
第四步,分析 dump 文件。用 MAT 打开,看 Dominator Tree 找出占用最大的对象,再看 Path to GC Roots 确认是谁在持有引用。这一步是定位根因的关键,-histo只能告诉你“什么多”,dump 分析才能告诉你“为什么多”。
第五步,验证与修复。根据分析结果改代码或调参数,然后观察内存曲线是否恢复正常。如果条件允许,可以在修复前后各做一次 dump 对比,确认问题对象确实减少了。
4.2 一个模拟的排查记录
为了让你有更具体的感受,我描述一个模拟场景。某个服务运行几天后老年代持续增长,Full GC 后也降不下来。执行jmap -histo:live后,发现排在前面的除了正常的框架类,还有一个模拟的com.example.cache.LocalCacheEntry类,实例数达到几十万,占用几百 MB。
接着做 dump 分析,在 MAT 里看这个类的引用链,发现它被一个静态的ConcurrentHashMap持有,而这个 map 的 key 是用户会话标识,value 是缓存条目。代码里写入缓存时没有设置过期和容量上限,导致会话越多、缓存条目越多,而且旧会话的条目永远不清理。这就是典型的无界缓存泄漏。
修复方式很简单,换成带容量上限和过期策略的缓存实现,比如基于 LRU 或者定时清理。修复后再次jmap -histo:live,那个类的实例数降到了正常范围。整个过程从告警到定位大概半小时,其中 dump 和分析占了大部分时间,但如果没有jmap提供的第一手数据,可能要在代码里盲目找很久。
4.3 参数与输出对照表
下面这张表整理了几个常用命令的用途、影响和适用场景,方便你快速查阅。
| 命令 | 主要用途 | 是否触发 STW | 适用场景 |
|---|---|---|---|
jmap -heap <pid> | 查看堆配置与使用概况 | 极短 | 初步判断内存压力 |
jmap -histo <pid> | 按类统计对象数量与大小 | 极短 | 快速定位占用大头 |
jmap -histo:live <pid> | 仅统计存活对象 | 触发 Full GC | 精确统计真实占用 |
jmap -dump:format=b,file=... <pid> | 生成完整堆快照 | 触发 STW | 需要引用链分析时 |
jmap -dump:live,format=b,file=... <pid> | 生成存活对象快照 | 触发 Full GC + STW | 聚焦泄漏对象 |
jmap -clstats <pid> | 类加载器统计 | 极短 | 排查元空间问题 |
jmap -finalizerinfo <pid> | finalizer 队列 | 极短 | 排查 finalize 堆积 |
5. 踩过的坑与常见问题速查
5.1 attach 失败的各种原因
“Unable to open socket file” 是jmap最常见的报错。原因通常有几个:执行用户与目标进程用户不一致;目标进程的临时目录权限不对;容器环境下/tmp不可写;或者目标 JVM 启动时禁用了 attach 机制。排查顺序是先确认用户,再检查/tmp/hsperfdata_<用户>目录是否存在且可访问。如果目录不存在,可能是 JVM 启动参数里加了-XX:+PerfDisableSharedMem之类的选项,这时候 attach 会走另一条路径,需要具体看。
还有一种情况是目标进程已经处于僵死状态,虽然进程还在,但 JVM 内部线程已经无法响应 attach 请求。这种时候jmap会卡住或者超时。遇到这种情况,可能只能通过kill -3生成线程转储,或者直接重启了。
5.2 dump 文件过大导致的分析困难
堆大的时候,dump 文件可能几个 G,用 MAT 打开需要配置足够的堆内存,否则 MAT 自己就 OOM 了。MAT 的MemoryAnalyzer.ini里可以调-Xmx,一般设成 dump 文件大小的 1.5 倍左右比较稳妥。另外,如果只是看直方图和支配树,MAT 有“Leak Suspects”报告可以快速给出可疑点,不一定要全量解析。
如果 dump 文件传输困难,可以在目标机器上用jhat简单看一下,但jhat性能一般,适合小堆。更好的做法是在目标机器上装一个轻量的分析工具,或者把 dump 文件压缩后传出来。压缩时用gzip通常能压到原大小的三分之一左右。
5.3 常见问题速查表
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| attach 报 socket 错误 | 用户不一致、临时目录权限 | 切换用户、检查 /tmp |
| dump 时服务卡顿严重 | 堆大、磁盘慢、STW 时间长 | 摘流量、换 SSD、评估必要性 |
| histo 第一名是 [B 或 [C | 大量字符串或字节数据 | 查缓存、日志、大结果集 |
| 老年代 used 持续上升 | 对象被长期引用无法回收 | dump 分析引用链 |
| 元空间持续增长 | 类加载器泄漏、动态类过多 | clstats 查看类加载器 |
| finalizer 队列堆积 | finalize 方法执行慢 | 检查重写 finalize 的类 |
| dump 文件打不开 | 文件损坏或格式不对 | 确认 format=b、重新 dump |
5.4 几个我自己的实操心得
第一,养成先 histo 后 dump 的习惯。很多人一上来就 dump,结果文件几个 G,分析半天。其实-histo几秒钟就能告诉你方向,大部分问题在这一步就能缩小范围。只有需要引用链的时候才上 dump。
第二,dump 前先记录时间点和现象。我习惯在文件名和笔记里记下执行时的内存使用率、GC 情况、服务表现。过几天回头看,这些上下文比 dump 文件本身还重要,因为 dump 只是一个瞬间的快照,脱离上下文很难解读。
第三,不要迷信 live 参数。-histo:live和-dump:live都会触发 Full GC,如果服务本身已经在频繁 Full GC,再加一次可能雪上加霜。紧急情况下先用不带 live 的版本快速看,等服务稍微缓过来或者摘了流量再做精确统计。
第四,对比比单次快照更有价值。内存问题往往是渐进的,单次 dump 只能看到“现在有什么”,两次间隔一段时间的 dump 对比才能看出“什么在增长”。如果条件允许,在问题复现过程中做两到三次 dump,分析增长最快的对象,定位效率会高很多。
第五,注意 dump 文件的清理。堆转储文件很大,做完分析后如果不及时清理,可能把磁盘占满,引发新的故障。我一般会在分析完成后把 dump 文件归档到专门的存储,本地只保留必要的分析报告。
6. 从诊断到解决的衔接
拿到jmap的输出只是第一步,真正解决问题还需要结合代码和配置。比如-histo显示某个缓存类占用高,你要去看这个缓存的写入逻辑、过期策略、容量限制。-clstats显示类加载器多,你要去看哪里在动态生成类,是不是有热部署或者脚本执行没有清理。dump 分析出的引用链,最终要落到具体的代码行,才能改。
另外,jmap的输出也可以和 GC 日志互相印证。如果 GC 日志显示 Full GC 后老年代回收效果很差,而jmap -heap显示老年代 used 很高,两者结合就能确认是存活对象太多,而不是 GC 参数问题。如果 GC 日志显示回收效果不错但内存还是涨,那可能是堆外内存或者元空间的问题,方向就要转到jmap -clstats或者操作系统的内存查看工具上。
我在实际使用中的体会是,jmap最大的价值在于它把“内存有问题”这个模糊的判断,变成了可以量化、可以对比、可以追溯的具体数据。有了这些数据,和团队沟通、向上汇报、写故障报告都有据可依,而不是凭感觉说“可能是内存泄漏”。这种从模糊到具体的转化能力,才是一个诊断工具真正的意义。