JVM内存溢出全解析:四类OOM的触发原理与排查实战

发布时间:2026/9/16 10:04:07
JVM内存溢出全解析:四类OOM的触发原理与排查实战 OOM是个老生常谈的话题但真正到线上出问题的时候大部分人还是容易慌。尤其是在堆OOM、栈溢出、元空间OOM、直接内存OOM这几类问题同时摆在面前时光靠报错信息里那几行英文很难一眼判断出到底该往哪个方向查。我这些年排查过不少内存问题踩过坑也总结出一些套路。这篇就把四类OOM的真实区别、触发原理和排查过程一次性讲透希望能帮你少走弯路。先交代一下背景我遇到过的一个线上服务实例平时堆内存设置的是4G某天突然开始频繁Full GC紧接着报出java.lang.OutOfMemoryError: Java heap space。但诡异的是同一个集群里的另一个服务堆内存占用很低却出现了java.lang.OutOfMemoryError: Direct buffer memory。还有一个网关服务堆和元空间都正常却因为递归调用直接StackOverflowError。这就是OOM最让人头疼的地方——报错信息不同根因完全不同排查路径也完全不同。如果你也想把这类问题理清楚这篇内容适合你不管你是刚接触JVM的新手还是已经写过一段时间Java但是对内存问题比较发怵的开发者又或者是需要在线上快速定位问题的运维和SRE这篇文章都能给你一套可落地的排查参考。1. 先建立OOM的整体认知四种类型到底怎么区分1.1 从JVM内存结构说起很多人把OOM直接等同于内存不够了这个理解不算错但太笼统。JVM内部的内存不是一整块而是分区域的。Java堆存放对象实例虚拟机栈存放栈帧每个方法调用一个栈帧元空间存放类的元数据直接内存则是堆外的一块Native内存。每块区域如果分配不到内存抛出的异常类型是完全不同的。Java heap space堆内存耗尽通常是对象太多或者对象无法被回收。StackOverflowError不是OutOfMemoryError的子类但本质是栈空间耗尽一般是递归无终止条件。Metaspace元空间耗尽通常是加载的类太多而且类加载器无法被回收。Direct buffer memory堆外直接内存耗尽常见于NIO、Netty等使用堆外缓冲的框架。这几类问题的定位思路差异很大。堆OOM看GC日志和堆dump就行栈溢出看线程栈基本就能定位元空间OOM需要查类加载器数量和动态生成类的来源直接内存OOM最麻烦因为常规的jmap很难把堆外内容dump出来需要结合NMTNative Memory Tracking等工具来分析。1.2 四种OOM的核心特征对比我把四类问题的关键特征整理成了一个表排查时可以先对照一下类型报错关键字主要区域常见原因首选排查工具堆OOMJava heap space堆大对象、内存泄漏、集合膨胀jmap MAT栈溢出StackOverflowError虚拟机栈无限递归、超深调用jstack、核心转储元空间OOMMetaspace元空间动态生成类、类加载器泄漏jstat jmap类直方图直接内存OOMDirect buffer memory堆外Native堆ByteBuffer未释放、框架BugNMT、pmap、Netty内存泄漏检测这个表只是初始方向不是绝对标准。实际排查中有些问题表面上是堆OOM但根因可能是创建了太多线程导致Native内存不足随后表现为堆分配失败。所以看到报错信息之后先不要急着下结论要继续往底层看。2. 堆OOM最常见也最容易被误判的一种2.1 堆OOM是怎么发生的堆OOM是四类里出现频率最高的也是很多人最先接触到的。它的核心机制很简单创建对象时堆中没有足够空间完成分配并且GC之后仍然没有足够空间JVM就会抛出OutOfMemoryError: Java heap space。但为什么没空间要分两种情况看一种是真·内存泄漏。对象明明已经不需要了却因为被某个长期存活的对象引用着GC无法回收。比如一个静态Map往里一直放数据但从不清理时间一长必然会堆OOM。另一种是内存充足但分配不进去比如一次性要申请一个超大数组即便堆还有几个G也装不下这个单个对象。这两种情况虽然都报同样的错但排查思路是不同的。我在实际排查中习惯先看GC日志。如果GC日志显示Full GC频繁而且每次回收后堆占用率降不下去那基本可以判断是泄漏如果Full GC不是特别频繁但堆有一个陡增的分配尖峰那就要考虑是不是突然出现了超大请求或者超大对象。2.2 一个典型的堆OOM排查流程本地复现或者线上保留现场之后我一般按下面这个流程走第一步用jmap确认堆的使用情况和GC情况命令是jmap -heap pid能看到堆的配置参数和各代的当前占用。第二步 dump堆快照命令是jmap -dump:formatb,fileheap.hprof pid。如果服务还能撑住最好在OOM之前就dump不要等到进程已经挂了再操作。第三步用MAT或者IDEA自带的JProfiler分析hprof文件查看支配树和泄漏报告。重点看两个视图一个是Leak SuspectsMAT会自动帮你圈出嫌疑对象另一个是Dominator Tree看哪些对象占用的Retained Heap最大。我遇到过一个典型场景一个Kafka消费线程把拉取到的消息全部放到一个ArrayList里做批量处理结果下游处理速度跟不上队列越积越大最后直接把堆打爆了。DOM树里能看到这个ArrayList持有几十万个消息对象根因一目了然。还有一点值得强调dump文件最好和出问题的时间点尽量接近。假如线上已经恢复了再dump出来的堆很可能已经自愈分析价值大打折扣。2.3 堆OOM的两种本质泄漏与膨胀我说堆OOM最容易误判就是因为很多人一看到堆占用高就喊泄漏但其实还有可能是业务逻辑上的合法膨胀。泄漏的特点是GC回收不掉堆占用只增不减。膨胀的特点是对象本身都有引用业务上确实还在用但数据量超过了预期。比如缓存没有设置过期策略或者用户在页面上一次性查询了全量数据。这类问题严格来说不是程序Bug而是资源规划没做好。排查时可以用jstat观察Eden区和Old区的变化曲线如果Old区持续增长说明确实有对象在晋升之后回收不了如果Eden区频繁打满但Young GC后回收效果不错那问题可能出在短时间创建了大量临时对象也就是分配速率过高。分配速率和对象晋升速率是堆OOM排查的两把尺子弄清楚到底是哪把尺子超了才能对症下药。3. 栈溢出线程私有空间的问题3.1 调用栈与StackOverflowError原理栈溢出虽然也带溢出两个字但它和堆OOM的机制完全不同。每个线程都有自己的虚拟机栈栈里面存的是栈帧。每次方法调用会压入一个栈帧方法返回就会弹出。如果某个方法一直没有返回还不断调用下一个方法栈帧就会越压越多最终超过栈的最大深度这时候JVM抛出的就是StackOverflowError。栈的大小的默认值跟平台有关在64位Linux上一般默认1024KB。可以用-Xss参数来调整比如-Xss512k。但我不建议为了防止栈溢出而盲目调大栈空间因为栈空间是线程私有的调大了会直接增加每个线程的内存占用。举个例子一个服务开了200个线程栈从1MB调大到2MB单单栈区域就多了200MB的Native内存消耗这在容器环境里很容易引发其他类型的内存问题。栈溢出最常见的触发场景就是没有终止条件的递归调用。但也有例外比如某些框架的AOP代理嵌套调用一层套一层虽然每次调用逻辑上看起来正常但调用链特别深。3.2 真实案例递归引发的栈溢出我之前处理过一个报表计算服务某个统计功能用递归去遍历树形分类结构。正常数据下分类树深度只有五六层没有问题。但运营人员在后台手工录入数据时不小心把父子节点配成了循环引用——A的父节点是BB的父节点又是A递归就永远走不完了直接StackOverflowError。这个案例的排查过程其实很短先把出错的线程栈dump下来jstack pid直接看到线程栈里同一个方法名反复出现几十次比如com.example.TreeService.traverseTree一眼就知道是递归了。再把栈里相关的业务对象和调用参数拉出来发现循环引用修复数据并加上递归深度限制就好。从这次之后我养成了一个习惯所有写递归的地方至少加一个深度参数超过临界值就抛业务异常。哪怕正常情况下用不到这个保护也值得留。3.3 栈相关配置与保护措施处理栈溢出时很多人第一反应是调大-Xss但前面说了这个方案要慎重。我建议的顺序是先通过线程栈分析是不是有死循环或循环引用确认程序逻辑没问题之后再考虑栈够不够用。正常业务方法调用深度很少超过几百层如果出现几万层的调用栈基本都是程序问题。还有一个容易被忽略的点StackOverflowError虽然属于Error但它可以被try-catch捕获。有些开发者想着我catch一下就没事了实际上这种思路很危险。栈已经接近极限的情况下catch块中执行任何方法都可能再次触发StackOverflowError而且错误对象本身也会占用栈空间。我见过一个服务因为catch了StackOverflowError之后还在catch里打印日志结果反复抛出线程直接卡死。保护措施上除了递归深度限制之外还可以关注线程数量。线程数量暴增本身也会占用大量Native内存压缩到线程栈的内存间接引发各种内存异常。所以线上监控线程数变化对预防栈溢出同样重要。4. 元空间OOM类加载器泄漏的隐形杀手4.1 Metaspace与直接内存的区别在JDK 8之前类的元数据存放在永久代PermGenJDK 8之后被元空间Metaspace取代。最大的变化是永久代在堆内受堆大小限制元空间在本地内存中默认情况下上限是机器的物理内存大小。这个默认无上限听起来是好事但实际上很坑。因为元空间无上限意味着类元数据占用的本地内存可能无限增长直到操作系统无法分配内存这时候表现出来的是本地内存耗尽而不一定是Metaspace异常。所以我建议线上服务一定要显式设置-XX:MaxMetaspaceSize哪怕设一个比较宽的值也能把失控的类加载问题挡在可控范围之内。元空间OOM的报错信息是java.lang.OutOfMemoryError: Metaspace但根因往往不在RUNTIME本身而在类加载器。每一个类加载器都会维护自己加载过的类信息如果类加载器可以被GC回收那它加载的类元数据也能被释放如果类加载器本身被某个长生命周期对象引用住了那它加载的类就永远释放不了元空间占用就会持续增长。4.2 元空间OOM排查从类加载入手排查元空间OOM我一般分三步第一步用jstat -gc pid查看M列Metaspace的使用量确认是否持续增长。第二步用jmap -clstats pid查看类加载器的加载数量以及每个加载器加载的类数。如果看到某个类加载器加载了大量类且始终无法卸载那基本可以锁定问题。第三步找到创建类加载器的代码路径。常见来源包括热部署框架重复加载应用、CGLIB/ByteBuddy动态生成代理类、反射调用时大量使用Proxy.newProxyInstance。我处理过一个典型的元空间OOM场景是用CGLIB做动态代理做方法增强。每次创建代理对象时CGLIB都会生成一个新的代理类。代码在循环里大量创建代理对象而且把生成的代理类引用放到了静态Map里防止被GC。随着代理对象越建越多元空间里的代理类也越来越多最终直接撑爆了MaxMetaspaceSize。4.3 案例热部署与动态代理导致的元空间OOM热部署是个高发场景尤其是使用Spring Boot DevTools或者自研热加载插件的时候。每次热部署都会创建新的类加载器来加载新的类如果旧的类加载器没有被释放就相当于每次部署都留下了一批僵尸类堆积多了必然元空间OOM。动态代理也类似但更隐蔽。CGLIB生成的代理类会被缓存在ClassLoader中正常情况下代理类数量是有限的因为业务类数量有限。但如果你的代码循环生成代理或者在一个长期存活的静态Map里缓存了动态类的Class对象类数量就会失控。这类问题在代码层面不好直接修复因为动态生成类本身不是错错的是没有控制生成数量和生命周期。我常用的保护手段是控制动态代理类的创建频率增加缓存相同类型的代理只创建一次。及时清理不再使用的类加载器引用避免长期被静态字段持有。设置-XX:MaxMetaspaceSize给失控状况一个最终的边界。5. 直接内存OOM最难定位的Native层问题5.1 直接内存在哪里直接内存是堆外内存由ByteBuffer.allocateDirect(size)分配底层走的是操作系统的本地内存。它不受堆大小控制默认上限是-XX:MaxDirectMemorySize如果不设置就用JVM的默认值通常等于是-Xmx的大小。为什么要用直接内存因为有更少的拷贝。比如用Netty做网络通信数据从内核态读到用户态如果用堆缓冲还要经过一次JVM堆内的拷贝用直接内存可以省掉一些中间拷贝性能更好。同理Kafka客户端和很多高性能框架也会用到直接内存做发送和接收缓冲区。但也正因为直接内存的分配和释放不由GC直接管理问题就来了堆内存有GC帮忙回收直接内存只能靠Cleaner机制在对象被回收时触发释放。如果你持有DirectByteBuffer的引用不释放或者GC一直不触发直接内存就一直在涨最终抛出OutOfMemoryError: Direct buffer memory。5.2 典型案例Netty与Kafka消费端的内存不稳定我遇到过一个很典型的直接内存OOM场景是在一个Kafka消费服务上。这个服务使用Netty做内部RPC通信同时用Kafka客户端消费消息。某次压测时流量突然增加消费速率跟不上Netty的发送缓冲区不断积压ByteBuffer分配越来越多最终报了Direct buffer memory。当时最迷惑的地方是jmap看堆内存只有1G多完全没有异常但整个进程的内存占用已经接近4G。原因就是大量DirectByteBuffer在堆外占了空间堆内看到的只是很小的引用对象。这种情况如果用常规的堆dump分析完全看不出问题。还有一个细节要特别注意直接内存不足时报错不一定都是Direct buffer memory有时会表现为OutOfMemoryError: unable to create new native thread因为本地内存耗尽后连创建线程所需的内存都分配不出来了。所以当你看到unable to create new native thread时除了检查线程数是否太多还要看看是不是直接内存或元空间把native内存吃光了。5.3 直接内存排查的四个关键动作第一开启Native Memory Tracking。JVM加参数-XX:NativeMemoryTrackingdetail启动后用jcmd pid VM.native_memory summary查看内存分配情况。它会把JVM内部的Native内存按模块分类能直接看到Direct Buffer占了多少。第二检查直接内存相关的配置。比如Kafka客户端的receive.buffer.bytes和send.buffer.bytesNetty的arena数量和maxDirectMemory。如果你的容器内存很小这些buffer配置一定要控制在合理范围内。第三注意Cleaner的触发时机。直接内存本来是依赖GC来回收的如果堆内存还很充裕GC不频繁那DirectByteBuffer的Cleaner就一直得不到执行。这也是为什么堆内回收压力不大、堆外却OOM的原因。可以考虑调低堆内存中触发GC的阈值或者用Netty自带的PlatformDependent.allocateDirectNoCleaner来避免依赖Cleaner机制但这属于进阶操作不建议新手直接用。第四观察进程的RSSResident Set Size常驻物理内存。用top -p pid看RES列如果RES持续涨而堆内存没有涨那大概率就是堆外内存泄漏。再配合pmap看详细的内存段哪里在涨就往哪里查。6. 一次完整的多类型OOM排查实战回顾6.1 把四类OOM放在一个流程里处理很多人问我遇到OOM的时候第一步到底该干嘛我的答案是先看报错关键字再决定用哪套工具但无论哪套流程都要先把现场保留住。我综合下来的一套流程是看到OOM日志后先确认是哪种类型关键看Exception或Error完整信息。立刻用jstat -gcutil pid采集GC数据观察各区域占用。根据类型选择下一步堆问题就dump堆元空间问题就dump类加载器统计栈问题就jstack拿线程栈直接内存问题就尝试打开NMT或者用pmap。保留完现场再想办法重启恢复服务能不停机最好但千万别在保留现场之前就盲目重启。这里特别提醒一点很多时候崩溃的线程只是压死骆驼的最后一根稻草真正的问题可能在几分钟甚至几小时之前就已经在积累了。所以线上监控非常重要我建议至少监控以下几项堆内存使用率、GC频率、Metaspace使用量、线程数、进程RSS。任何一个指标出现异常增长趋势都要在OOM爆发之前介入。6.2 常用工具与参数对照表我把排查OOM时最常用的命令和参数整理了一下你可以直接保存备用目的命令/参数说明查看堆配置jmap -heap显示堆参数、各代使用情况dump堆快照jmap -dump:formatb,fileheap.hprof用于MAT分析查看GC统计jstat -gcutil 间隔ms观察Eden、Old、Metaspace变化查看Java线程栈jstack定位死锁、阻塞、递归查看类加载器统计jmap -clstats查看每个加载器的类数Native内存统计jcmd VM.native_memory summary需要开启NMT才有效查看进程物理内存top -p 或 pmap排查堆外内存占用对应JVM参数我常用的配置是这样的-Xms4g -Xmx4g -Xss512k -XX:MaxMetaspaceSize512m -XX:MaxDirectMemorySize1g说明一下-Xms和-Xmx设成一样可以避免运行时动态扩缩容带来的性能抖动-Xss512k对大部分业务足够但需要确认你们的调用链深度-XX:MaxDirectMemorySize一定要显式设置尤其是在使用Netty、Kafka这类框架时。6.3 几个让我印象深刻的排查教训最后分享几个踩过的坑都是常规文档里不会写的。第一个教训线上OOM之后千万不要急着改内存参数。有一段时间我们某个服务堆OOM同事第一反应是把-Xmx从4G改到8G。结果服务倒是能多撑几天但OOM还是会来而且因为堆更大GC停顿更明显用户反而更卡了。后来dump一分析就是内存泄漏改参数只是把问题延后了没有解决根源。第二个教训元空间OOM不一定要堆dump。我见过有人对着Metaspace OOM去jmap dump堆文件分析半天啥也没看出来。因为元空间问题根本不在堆对象里而是在类加载器里。要去看类加载器的数量和加载的类而不是去分析堆对象占用。用错工具会让排查时间翻好几倍。第三个教训直接内存OOM时jmap的堆快照几乎帮不上忙。有一次我们拿到了一份OOM现场但也只 dump 了堆文件分析了两天没结果。后来才想到开NMT但进程已经重启了现场丢了只能靠回忆排查非常痛苦。所以建议直接内存使用较多的服务最好提前开好NMT语法是-XX:NativeMemoryTrackingsummary性能损耗很小线上可以直接开。第四个教训Kafka消费端的OOM不仅要调buffer参数还要关注消费速率。我遇到过的Kafka OOM里很大一部分不是因为buffer配置太小而是因为消费逻辑中有阻塞操作导致消费线程跟不上分区分配消息在本地积累。这种问题改了JVM参数也没用要去优化业务消费逻辑。最后再分享一个小技巧如果你在Linux上排查OOM发现进程已经没了别慌可以先去看系统日志。dmesg -T | grep -i kill能看到操作系统有没有启用了OOM Killer以及被kill掉的是不是你的Java进程。很多时候Java进程突然消失并不是JVM自己抛OOM而是操作系统在内存耗尽时主动杀了它。这类问题光靠分析JVM日志是不够的要考虑整个容器或者物理机的内存分配。这些排查经验是我踩过不少坑之后总结出来的。OOM问题虽然种类多但只要你养成先分类型、保留现场、用对工具的习惯大部分问题都能在合理时间内定位到根因。下次再看到OutOfMemoryError不用慌先把报错类型看清楚再按对应路径去查。