上周二晚上十一点半,监控电话把我吵醒:mrds65批次服务走完一轮跑批后,内存曲线冲过堆上限,JVM直接抛了OutOfMemoryError: Java heap space。我当时打开 VisualVM 2.2,从进程里导出一份内存快照,花了半个多小时定位到一批被静态 Map 强引用的大对象——典型的缓存只写不清。这篇文章就把这套从报警到快照、从快照到根因的完整排查链路写出来,同时把 VisualVM 2.2 分析内存快照时容易踩的坑一并交代清楚。适合正在接触 OOM 问题、手头有堆转储但不知道从哪里下手的读者。
1. 分清OOM类型,再决定要不要先抓快照
不是所有 OOM 都适合立刻打开 VisualVM 抓内存快照。先花两分钟确认异常类型,比盲猜更高效。很多人一看到OutOfMemoryError就急着导 hprof,结果导出来几 GB 文件打开一看,全是生命周期极短的临时对象,对定位帮助不大。所以我习惯先根据异常信息把 OOM 分分类。
1.1 四类常见的“内存爆炸”与定位方向
JVM 的OutOfMemoryError有很多变体,对应完全不同的内存区域和排查路径。这里列一份我在实际工作中用得最多的对照表:
| 异常信息 | 本质 | 优先排查方向 |
|---|---|---|
| Java heap space | 堆内存不足以容纳新对象 | 抓 heap dump,分析对象直方图与引用链 |
| GC overhead limit exceeded | GC 反复全力回收,但每次回收后内存依然不足,回收效率低于 2% | 先看 GC 日志,再看堆转储 |
| Metaspace | 方法区/元空间耗尽,类元数据不断膨胀 | 统计类加载数量,排查动态代理与类加载器泄漏 |
| Direct buffer memory | 堆外直接内存耗尽 | 用 NMT(Native Memory Tracking)或排查 DirectByteBuffer 未释放 |
先说最常见的Java heap space。这类问题最适合用 VisualVM 的“堆 Dump”功能:把堆里所有对象导出来,按保留大小排序,看谁是真正的“大块头”。GC overhead limit exceeded本质也是堆空间长期处于耗尽边缘,GC 退化成了“空转”,所以定位思路仍然是堆转储,额外再加一份 GC 日志辅助判断。
Metaspace 和 Direct buffer memory 虽然也报 OOM,但它们在堆里往往看不出明显问题。Metaspace 膨胀通常和 CGLIB 动态代理、热部署类加载器泄漏有关;直接内存则需要打开 NMT 或看一下 DirectByteBuffer 的分配与释放。这两类场景里,VisualVM 能帮的忙有限,别把时间耗在错误的方向上。
比较实用的做法是看异常栈后缀:报Java heap space,就奔着堆转储去;报GC overhead limit exceeded,先看 GC 日志再决定要不要抓 dump;报Metaspace或Direct buffer memory,直接转去查类加载统计和本地内存追踪。方向对了,后面每一步才有意义。
1.2 VisualVM适合哪种现场,不适合哪种现场
我遇到过两类完全不同的 OOM 现场。第一类是慢性增长型:GC 日志里老年代占用率像台阶一样一节一节往上走,每次 Full GC 都能回收到一部分,但回收后的水位一次比一次高。这种现场最适合抓快照。第二类是突发分配型:压测或大促流量瞬间打进来,年轻代连续分配失败,Survivor 区根本接不住,堆瞬间被打满。这种现场如果也去抓快照,dump 里面大概率全是当时创建到一半就被打断的临时对象,反而干扰判断。
慢性增长型 OOM,说明堆里有一批对象被某个东西长期持有,既不会被 GC 回收,又不断新增。这基本就是“泄漏”或“有意缓存但没设上限”。分析内存快照时,我们找的正是这批“应该死掉却没死掉”的对象。突发分配型的核心问题则是“瞬时并发太高”或“堆上限设得太小”,优先看限流配置、堆参数和 GC 停顿,而不是逐个研究对象引用关系。
还有一个容易被忽略的细节:抓 dump 的行为本身会让应用停顿。JVM 要安全地导出快照,必须让所有执行线程到达安全点,再遍历整个对象图序列化。在有大量对象、高并发进程上,这个停顿可能持续几十秒甚至几分钟。所以生产环境做手动 dump 前,一定要先和业务方确认是否在低峰期,必要时先转移流量再操作。这也是为什么我一直强调:能自动 dump 绝不依赖人工手动抓。
2. 用VisualVM 2.2拿到一份能用的hprof
内存快照(heap dump)是一切分析的起点。很多人栽在第一步:要么 OOM 发生时根本没留下 dump,要么留下的 hprof 是损坏的。这里我把两种获取方式都捋一遍,顺便说说被忽略的配置细节。
2.1 启动参数自动dump,别等想起来才后悔
最靠谱的兜底方案是提前在 JVM 启动参数里加上自动导出。线上 Java 进程可以在启动脚本里配置:
-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/oom/配置很简单,但有三个细节值得注意。
第一,HeapDumpPath既可以写完整文件名,也可以只写目录。写目录的情况下,JVM 自动生成的文件名一般形如java_pid12345.hprof,其中 12345 是进程 PID。好处是多次 OOM 不会互相覆盖,文件名自带 PID 信息,便于和监控系统核对是哪个实例。
第二,磁盘预留。hprof 文件的大小约等于当前堆使用量,甚至可能略大于-Xmx。一个 4G 堆的进程,OOM 时留下的 hprof 至少 3GB 以上。如果日志分区只有 5GB,而服务又连续 OOM 两次,第二次 dump 很可能因为磁盘写满而失败。我在生产中习惯把HeapDumpPath单独指向一个至少两倍堆大小的目录。
第三,别只配参数不管目录权限。JVM 进程通常以服务账号运行,如果目标目录没写权限,OOM 时静默失败,你以为有 dump,其实什么都没有。这样的事故我见过不止一次,排查半天最后发现是权限问题,坑得很。
VisualVM 2.2 打开 hprof 前,先确认文件完整。最简单的方式是看文件头是否有 hprof 二进制格式的魔数——文件开头的字节就是JAVAPROFILE这几个字符的 ASCII 码。如果文件传输出问题,VisualVM 一般会直接报错而不是假装打开成功,遇到报错先检查文件本身,别急着怀疑工具坏了。
2.2 正在运行的进程,手动抓的界面操作与命令
不是所有 OOM 都会留下自动 dump。有些服务是别人部署的,没加任何参数;有些是刚开始内存异常飙升,你想在崩溃前先抓一份现场。这时就得手动抓。
VisualVM 2.2 界面操作很简单:左侧“应用程序”列表找到目标进程,右键,选择“堆 Dump”,等右下角进度条走完,dump 文件会自动出现在左侧“应用程序”下的“堆转储”节点里。双击就能打开分析视图。整个过程不需要任何命令行,适合对工具不熟的同学。
命令行方式则更快,用 jmap 直接导出:
jmap -dump:format=b,file=/data/logs/manual.hprof <pid>手动抓有两个我踩过的坑。第一个是“要不要先执行 Full GC 再抓”。如果服务还活着且能响应,尤其当你怀疑存在老年代缓慢增长时,我会先抓一份“现场 dump”,再执行一次jcmd <pid> GC.run触发 Full GC,然后间隔几分钟抓第二份“活对象 dump”。两个 dump 对照看,那些 GC 后依然存在的对象,基本上是泄漏或长期驻留的可疑对象。注意第一次 dump 一定要在 GC 之前抓,否则你看到的是被 GC 打扫过的表象,原始现场丢了。
第二个坑是抓 dump 的耗时。印象最深的一次,一个 12G 堆的 Java 服务,jmap 导出整整花了 40 多秒,期间所有请求全部阻塞。一定要在低峰期操作,而且把期望值摆正:这几十秒的停顿是工具原理决定的,不是命令卡死。
2.3 远程连接和快照文件管理
VisualVM 2.2 还支持远程连接。配置 JMX 后,可以在本地开发机上直接查看远程 Linux 实例,并远程执行堆 Dump。启动参数大致是这样的:
-Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false但说实话,跨网络远程抓大堆转储,体验并不好。VisualVM 远程导出时文件先落在目标进程所在机器,再由本地端拉取,链路长、速度慢、容易超时。我更推荐的做法是:在服务器上用 jmap 把 hprof 导到本地路径,再通过堡垒机或文件同步工具拉回本地,最后用 VisualVM 2.2 离线打开。这样快照文件完整可控,也不会长时间占用生产环境的 JMX 端口。
文件传输方面,hprof 动辄几个 GB,直接传很耗时。可以先用 tar 或 zip 压缩,hprof 的压缩率常年能到 60%~70%,节省不少时间。拷回本地后,解压再交给 VisualVM 打开,比在远程界面里硬等更快。
3. 堆转储的三层下钻:直方图、实例、引用链
拿到 hprof 之后,才是真正考验分析能力的地方。快照里对象动辄几千万个,直接一个个翻到天亮也翻不完。正确打开顺序是:先看全局直方图,锁定大对象所属的类;再看具体实例,确认对象的字段和内容;最后顺着引用链往上找,看看是谁一直拿着它不放。
3.1 打开快照后第一眼看什么
VisualVM 2.2 里双击 hprof 文件,会打开一个带多个页签的分析视图。“概览”页显示堆大小、类数量、实例总数等基本信息;“类”页签就是对象直方图,这里是我们第一个落点。
“类”页签默认按实例数排序,但这个排序方式对找问题帮助不大,你应该手动切换成按“大小”列排序。这个“大小”一般是指对象的 Shallow Size(浅大小),也就是对象本身占用的空间,不包括它引用的其他对象。如果某个类的浅大小已经排到前列,说明问题很直接——比如一个几 GB 的byte[]数组,本身就很可疑。
但更多时候,真正占地方的是对象之间的关系。VisualVM 2.2 的“类”页签里还有一列叫“保留”,表示 Retained Size(保留大小),含义是:如果这个类的所有这些实例都被回收,连带被它们独占持有的其他对象一起释放,总共能释放多少空间。这列更能体现“如果干掉这个类,能省多少堆”。锁定目标的基本原则是:优先看浅大小大或者保留大小大的类,但真正要追的是保留大小大、同时还被外部引用的对象。
举个生活化的例子:浅大小是一本书本身占的书架位置,保留大小是这本书加上书里引用到的、只有这本书独占的附录和光盘。如果一本书的保留大小很大,说明把它移走能腾出的总空间不小。
3.2 顺着引用链一路挖到GC Roots
当你在直方图里看到一个重点类后,下一步是看它的具体实例。在类视图里选中类,右边会列出“实例”子页签。双击任意实例,能看到该对象的字段值——比如byte[]的字节内容、String 的字符数组、Map 的 entry 数量。
到这里,VisualVM 的优势才开始体现。选中对象后,界面下方会出现“引用”页签,显示“谁引用了这个对象”和“这个对象引用了谁”。这个功能用得好可以一直往上追:例如你发现某类订单快照对象特别多,选中一个查看引用,发现它被一个 HashMap 引用;再看这个 HashMap 被谁引用,发现它被某个上下文类持有;继续追,上下文类又挂在某个静态字段下。当一条引用链从业务对象指向某个静态属性时,基本就是“长期驻留”的实锤了。
有个小提醒:VisualVM 能看到对象之间的直接引用,但“能否被 GC 回收”最终取决于是否有从 GC Roots 出发可达的路径。VisualVM 2.2 在对象面板里能看到引用关系,但自动计算“到 GC Roots 的最短路径”这类功能不如 MAT 方便。我通常在 VisualVM 里做快速人工追踪,一旦链路变深就转 MAT,用“Leak Suspects”报告看自动化结论。工具穿插着用,效率最高。
3.3 VisualVM和MAT的分工合作
VisualVM 2.2 强在“轻量、直观”:自带监视、线程、堆转储分析功能,启动快,单文件即可运行。MAT(Memory Analyzer)强在“深度分析”:支持支配树、OQL 查询、自动泄漏嫌疑报告,分析逻辑更重。
我的固定套路是这样:先用 VisualVM 打开 hprof,扫一眼直方图,顺着大对象人工追一两层引用,心中有个大致方向;然后让 MAT 加载同一份 hprof,跑“Leak Suspects”自动报告,看看工具推断的嫌疑根是什么;最后回到 VisualVM 验证关键对象,读字段值,确认根因讲得通。两个工具取长补短,比只用一个更稳。
需要留意的是 VisualVM 有时会被大 hprof 拖垮,此时可以直接用 MAT 读取。这不是 VisualVM 不好,而是分析工具自身的堆内存配置不够,下一节我会说怎么调。
4. 实战复盘:mrds65批次任务连续OOM的完整定位链路
光讲方法论容易飘起来,落到一个真实案例里对比着看,才更能理解每一步的意义。下面这个案例来自我最近处理的一次线上问题,服务代号就叫 mrds65,是一个每 5 分钟跑一次批次的订单汇总服务。
4.1 拿到手的现场与初步判断
那天晚上的报错很典型:
java.lang.OutOfMemoryError: Java heap space服务启动参数是-Xmx4g,OOM 自动 dump 配置是有的,目录里留下了java_pid24571.hprof,大小 3.8GB。打开 VisualVM 2.2 后,先看“概览”页:堆总大小 4GB,其中老年代(ParOldGen)已占用 3.1GB,类数量约 23 万个,实例总数 1.2 亿个。光看这几个数字,心里已经有数:这是一次“老年代吃满后分配失败”的 OOM,不是年轻代瞬间被打爆。
再看“类”页签按大小排序,前几名是这样的:
| 类名 | 实例数 | 浅大小 | 初步判断 |
|---|---|---|---|
| byte[] | 约 12000 | 1.2GB | 大量序列化数据 |
| OmsOrderSnapshot | 约 86000 | 860MB | 订单快照对象 |
| String | 约 300000 | 420MB | 字符串缓存 |
| ConcurrentHashMap$Node | 约 18 万 | 230MB | Map 内部节点 |
看到byte[]占 1.2GB,且实例数只有一万出头,说明这批大数组很可疑。接着往下看实例内容,双击其中一个byte[]查看字段,发现 content 是一段序列化后的订单数据。此刻已经把怀疑重点放在了“和订单数据缓存相关的对象”上。
4.2 从byte[]一路找到static Map
下一步就是顺着引用链往上走。在“实例”页签中选中某一个大byte[],下方“引用”面板显示:这个byte[]被某个OmsOrderSnapshot对象引用,而OmsOrderSnapshot是批次快照里每一条订单的完整拷贝。
继续选中OmsOrderSnapshot实例,看它被谁引用,发现大量OmsOrderSnapshot被BatchContext的totalMap持有。再点开totalMap,它是一个ConcurrentHashMap,里面 key 是批次号,value 是批次的完整快照 Map。每轮跑批都会往totalMap里放入一份订单快照,且从不移除。
到这个节点,根因其实已经浮出水面:原本BatchContext设计成“单批次上下文”,结果程序里把它当成“全量缓存”,每个批次的订单快照都放进去,并且没有清理机制。堆里只有这批订单快照会越攒越多。VisualVM 的引用链在这里帮了大忙,一眼看到底。
4.3 修复方案与上线验证
确认根因后,修复方向就清晰了。代码里原来是这样处理的:
public class BatchContext { private static final Map<String, Map<String, OmsOrderSnapshot>> TOTAL_MAP = new ConcurrentHashMap<>(); public void putBatch(BatchResult result) { // 往 TOTAL_MAP 里放整批次数据,但从不清理 TOTAL_MAP.put(result.getBatchNo(), result.getSnapshotMap()); } }修复时需要根据业务需求判断:如果批次数据只为对账服务,就应该处理完马上移除;如果只是短期查询,考虑换成带过期时间的本地缓存,或直接落库/Redis,把数据引到 JVM 堆外面。最终采用的方式是:批次结束后主动移除totalMap中对应键,同时把历史对账数据同步到数据库,堆内不再保留整份快照。
public class BatchContext { private static final Map<String, Map<String, OmsOrderSnapshot>> TOTAL_MAP = new ConcurrentHashMap<>(); public void finishBatch(BatchResult result) { // 批次处理完成,立即释放堆内引用 TOTAL_MAP.remove(result.getBatchNo()); } }验证也不能草率。重启上线后观察了 48 小时,VisualVM“监视”页签里堆空间曲线平稳,老年代占用率稳定在 40% 上下,没有再出现台阶式爬坡;Full GC 频率从原来每小时十几次降到一天几次。后来几次自动 dump 也再没触发。对于内存问题,我的判断标准向来是:修复后至少跨过一个完整业务周期(比如一整天的跑批),才算有效。
5. VisualVM排查OOM时踩过的坑和心得
工具用多了,总会积累一些反直觉的经验。下面这几点是我在实际操作中踩过或看同事踩过的,几乎每一条都对应一次本可以避免的加班。
5.1 大hprof打不开或转半天,先改VisualVM内存
VisualVM 本身是一个 Java 程序,它读取 hprof 时要先把对象索引加载进自己的堆。默认情况下 VisualVM 的堆上限不高,一旦 hprof 超过 2GB,常常出现打开后卡死、界面假死或者直接抛内存异常。
解决办法是修改它的启动配置。在 VisualVM 安装目录下找到etc/visualvm.conf文件,找到 JVM 参数配置行,加上堆大小设置。比如:
visualvm_default_options="-J-Xms512m -J-Xmx4g -J-XX:MaxPermSize=256m"这里关键参数的含义:-J-Xms是 VisualVM 自身初始堆,-J-Xmx是最大堆。分析 5GB 左右的 hprof,建议至少给到 4GB 以上。如果你的机器内存吃紧,就先只改-J-Xmx,重启 VisualVM 即可生效。
另外一个更轻量级的做法是控制打开方式:如果只是看直方图和对象分布,不一定非要把 hprof 整个加载进来,可以用jhat或者 MAT 的解析功能先看摘要再判断。不过 VisualVM 的交互式引用追踪确实方便,调完内存后用起来最顺手。
5.2 拿top和线程栈来查OOM,容易把方向带偏
很多同学一到线上排查就习惯性先敲top、再top -Hp查线程、然后jstack导线程栈。这组动作是定位 CPU 飙高或线程卡死的神器,但用来排查堆 OOM,常常南辕北辙。
理由很简单:top显示的是进程常驻内存,线程栈显示的是每个线程此刻正在执行的代码路径。OOM 发生时,线程栈只会告诉你“哪根线程正在尝试分配大对象或数组导致分配失败”,却完全展示不出堆里那些历史对象是谁创建的、为什么没被回收。堆内对象情况必须靠 dump 分析,不能靠线程快照脑补。
我不是说线程栈完全没用。如果某个 OOM 线程反复出现在同一段分配大数组的代码里,那至少能提供一个“嫌疑生产者”的线索。但真正的证据链——谁持有对象、谁阻止 GC——必须回到堆转储里找。所以遇到 OOM,先把 jstack 放一放,优先问自己:有没有 hprof,什么时候抓的,分析工具准备好了吗。
5.3 没抓到dump的补救操作
在线环境总有不完美的时候:有些服务没配自动导出,有些配了但 OOM 时磁盘正好满了。这种时候也别慌,还有几步补救可以做。
第一步,先确认进程是否还活着。发生 OOM 后进程不一定立刻退出,有些线程还能继续跑。如果能连上 jmap,立刻手动抓一份:
jcmd <pid> GC.heap_dump /data/logs/emergency.hprof jmap -dump:format=b,file=/data/logs/emergency.hprof <pid>第二步,抓不到 dump 就抓 GC 日志。检查启动参数里有没有-verbose:gc或-Xlog:gc*,有的话分析 GC 日志里老年代占用率和 Full GC 频率的变化曲线。即便没有 dump,老年代一步步爬升的数据也能指认“对象累积”的方向。
第三步,看 VisualVM“监视”页签的历史曲线。如果之前已经连过 VisualVM 或 JMX 监控,堆空间曲线会画出内存上涨轨迹。台阶型上涨通常对应批次任务、定时任务;直线爬升通常对应循环泄漏;锯齿状波动大但整体平稳则更偏向“堆太小”而不是泄漏。这些特征虽然不如 dump 扎实,但在没有快照时能救命。
5.4 平时盯住这几个指标,提前预警OOM
最后说句实在话,OOM 最好的处理方式是把它扼杀在预警阶段。与其等着告警响起来再查 dump,不如提前在监控里盯几个关键信号。
第一个信号是老年代占用率。VisualVM“监视”页签能看到堆空间分布,结合 GC 日志里的老年代使用量,如果每次 Full GC 后的水位线持续抬高,基本就是慢性泄漏的前兆。第二个信号是 Full GC 频率和单次时长。正常情况下老年代较大的服务,Full GC 频率应该很低;一旦出现每小时多次 Full GC 且单次 GC 耗时超过 1 秒,说明堆已经不堪重负。第三个信号是处理能力下降但 CPU 没满——这时候并发虽然低,但大量线程在 GC 安全点等待或反复扫描对象,常被误判为“服务卡了”,其实背后是堆问题的影子。
这些指标配好告警阈值,通常在真正 OOM 之前几个小时就能收到提醒,留给定位的时间窗口远比事后抓 dump 从容。
我个人现在处理线上 OOM 的习惯是:先看有没有自动 dump,有就看 dump,没有就等进程还活着时手动抓,顺便看一眼监控曲线和 GC 日志,再用 VisualVM 和 MAT 轮流分析确认根因。这套流程跑过很多次,虽然不是每次都能几分钟搞定,但至少不会在茫茫对象海里迷失方向。
最后再说一个很多人不知道的操作:VisualVM 2.2 里可以对同一进程先后抓两份 dump,然后在对比视图里并排看,能直观看两个时间点哪些对象变多了。这个功能对“内存为什么慢慢涨”这类问题尤其好用,比单看一份快照更容易发现增长源。内存问题最怕的不是难分析,而是没有现场。把自动 dump 参数配好,把分析工具调顺,真到 OOM 那天,你会感激自己提前做的这些准备。