
上周二晚上十一点半监控电话把我吵醒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 exceededGC 反复全力回收但每次回收后内存依然不足回收效率低于 2%先看 GC 日志再看堆转储Metaspace方法区/元空间耗尽类元数据不断膨胀统计类加载数量排查动态代理与类加载器泄漏Direct buffer memory堆外直接内存耗尽用 NMTNative 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:formatb,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.port9999 -Dcom.sun.management.jmxremote.authenticatefalse -Dcom.sun.management.jmxremote.sslfalse但说实话跨网络远程抓大堆转储体验并不好。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 强在“轻量、直观”自带监视、线程、堆转储分析功能启动快单文件即可运行。MATMemory 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服务启动参数是-Xmx4gOOM 自动 dump 配置是有的目录里留下了java_pid24571.hprof大小 3.8GB。打开 VisualVM 2.2 后先看“概览”页堆总大小 4GB其中老年代ParOldGen已占用 3.1GB类数量约 23 万个实例总数 1.2 亿个。光看这几个数字心里已经有数这是一次“老年代吃满后分配失败”的 OOM不是年轻代瞬间被打爆。再看“类”页签按大小排序前几名是这样的类名实例数浅大小初步判断byte[]约 120001.2GB大量序列化数据OmsOrderSnapshot约 86000860MB订单快照对象String约 300000420MB字符串缓存ConcurrentHashMap$Node约 18 万230MBMap 内部节点看到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 MapString, MapString, 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 MapString, MapString, 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:MaxPermSize256m这里关键参数的含义-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:formatb,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 那天你会感激自己提前做的这些准备。