1. 从一次线上告警说起:OOM 到底在报什么
凌晨两点,监控大屏突然飘红,某个核心服务的接口成功率从 99.9% 直接掉到 60%,日志里刷出一行刺眼的java.lang.OutOfMemoryError: Java heap space。这种场景,做过 Java 后端的人大概率都遇到过。OOM 这三个字母看起来简单,但它背后可能是内存泄漏、可能是流量突刺、可能是配置不合理、也可能是代码里一个不起眼的集合对象在悄悄膨胀。Java OOM 原因以及问题分析排查步骤,说白了就是一套“从现象到根因”的定位方法论,它解决的不是“怎么让报错消失”,而是“怎么找到那个真正吃掉内存的家伙”。
这篇文章适合所有跟 Java 服务打交道的人看:刚入行的同学可以把它当成一份排查手册,遇到问题照着走;有一定经验的工程师可以对照检查自己的排查思路有没有遗漏;做架构和运维的同学也能从中找到容量规划和监控配置的参考点。我不会只丢几个命令给你,而是把每个动作背后的意图讲清楚——为什么要 dump、为什么要看 GC 日志、为什么有时候堆内存没满也会 OOM。这些“为什么”才是排查效率的分水岭。
先建立一个基本认知:OOM 不是一种错误,而是一类错误。OutOfMemoryError是 JVM 抛出的,但触发它的原因五花八门。常见的有堆内存耗尽、元空间耗尽、线程栈溢出、直接内存耗尽、GC 开销过大等。不同种类的 OOM,排查路径完全不同。你要是拿排查堆内存的那套方法去查元空间问题,基本等于白忙活。所以第一步永远是:看清楚报错信息里冒号后面那半句是什么。这半句,就是 JVM 给你的第一手线索。
2. OOM 的常见类型与背后原理拆解
2.1 堆内存溢出:最典型也最容易误判
Java heap space是出现频率最高的 OOM。它的直接含义是:堆内存已经被占满,垃圾回收器尽了最大努力还是腾不出足够空间给新对象。但这里有个特别容易踩的坑——堆内存满,不等于内存泄漏。有可能是你的堆本身设置得太小,业务量一上来就撑爆了;也有可能是某个瞬间产生了超大对象,比如一次性从数据库查了几十万条记录塞进 ArrayList。这两种情况的处理方式截然不同,前者调参数,后者改代码。
从原理上讲,堆是 JVM 中最大的一块内存区域,所有对象实例和数组都分配在这里。当 Eden 区满了,触发 Minor GC;存活对象进入 Survivor 区;反复存活的对象晋升到老年代;老年代满了触发 Full GC。如果 Full GC 之后老年代还是放不下新对象,JVM 就抛出Java heap space。所以你看,这个报错其实是 GC 链条的最后一环,前面每一环的异常都可能导致它。
2.2 元空间溢出:动态生成类惹的祸
Metaspace是 JDK 8 之后取代永久代的一块区域,它不在堆里,而是使用本地内存。报错信息通常是java.lang.OutOfMemoryError: Metaspace。元空间存的是类的元数据,比如类结构、方法信息、常量池等。正常情况下,一个应用加载的类数量是有限的,元空间不会出问题。但如果你用了大量反射、动态代理、字节码增强框架(比如某些 ORM 框架、AOP 框架),运行时不断生成新类,元空间就会持续增长。
我见过一个典型案例:某服务用了动态代理做数据源路由,每次请求都创建一个新的代理类,结果类加载器无法卸载这些类,元空间几天就涨满了。这种问题的隐蔽性在于,堆内存看起来完全正常,GC 日志也很健康,但服务就是会突然挂掉。所以排查 OOM 时,千万别只盯着堆看。
2.3 GC 开销过大与直接内存溢出
GC overhead limit exceeded这个报错很有意思,它不是说内存不够了,而是说 JVM 花了超过 98% 的时间在做 GC,却只回收了不到 2% 的堆空间。这相当于一个人在跑步机上拼命跑,但原地不动。JVM 判断这样下去没意义,干脆直接抛错。这种情况通常意味着堆里全是活对象,GC 根本回收不动,本质上还是内存泄漏的一种表现。
还有一种是Direct buffer memory,直接内存溢出。NIO 的 ByteBuffer、Netty 的堆外内存都用的是这块区域。它不受堆参数控制,而是受-XX:MaxDirectMemorySize限制。如果你用了 Netty 做网络通信,又没注意释放 ByteBuf,直接内存就会慢慢涨上去。这种 OOM 的排查难度在于,堆 dump 里看不到任何异常,因为对象根本不在堆里。
| OOM 类型 | 报错关键词 | 内存区域 | 常见诱因 |
|---|---|---|---|
| 堆溢出 | Java heap space | 堆 | 内存泄漏、大对象、堆配置过小 |
| 元空间溢出 | Metaspace | 本地内存 | 动态类生成、类加载器泄漏 |
| GC 开销过大 | GC overhead limit exceeded | 堆 | 活对象过多、回收效率极低 |
| 直接内存溢出 | Direct buffer memory | 本地内存 | 堆外内存未释放、NIO 使用不当 |
| 线程栈溢出 | unable to create new native thread | 线程栈 | 线程数过多、栈大小配置不当 |
3. 排查前的准备工作:别急着 dump
3.1 先确认现场信息,别上来就动手
很多人一看到 OOM,第一反应就是jmap -dump。这个动作本身没错,但如果你在 dump 之前没收集足够的信息,后面分析起来会很吃力。我的习惯是,先做三件事:看报错日志、看 GC 日志、看监控曲线。报错日志告诉你 OOM 的类型和发生时间;GC 日志告诉你内存增长的趋势和 GC 的频率;监控曲线告诉你当时系统的整体负载情况。这三样东西结合起来,能帮你快速缩小排查范围。
具体来说,报错日志里要关注几个点:OOM 的类型、抛出的线程名、有没有伴随的其他异常。比如如果是unable to create new native thread,那问题可能出在线程池配置或者操作系统限制上,跟堆内存没关系。GC 日志则要看 Full GC 的频率、每次回收的效果、老年代的增长速度。如果 Full GC 后老年代几乎不下降,那基本可以判定是内存泄漏。
3.2 开启必要的 JVM 参数
很多排查工作能不能做,取决于你事前有没有开启对应的参数。如果服务启动时没加 GC 日志参数,出了问题你连 GC 日志都没有,只能靠猜。所以我的建议是,生产环境一定要开启 GC 日志和 OOM 自动 dump。这两个配置的成本很低,但关键时刻能救命。
# 开启 GC 日志,输出到文件,按时间戳滚动 -Xloggc:/var/log/app/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=50M # OOM 时自动 dump 堆快照 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heapdump.hprof # 元空间大小限制,避免无限增长 -XX:MaxMetaspaceSize=256m # 直接内存限制 -XX:MaxDirectMemorySize=256m注意:
HeapDumpPath指定的目录要有足够的磁盘空间。一个几 G 的堆 dump 出来可能就是几个 G 的文件,磁盘满了会更麻烦。另外 dump 过程会暂停应用(STW),如果服务还在线上跑,要评估影响。
3.3 保留现场,避免重启了事
我见过太多团队的处理方式:服务 OOM 了,运维直接重启,服务恢复,大家松一口气,然后就没有然后了。下次再 OOM,再重启。这种循环的代价是,你永远不知道根因是什么,问题只会越来越频繁。OOM 的现场是有时效性的,进程一旦重启,堆里的对象分布、线程状态、类加载情况全部丢失。所以正确的做法是:先 dump,再重启;先保留日志,再恢复服务。
如果服务已经无法响应,但进程还在,可以用jmap手动 dump。如果进程已经僵死,jmap可能也会卡住,这时候可以用gcore生成核心转储,再用jstack和jmap分析。当然,这些操作需要提前在环境里准备好工具,临时装往往来不及。
4. 核心排查步骤:从宏观到微观的完整链路
4.1 第一步:用 jps 和 jstat 快速摸底
服务出问题的时候,你可能连是哪个 Java 进程都找不到。jps -l可以列出当前机器上所有 Java 进程及其主类,配合ps -ef | grep java能快速定位目标进程。拿到 PID 之后,第一件事不是 dump,而是用jstat看 GC 的整体情况。
# 每 1 秒输出一次 GC 统计,共输出 100 次 jstat -gcutil <pid> 1000 100这个命令会输出各个内存区域的使用率(Eden、Survivor、Old、Metaspace)以及 YGC、FGC 的次数和耗时。重点看两个指标:老年代使用率和Full GC 频率。如果老年代使用率一直在 90% 以上,Full GC 后也降不下来,那基本可以确定是内存泄漏。如果 Full GC 频率很高但每次都能回收一些,可能是内存分配速率太高,需要调整堆大小或者优化代码。
jstat的好处是轻量,对应用影响很小,适合在生产环境快速摸底。但它只能告诉你“有问题”,不能告诉你“问题在哪”。要定位具体对象,还得靠 dump。
4.2 第二步:生成堆快照的正确姿势
生成堆快照有两种方式:自动和手动。自动就是前面说的-XX:+HeapDumpOnOutOfMemoryError,OOM 发生时 JVM 自己 dump。手动则是用jmap命令。
# 生成堆快照,live 表示只 dump 存活对象,会触发一次 Full GC jmap -dump:live,format=b,file=/tmp/heapdump.hprof <pid> # 如果只想知道对象统计,不生成文件 jmap -histo:live <pid> | head -50这里有个细节值得说:live参数会先触发一次 Full GC,只 dump 存活对象。好处是文件小、分析快,坏处是 Full GC 会 STW,对线上服务有影响。如果服务已经不可用,那无所谓;如果服务还在勉强运行,要谨慎使用。不加live的话,dump 的是所有对象,包括已经垃圾但还没回收的,文件会大很多,但能看到更完整的内存分布。
实操心得:
jmap -histo:live其实是个被低估的工具。它不需要生成大文件,直接输出对象数量和占用大小的排行。很多时候,你一眼就能看到某个类的实例数量异常多,比如java.util.HashMap$Node有几百万个,那问题方向就明确了。
4.3 第三步:用 MAT 或 JProfiler 分析 dump 文件
dump 文件拿到手,接下来就是分析。常用的工具有 Eclipse MAT、JProfiler、VisualVM。我个人最常用的是 MAT,因为它免费、功能强,而且有专门的泄漏检测报告。打开 dump 文件后,先看两个东西:Leak Suspects 报告和Dominator Tree。
Leak Suspects 是 MAT 自动生成的泄漏嫌疑报告,它会告诉你哪些对象占用了最多内存,以及它们的引用链。Dominator Tree 则是按对象支配的内存大小排序,能帮你找到“如果这个对象被回收,能释放多少内存”。这两个视图结合起来,基本能定位到问题对象。
但这里有个坑:占用内存最多的对象,不一定是泄漏的对象。比如一个缓存对象占用了 2G 内存,但它本来就是设计成这样的,那它不是泄漏。真正的泄漏往往是那些“数量持续增长、且不应该增长”的对象。所以分析 dump 时,最好能对比两个时间点的 dump,看哪些对象的数量在增长。如果条件不允许,那就结合代码逻辑判断。
4.4 第四步:结合线程栈定位代码位置
找到可疑对象后,下一步是找到“谁在创建这些对象”。这时候线程栈就派上用场了。jstack可以输出当前所有线程的调用栈,如果 OOM 发生时你正好抓到了栈,就能看到是哪个方法在大量分配对象。
# 输出线程栈到文件 jstack <pid> > /tmp/threaddump.txt # 连续抓取多次,观察线程状态变化 for i in {1..5}; do jstack <pid> > /tmp/threaddump-$i.txt; sleep 2; done在 MAT 里,你也可以右键点击某个对象,选择“Find References”或“Path to GC Roots”,看它是被谁引用的。如果发现某个 ThreadLocal 持有大量对象,或者某个静态集合在不断增长,那问题就找到了。我处理过的一个案例是,某个本地缓存用了static Map,但没有淘汰策略,数据越积越多,最终 OOM。这种问题从 dump 里一眼就能看出来:HashMap的实例数量异常,且引用链指向一个静态变量。
5. 典型场景实战:从现象到根因的完整推演
5.1 场景一:堆内存缓慢增长,最终 OOM
这是最常见的一类。服务刚启动时一切正常,运行几天后内存逐渐上涨,最终 OOM。监控曲线呈现明显的“锯齿形”——每次 Full GC 后内存下降一点,但整体趋势向上。这种基本可以判定为内存泄漏。
排查思路:先 dump 两个时间点的堆快照,对比对象数量变化。重点看那些“只增不减”的对象。常见的有:静态集合、缓存未设过期、监听器未注销、ThreadLocal 未清理、连接未关闭。找到可疑对象后,用jstack看线程栈,定位到具体代码。修复方式通常是加上清理逻辑或者改用有界缓存。
5.2 场景二:流量突刺导致瞬间 OOM
服务平时运行正常,但某个时间点突然 OOM,之后又恢复。监控显示当时 QPS 飙升。这种情况往往不是内存泄漏,而是瞬时对象分配速率超过了堆的承受能力。比如某个接口一次性查询大量数据、某个批量任务同时处理太多记录。
排查思路:先看 GC 日志,确认 OOM 前是否有频繁的 Young GC 和 Full GC。再看业务日志,确认当时是否有异常流量或批量任务。如果是流量问题,可以考虑限流、熔断、异步处理;如果是代码问题,比如一次性加载太多数据,那就改成分页查询或流式处理。这种场景下,调大堆内存只能缓解,不能根治。
5.3 场景三:元空间溢出,堆却很正常
前面提到的动态类生成问题。服务运行一段时间后报MetaspaceOOM,但堆内存使用率很低。用jstat -gcutil看,Metaspace 区域持续增长,Full GC 也回收不了。
排查思路:用jmap -clstats <pid>查看类加载器统计,看哪个类加载器加载了大量类。再用jcmd <pid> GC.class_stats查看类级别的内存占用。常见原因是反射、动态代理、脚本引擎(如 Groovy、JavaScript 引擎)在运行时不断生成新类。解决办法是缓存生成的类,或者限制类加载器的生命周期。
5.4 场景四:直接内存溢出,堆 dump 看不出问题
用了 Netty 或 NIO 的服务,报Direct buffer memory。堆 dump 分析下来一切正常,但服务就是会挂。这种问题的排查需要用到-XX:NativeMemoryTracking参数。
# 开启本地内存追踪 -XX:NativeMemoryTracking=detail # 查看本地内存使用情况 jcmd <pid> VM.native_memory summary通过这个命令,可以看到 JVM 各个区域的内存使用,包括堆、元空间、线程栈、直接内存等。如果直接内存持续增长,那就要检查代码中 ByteBuffer 的分配和释放逻辑。Netty 用户要特别注意ByteBuf的release()调用,漏掉一次就泄漏一块内存。
| 场景 | 现象特征 | 首选排查工具 | 常见根因 |
|---|---|---|---|
| 内存泄漏 | 内存缓慢增长,Full GC 后不降 | jmap + MAT | 静态集合、缓存、ThreadLocal |
| 流量突刺 | 瞬间 OOM,之后恢复 | GC 日志 + 监控 | 大对象、批量查询、限流缺失 |
| 元空间溢出 | Metaspace 持续增长 | jcmd + jmap -clstats | 动态类生成、类加载器泄漏 |
| 直接内存溢出 | 堆正常但服务挂 | NMT + jcmd | ByteBuf 未释放、NIO 使用不当 |
6. 避坑指南与常见问题速查
6.1 那些年我踩过的坑
第一个坑:dump 文件太大,分析工具打不开。生产环境的堆动辄几个 G,dump 出来可能十几个 G,MAT 默认的内存配置根本加载不了。解决办法是修改 MAT 的MemoryAnalyzer.ini,把-Xmx调大,比如-Xmx8g。如果文件实在太大,可以用jmap -histo先看个大概,或者用jhat做初步分析。
第二个坑:在容器里 dump 到宿主机找不到文件。容器里的路径和宿主机不一样,dump 完发现文件不知道去哪了。解决办法是提前把 dump 路径挂载到宿主机,或者 dump 完用docker cp拷出来。另外容器里的 JVM 看到的可用内存可能和宿主机不一样,堆参数要配合-XX:MaxRAMPercentage使用。
第三个坑:OOM 后进程僵死,jmap 执行不了。这时候可以试试jcmd,它比jmap更轻量。如果jcmd也不行,那就只能用gcore生成核心转储,再用jmap从核心转储里提取堆信息。这个操作比较重,但总比没有现场强。
第四个坑:只看堆,不看其他区域。前面反复强调过,OOM 不只有堆溢出。元空间、直接内存、线程栈都可能出问题。排查时一定要先看报错类型,再决定排查方向。
6.2 常见问题速查表
| 问题 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| Full GC 频繁但回收效果差 | 内存泄漏或堆太小 | jstat + dump | 定位泄漏对象或调大堆 |
| 堆内存正常但服务变慢 | GC 停顿或直接内存问题 | GC 日志 + NMT | 调整 GC 策略或检查堆外内存 |
| 类加载数量持续增长 | 动态类生成未缓存 | jmap -clstats | 缓存类或限制类加载器 |
| 线程数暴涨 | 线程池配置不当 | jstack + 监控 | 调整线程池参数 |
| dump 文件分析无异常 | 可能是直接内存或元空间 | NMT + jcmd | 检查堆外内存使用 |
6.3 预防胜于治疗:日常该做的几件事
与其等 OOM 了再手忙脚乱,不如平时就把基础打好。我的习惯是:所有生产服务必须开启 GC 日志和 OOM 自动 dump,这是底线。其次,监控要覆盖堆内存、元空间、直接内存、线程数、GC 频率和耗时,任何一个指标异常都能提前告警。第三,定期做压测和容量评估,知道服务的极限在哪里。第四,代码审查时关注集合使用、缓存策略、资源关闭,很多 OOM 都是代码层面埋的雷。
还有一点容易被忽略:JVM 参数的版本差异。JDK 8 和 JDK 11、JDK 17 的 GC 策略和默认参数都不一样。比如 JDK 8 默认是 Parallel GC,JDK 9 之后默认是 G1。同样的参数在不同版本上效果可能完全不同。升级 JDK 时,一定要重新评估 JVM 参数,别直接照搬。
7. 工具选型与效率提升
7.1 命令行工具:快速响应的第一选择
jps、jstat、jmap、jstack、jcmd这五个命令,基本覆盖了 80% 的排查场景。它们的优势是轻量、快速、不需要额外安装。我通常的组合是:jps找进程,jstat看 GC,jmap -histo看对象排行,jstack看线程栈,jcmd做综合诊断。这套组合拳打下来,大部分问题都能定位个七七八八。
jcmd值得单独提一下,它是 JDK 7 之后引入的多功能命令,可以替代很多老命令。比如jcmd <pid> GC.heap_info看堆信息,jcmd <pid> Thread.print看线程栈,jcmd <pid> VM.native_memory看本地内存。一个命令搞定多种诊断,效率很高。
7.2 图形化工具:深度分析的利器
MAT 是堆 dump 分析的首选,它的 Leak Suspects 和 Dominator Tree 能快速定位问题。JProfiler 功能更全,但收费。VisualVM 免费且集成度高,适合做初步分析。如果团队有预算,JProfiler 的实时监控和 CPU 分析功能确实好用。如果预算有限,MAT + VisualVM 的组合也够用了。
实操心得:MAT 的 OQL(对象查询语言)是个高级功能,可以像查数据库一样查堆里的对象。比如
SELECT * FROM java.util.HashMap能列出所有 HashMap 实例。熟悉 OQL 之后,定位特定类型的对象会快很多。
7.3 监控体系:把问题扼杀在萌芽期
工具再好,也是事后排查。真正高效的团队,靠的是监控体系提前发现问题。Prometheus + Grafana 可以采集 JVM 指标,配合 Micrometer 或 Spring Boot Actuator,能实时看到堆内存、GC、线程等数据。设置合理的告警阈值,比如老年代使用率超过 80% 持续 5 分钟就告警,这样在 OOM 之前就能介入。
日志体系也很重要。GC 日志、应用日志、错误日志要集中收集,方便关联分析。ELK 或 Loki 都是不错的选择。当 OOM 发生时,你能快速检索到相关的日志片段,而不是在几十台机器上挨个翻。
8. 个人经验总结
排查 OOM 这件事,工具和命令只是表象,真正决定效率的是排查思路。我的经验是:先分类,再定位,后验证。分类就是看清楚 OOM 的类型,是堆、元空间、直接内存还是其他;定位就是通过 dump、GC 日志、线程栈找到可疑对象和代码位置;验证就是修复后观察监控,确认问题真的解决了。
另外,不要迷信经验,要相信数据。我见过太多人凭直觉说“肯定是内存泄漏”,结果查了半天发现是堆配置太小。也见过有人一口咬定“代码没问题”,结果 dump 出来一看,某个集合涨了几百万个对象。数据不会骗人,dump 文件和 GC 日志是最诚实的证人。
最后分享一个小技巧:如果你不确定某个对象是不是泄漏,可以间隔一段时间 dump 两次,用 MAT 的“Compare Basket”功能对比两个 dump,看哪些对象的数量在增长。这个方法特别适合排查缓慢泄漏,比单次 dump 分析准确得多。OOM 排查没有银弹,但有一套靠谱的方法论,加上平时积累的工具熟练度,大部分问题都能在半小时内定位到方向。