Java报错java.lang.OutOfMemoryError: Java heap space的排查方法(jmap与jcmd导出堆快照、MAT定位泄漏点与-Xmx调整)

跑得好好的应用突然就不响应了,日志末尾一行java.lang.OutOfMemoryError: Java heap space。这句话能给出的信息其实很少,它只说明分配新对象时堆里挤不下了,至于谁把堆占满、占的是不是该占的,得看堆快照。排查的顺序基本固定:先把现场存下来,再判断是堆给小了还是有对象没释放,最后回到代码。


让JVM在崩之前自己存一份

第一件事是把自动导出打开,这两个参数加上去,平时没有任何开销,只在OOM触发的那一刻写一次文件:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump

路径要落到挂载出来的卷上。容器里写进镜像层的话,Pod一重启文件跟着没了,恰好OOMKilled的场景就是最需要这份文件的场景。另外hprof的体积接近-Xmx的值,Xmx给了4G就按4G留磁盘,见过根分区只剩2G、dump写一半失败的情况,日志里多一行Unable to create heap dump,现场直接没了。


进程还活着的时候手动导一份

内存涨得慢、还没崩的情况,手动导更从容。现在推荐用jcmd,它对进程的影响比jmap小:

jcmd 12345 GC.heap_dump /data/dump/heap.hprof

老一点的写法是jmap,在JDK 8上它会先触发一次Full GC再遍历堆,整个进程停住,堆大的时候几十秒到几分钟都有:

jmap -dump:format=b,file=/data/dump/heap.hprof 12345

高峰期宁可等一等也不要用jmap硬导。JDK 8、11、17、21与25,这一串版本里jcmd都是随JDK带的,不过用jlink裁过的运行时镜像可能连jcmd都没打进去,这种环境下启动时就得把-XX:+HeapDumpOnOutOfMemoryError配上,别等出事了才发现手上没工具。


先看一眼是不是纯粹给小了

不用急着开MAT,先花十秒钟看老年代曲线,连续打十次GC统计,每秒一次:

jstat -gcutil 12345 1000 10

盯O那两列。老年代长期在95%以上、每次Full GC之后只掉下去两三个百分点,基本是泄漏;每次Full GC都回收得干干净净、只是业务峰值时冲得很高,那就是-Xmx给小了,加内存就能解决。

顺手确认一下堆的当前配置:

jcmd 12345 GC.heap_info

-Xms和-Xmx设成同一个值更省事,省掉运行过程中扩容缩容带来的抖动。有件事容易记混:JDK 8已经没有永久代,类加载那类OOM报的是Metaspace而不是Java heap space,堆参数加再多也治不了元空间。


用MAT找出谁在占着堆

MAT 1.14.0、1.15.0、1.16.1与1.17.0,这几个版本用起来差别不大,2026年6月那版1.17.0主要还是在适配新的JDK格式。打开hprof先看Leak Suspects,它会直接给出嫌疑最大的一两个对象和引用链;报告不够明确就切到Dominator Tree,按Retained Heap排序,排在最前面的类名基本就是答案。要找出具体是谁持有着,选中对象走Path To GC Roots,把弱引用和软引用排除掉。

堆文件几GB的时候MAT自己会先OOM,改MemoryAnalyzer.ini里的-Xmx,给到dump体积的1.5倍左右才打得住。

实际查出来的落点就那么几类:静态HashMap做缓存只往里放不清理、ThreadLocal用完没remove、监听器注册了没注销,还有最朴素的一种,一条没加分页的查询把整张表捞进List里。之前碰到过的是第三种,一个报表服务每次请求都new一个监听器注册进去,跑了两天堆里全是同一个类的实例。


容器里那层容易算错

容器环境有个单独的坑。JDK 8u191之前的版本不认cgroup的内存限制,会照着宿主机的内存算默认堆大小,容器限额2G、宿主机128G,JVM敢给自己分32G,然后被内核直接杀掉。8u191之后以及JDK 11、17、21这几个版本,UseContainerSupport是默认开着的,默认最大堆取容器限额的25%,所以不写-Xmx反而容易出现内存明明够用却OOM的情况。一般按容器限额的60%到75%来给:

java -Xms2g -Xmx2g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump -jar app.jar

或者直接按百分比写,省心:

-XX:MaxRAMPercentage=70.0

内存给多了会走向另一个方向:堆涨过容器限额被系统杀掉,容器状态里看到的是Exit Code 137、OOMKilled,日志里一个OOM字都没有。堆外内存那部分(直接缓冲、线程栈、元空间)也要占限额,所以Xmx不要贴着limit给。


还有一种报错串别认错

java.lang.OutOfMemoryError: GC overhead limit exceeded和heap space是两回事,触发条件是GC占用了超过98%的时间却只回收到不到2%的堆。本质上也是堆快满了,只是JVM提前一步认输。流传很广的那个-XX:-UseGCOverheadLimit只是把报错改回heap space,堆照满不误,相当于把警报器关掉继续烧水。


整件事的顺序记下来就三句:先留dump再重启,重启完就只剩日志里那一行字了;然后看老年代的回收曲线,分清是配小了还是泄漏;拿MAT按Retained Heap往下追引用链。查到具体那个静态Map或者没关的连接之后,改起来通常只要几行。