拓冰建站拓冰建站
首页 / 资讯中心 / 正文

OOM排查实战:从JVM内存溢出现场到根因定位的全流程

你是否也遇到过这种瞬间半夜手机疯狂震动群里丢过来一段报错截图你打开终端敲下几行命令盯着满屏的堆栈信息脑海里只剩一句话——“这里是地狱啊……”这是很多开发者在排查线上内存溢出OOM、应用假死、接口超时问题时最真实的感受。明明代码在自己电脑上跑得好好的一到生产环境就频繁 Full GC内存蹭蹭往上涨重启之后过几个小时又原形毕露。更麻烦的是这类问题不像空指针一样有明确的堆栈行号现场数据转瞬即逝稍不留神就错过了最佳排查时机。这篇文章不是给你灌鸡汤的。我准备把“地狱难度”的 OOM 排查拆成一条可以照着走的完整链路从 JVM 内存概念、现场数据采集到堆转储分析、线程定位、根因确认再到生产环境如何预防和快速恢复全部用一套可以重复执行的流程讲清楚。文章适合这几类读者刚接触 JVM 调优想搞懂 OOM 到底是什么的新手。后端开发遇到过java.lang.OutOfMemoryError: Java heap space不知道怎么定位根因。运维或 SRE需要掌握线上问题排查的基本武器。准备面试 JVM 内存相关问题的同学可以作为系统化笔记收藏。读完这篇文章你能掌握以下能力分清 OOM 的几种常见类型以及各自的触发原因。知道哪些 JVM 参数和工具能在第一时间保留现场。能独立完成一次 heap dump 的分析找到“谁占满了堆内存”。掌握 OOM 排查的完整思路避免靠猜、靠重启、靠碰运气。下面我们正式开始。先从一个最基础的问题说起OOM 到底是怎么发生的。1. 遇到“这里是地狱啊”先搞清楚内存溢出到底是什么1.1 一次线上 OOM 问题的典型画面先来描述一个非常典型的场景。某个 Spring Boot 服务部署在 4G 堆内存的容器里运行一段时间后监控面板显示堆内存使用率持续走高从 40% 一路爬到 95%GC 频率越来越快Full GC 从每小时几次变成每分钟几次接口响应时间从 50ms 涨到 3 秒甚至出现请求超时日志里开始出现java.lang.OutOfMemoryError: Java heap space服务最终假死健康检查失败被容器编排系统自动重启。重启之后内存使用暂时恢复正常但几小时后历史重演。这种“定时炸弹式”的问题就是典型的 OOM 排查场景。它之所以让人头疼是因为问题本身不总是在代码的某一行“显式报错”而是内存被一点一点耗尽。你看到的是 GC 压力、接口变慢、进程挂掉但真正的原因可能藏在一个不起眼的缓存、一条未关闭的流、一个不合理的集合初始化大小里。如果只是“重启一下”问题并没有被解决只是在等待下一次爆发。1.2 OOM 与内存泄漏、频繁 Full GC 的区别很多初学者会把几个概念混在一起这里先做个区分。内存溢出OOM指 JVM 申请内存时内存空间已经不足无法分配所需对象于是抛出OutOfMemoryError。比如堆内存被占满新对象无法分配。内存泄漏Memory Leak指对象已经不再被业务使用但仍然被某些引用链持有导致垃圾回收无法回收它们。内存泄漏是 OOM 的常见诱因之一泄漏对象越积越多最终把堆内存耗尽触发 OOM。频繁 Full GC指 JVM 频繁触发全局垃圾回收但回收效果很差大量内存无法被释放。它的最终结果往往也是 OOM但在此之前服务会先出现明显的性能下降。可以用一句话概括它们的关系内存泄漏 → 可用内存越来越少 → Full GC 越来越频繁 → 最终触发 OOM。所以排查 OOM 时不要只盯着“内存不够”这个结果还要向上追两层内存是被谁占用的为什么垃圾回收收不掉2. 环境准备与排查工具清单2.1 本文演示环境说明本文使用 Java 语言演示重点讲清楚排查思路和命令。版本以常见 JDK 8 / JDK 11 环境为例命令在两者上基本兼容。如果你使用的是 JDK 17 或更高版本要注意一点部分 JDK 的内部类发生了变化一些排查工具对 JDK 版本有要求使用前先确认版本匹配不过本文涉及的核心命令仍然适用。操作系统方面Linux 服务器是最常见的生产环境下文命令均以 Linux 为前提。Windows 本地调试时命令本身一致文件路径和jmap、jstat等工具的 PID 进程号获取方式略有差异示例中会单独说明。现场准备清单大致如下工具或组件用途说明JDK 8运行 Java 程序本文演示以 JDK 8 为基准jps查看 Java 进程JDK 自带jstat查看 JVM GC 状态JDK 自带jmap生成堆转储文件JDK 自带jstack生成线程转储文件JDK 自带jcmd综合性 JVM 诊断命令JDK 7 自带MAT 或 JProfiler分析 heap dump需要单独安装Arthas在线诊断可选推荐生产环境使用有一个原则值得刻在脑子里生产环境排查问题能先保留现场就一定不要急着重启进程。因为一旦重启堆内存里的所有对象都会消失OOM 的“犯罪现场”被销毁排查难度会大幅上升。2.2 几个高频命令的使用说明在进入完整案例之前先认识一下最常用的几条排查命令。它们是整个排查链路的地基。jps用来查看当前系统中有哪些 Java 进程jps -l输出示例25472 /opt/app/demo-boot.jar 31807 sun.tools.jps.Jps第一列是进程号 PID第二列是主类或启动 jar 包路径。拿到 PID 之后后续所有命令都围绕它展开。jstat用来查看 JVM 的 GC 情况是判断“内存压力大不大”的第一步jstat -gcutil 25472 1000 10这里的含义是每隔 1000 毫秒输出一次 GC 统计一共输出 10 次。重点关注EEden、O老年代、FGCFull GC 次数和FGCTFull GC 累计时间。jmap用来生成堆转储快照是分析 OOM 根因的关键命令jmap -dump:live,formatb,file/tmp/heap.hprof 25472jstack用来查看线程栈可以配合 OOM 场景分析“是哪个线程在不停创建对象”jstack 25472 /tmp/thread_dump_$(date %Y%m%d_%H%M%S).logjcmd是 JDK 7 之后更推荐的诊断命令能力比jmap更全面而且不用依赖tools.jarjcmd 25472 GC.heap_dump /tmp/heap_jcmd.hprof到这里工具已经认识了。接下来进入核心原理部分看看 OOM 为什么会有那么多种表现。3. 核心原理JVM 内存布局与 OOM 分类3.1 JVM 运行时内存区域JVM 的内存区域可以粗略分成以下几块堆内存Heap存放对象实例是 OOM 出现最频繁的区域。元空间Metaspace存放类元数据、方法信息JDK 8 之后取代了永久代。虚拟机栈VM Stack每个线程私有的栈方法调用时创建栈帧。本地方法栈Native Method Stack为 native 方法服务。程序计数器PC Register记录当前线程执行字节码的行号。直接内存Direct Memory由 NIO 等机制使用不受堆大小限制但受物理内存限制。大多数 OOM 集中在堆内存但元空间、虚拟机栈、直接内存也都可能抛出 OOM。不同区域的 OOM 报错信息不同排查方向也完全不同。3.2 常见 OOM 类型对比下面是几种最常见的 OOM 报错以及它们的含义和排查方向。报错信息含义常见原因排查方向Java heap space堆内存不够对象过多、内存泄漏、堆设置过小dump 堆分析对象引用GC overhead limit exceededGC 几乎一直在运行但回收效率极低堆空间几乎被占满GC 回收不掉结合 GC 日志判断回收率Metaspace元空间不足动态生成类过多、字节码增强检查 CGLIB、反射、自定义类加载器Unable to create new native thread无法创建新线程线程数超过系统限制检查线程数量降低线程栈大小Direct buffer memory直接内存不足NIO 使用不当堆外内存泄漏检查 ByteBuffer 是否释放StackOverflowError栈深度溢出无限递归检查递归方法和循环调用注意StackOverflowError虽然也是Error但它一般由递归深度过大导致和“内存耗尽”的 OOM 场景不完全相同排查方式也有区别。3.3 关键 JVM 参数说明排查 OOM 时合理的 JVM 参数能让我们在事故发生的第一时间拿到现场数据。以下参数建议在启动脚本中显式配置java -Xms2g -Xmx2g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heapdump/${APP_NAME}_$(date %Y%m%d_%H%M%S).hprof \ -XX:PrintGCDetails \ -XX:PrintGCDateStamps \ -Xloggc:/data/logs/gc.log \ -jar demo-boot.jar逐条解释关键参数-Xms表示堆内存初始大小-Xmx表示堆内存最大大小。生产环境建议两个值设置成一样避免运行期堆扩容带来的性能波动。-XX:HeapDumpOnOutOfMemoryError表示发生 OOM 时自动导出一份 heap dump 文件。这个参数极其重要很多 OOM 现场就是靠它保住的。-XX:HeapDumpPath指定 dump 文件的保存路径。注意路径目录需要提前创建并且容器内的进程要有写入权限。-XX:PrintGCDetails和-XX:PrintGCDateStamps用于打印 GC 详细日志和日期。GC 日志是判断内存压力、回收效率的重要依据。-Xloggc指定 GC 日志输出文件。这里需要提醒一句在 JDK 11 之后PrintGCDetails和Xloggc等参数已被新的-Xlog体系取代。如果你用 JDK 11需要根据版本调整启动参数。文章示例以 JDK 8 为基准展开同时提醒读者根据实际版本调整。4. 完整实战从告警到定位内存溢出的根因下面我们完整跑一遍 OOM 排查流程。为了便于理解这里用一个模拟程序模拟“内存泄漏导致 OOM”的场景把常见的排查工具和思路全部串起来。4.1 编写一个模拟 OOM 的程序先写一个非常简单的 Java 程序。它维护一个静态集合不停往里面添加对象模拟“对象被长期持有无法回收”的情况。文件路径src/main/java/com/example/oom/OomDemo.javapackage com.example.oom; import java.util.ArrayList; import java.util.List; import java.util.UUID; public class OomDemo { // 静态集合模拟缓存未清理导致对象无法回收 private static final ListString CACHE new ArrayList(); public static void main(String[] args) throws InterruptedException { System.out.println(OOM Demo Start); int index 0; while (true) { // 不断向集合中添加对象 CACHE.add(UUID.randomUUID().toString()); index; if (index % 10000 0) { System.out.println(当前已添加对象数量: CACHE.size()); // 每添加 1 万个对象睡 100ms方便观察内存变化 Thread.sleep(100); } } } }这段代码很简单但非常经典。CACHE是静态变量生命周期和进程一样长。只要程序不退出集合里的对象就永远不会被垃圾回收。随着循环不断执行堆内存最终会被占满。在实际业务中类似的场景可能是用静态 Map 做本地缓存只放不清理。把请求对象、会话对象存入没有失效机制的集合。使用线程池时ThreadLocal没有被清理对象被线程绑定。这些代码都有一个共同特点从内存视图看它们和上面的CACHE没有本质区别。4.2 复现并采集现场数据编译并运行程序。为了让问题快速暴露我们把堆内存调小。# 编译 javac src/main/java/com/example/oom/OomDemo.java # 运行堆内存初始和最大均为 256m并开启 OOM 自动 dump java -Xms256m -Xmx256m \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/tmp/oom_demo.hprof \ -cp src/main/java com.example.oom.OomDemo程序运行后可以看到如下输出OOM Demo Start 当前已添加对象数量: 10000 当前已添加对象数量: 20000 当前已添加对象数量: 30000 ... 当前已添加对象数量: 140000 Exception in thread main java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3336) at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124) at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448) at java.lang.StringBuilder.append(StringBuilder.java:136) at com.example.oom.OomDemo.main(OomDemo.java:23)同时因为启动了-XX:HeapDumpOnOutOfMemoryError目录下会生成一个oom_demo.hprof文件ls -lh /tmp/oom_demo.hprof输出示例-rw------- 1 root root 267M 7月 8 14:22 /tmp/oom_demo.hprof到这里现场已经被保住了。接下来开始分析。4.3 定位可疑线程线程转储在真实的线上环境中你不一定总是能直接看到程序日志很多时候是接到告警后手动去查。这时候先用jps找到进程号再用jstack看一下线程在做什么。jps -l28561 com.example.oom.OomDemo拿到 PID 后生成线程转储jstack 28561 /tmp/thread_dump_28561.log查看线程栈中与业务相关的部分grep -A 20 com.example.oom /tmp/thread_dump_28561.log在线程转储中通常能看到业务线程的调用栈比如main #1 prio5 os_prio0 tid0x00007f9a4800b800 nid0x1f3b runnable [0x00007f9a4e3f4000] java.lang.Thread.State: RUNNABLE at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448) at java.lang.StringBuilder.append(StringBuilder.java:136) at com.example.oom.OomDemo.main(OomDemo.java:23)这说明 main 线程正在不停执行CACHE.add()也就是那个不断添加对象的地方。这个信息能帮我们快速判断“哪些线程在消耗内存”但要注意线程栈只反映某一瞬间的调用情况不能直接证明内存是被谁占用的。要确认内存对象归属必须分析堆转储。4.4 分析堆转储找到内存中的大对象堆转储文件可以用 MATEclipse Memory Analyzer分析。MAT 可以从官网独立下载无需安装 Eclipse IDE下载后解压即可使用。打开 MAT加载oom_demo.hprof文件。MAT 会先展示一份概览报告通常重点看两个视图第一个是Leak Suspects泄漏嫌疑点。MAT 会自动计算哪些对象占用了大量内存并给出引用链分析。在这个模拟场景中MAT 大概率会直接指出OomDemo类的静态字段CACHE持有大量String对象。第二个是Dominator Tree支配树。它以对象为节点展示每个对象“支配”了多少内存能帮我们快速找到内存占用最大的对象。在 Dominator Tree 中你会看到类似这样的结构class org.example.oom.OomDemo 0x12345678 └─ static CACHE java.util.ArrayList 0x87654321 ├─ java.lang.String 0x0000001 ├─ java.lang.String 0x0000002 ├─ ...到这里根因已经非常清晰大量String对象被保存在静态CACHE集合中集合永远不会被清理导致堆内存被耗尽。4.5 结合业务代码确认根因有了堆转储的分析结果最后一步是回到业务代码确认“为什么这些对象没有被回收”。在本例中原因是静态变量持有对象引用集合只增不减。解决办法可以分几个层面第一如果这个集合确实是业务需要的缓存应该引入过期机制比如使用Caffeine或Guava Cache而不是用ArrayList无限存放。第二如果不需要缓存就移除静态集合用局部变量代替。第三如果集合中的数据量是可预见的可以在初始化时指定合理容量减少频繁扩容带来的临时对象分配。用Caffeine改造后示例代码如下package com.example.oom; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.time.Duration; import java.util.UUID; public class OomDemoFixed { // 使用 Caffeine 本地缓存设置最大容量和过期时间 private static final CacheString, String CACHE Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(Duration.ofMinutes(5)) .build(); public static void main(String[] args) throws InterruptedException { System.out.println(OOM Demo Fixed Start); int index 0; while (true) { String key UUID.randomUUID().toString(); CACHE.put(key, key); index; if (index % 10000 0) { System.out.println(当前缓存条数: CACHE.estimatedSize()); Thread.sleep(100); } } } }这个示例只是演示思路实际项目中引入 Caffeine 前需要先添加对应依赖并评估最大容量、过期时间和业务场景是否匹配。到这里一次完整的 OOM 排查流程就走完了。整体链路可以概括为收到告警 → 保留现场heap dump / thread dump / GC 日志 → 分析线程栈找可疑线程 → 分析堆转储找大对象 → 结合业务代码确认根因 → 修复并验证5. 常见问题与排查思路5.1 高频问题排查表下面是 OOM 排查中非常高频的几类问题按“现象 → 原因 → 思路”整理成表。问题现象常见原因解决思路Java heap space堆内存设置过小或存在内存泄漏检查堆配置dump 堆分析大对象GC overhead limit exceededGC 回收效率极低堆几乎被占满优先看 GC 日志判断哪些对象无法回收Metaspace溢出动态生成类过多检查反射、CGLIB、自定义类加载器是否重复加载Unable to create new native thread线程数量超过系统资源上限检查线程池配置减小线程栈-Xss频繁 Full GC 但堆内存不降存活对象过多回收不掉dump 堆查看老年代中对象的引用链GC 正常但内存持续上涨可能是直接内存或堆外内存泄漏检查 NIO、Netty 等堆外内存使用5.2 一个容易忽略的坑直接内存堆内 OOM 相对容易发现因为报错信息明确dump 工具也成熟。但直接内存Direct Memory引发的 OOM 往往会让人陷入误区。直接内存由ByteBuffer.allocateDirect()或 Netty 的PooledByteBufAllocator等机制使用不占用堆内存而是占用进程的本地内存。当直接内存被耗尽时你可能看到这样的报错java.lang.OutOfMemoryError: Direct buffer memory此时用jmap生成堆 dump堆内可能一切正常GC 压力也不大。真正的原因是堆外内存被大量 ByteBuffer 占满而这些 ByteBuffer 如果没有被显式释放或无法被 GC 及时回收就会持续累积。排查方向包括检查代码中是否频繁创建DirectByteBuffer而没有及时释放。检查 Netty 等框架的池化内存配置是否合理。结合pmap、NMTNative Memory Tracking等工具分析堆外内存占用。如果启用 NMT启动参数可以加上-XX:NativeMemoryTrackingsummary然后使用jcmd查看jcmd pid VM.native_memory summary需要注意NMT 本身也会带来少量性能开销生产环境开启前要评估影响。5.3 排查时最容易犯的错先重启排查线上问题最重要的原则是“先保留现场再考虑恢复”。很多运维同学遇到 OOM 的第一反应是“重启大法好”这确实能让服务立刻恢复但同时也会把最重要的堆转储现场彻底销毁。如果启动时没有配置-XX:HeapDumpOnOutOfMemoryErrorOOM 发生的那一瞬间内存中的所有证据都会随进程消失。所以强烈建议所有 Java 服务在启动参数中默认开启 OOM dump。这个习惯能帮你在事故发生后保留一手证据而不是两眼一抹黑。6. 最佳实践与工程建议6.1 如何从源头减少 OOMOOM 的排查成本很高最好的策略其实是“提前预防”。下面几条建议来自于日常工程实践值得逐条对照检查。第一统一 JVM 启动参数。把-Xms和-Xmx设置为相同的值避免堆自动扩容开启HeapDumpOnOutOfMemoryError根据 JDK 版本配置 GC 日志。将这些参数固化为标准启动模板所有服务统一使用。第二谨慎使用静态集合和缓存。静态集合的生命周期和进程一致一旦对象被放进去就很难被回收。如果必须使用缓存优先选择成熟方案比如 Caffeine、Guava Cache并配置最大容量和过期策略。第三关注批量处理逻辑。分页查询、批处理导入、报表导出等场景中容易一次性把大量数据加载到内存造成短暂的内存峰值。建议使用流式处理或分批处理控制单次驻留内存的对象数量。第四监控堆内存和 GC 指标。至少监控以下指标堆内存使用率、老年代使用率、Full GC 次数、Full GC 耗时、GC 后存活对象大小。不要等到 OOM 才去关注内存内存使用率超过 70% 并持续升高时就应该开始调查。第五发布前做压力测试。很多 OOM 问题不是代码逻辑错误而是并发上来之后内存被流量放大。上线前使用压测工具模拟高峰流量提前观察内存曲线能发现很多隐藏问题。6.2 监控、告警与快速恢复即使做了很多预防线上环境依然可能出现意外。这时一套完善的监控和恢复机制就格外重要。推荐的做法是使用 Prometheus Grafana 采集 JVM 指标配置堆内存使用率、GC 次数、GC 耗时的告警规则。告警阈值不要只在“OOM 发生后”才触发而是分两级预警级别比如堆内存持续 80% 以上 10 分钟和严重级别Full GC 频繁或已发生 OOM。容器化部署时配置合理的健康检查和自动重启策略。但要注意自动重启是一把双刃剑它能快速恢复服务也可能把现场证据一并抹掉。建议进程启动时先异步上传已有的 heap dump 和 GC 日志到对象存储再执行重启。保留最近 N 次的 GC 日志和线程栈快照便于事后回溯。6.3 排查工具链沉淀在团队内部可以把排查步骤固化为一份“OOM 排查手册”或脚本。例如发生 OOM 时一条命令同时收集线程栈、堆 dump、GC 日志、系统负载等现场信息#!/bin/bash PID$1 TIME$(date %Y%m%d_%H%M%S) DIR/data/logs/diagnose/$PID mkdir -p $DIR # 保存线程栈 jstack $PID $DIR/thread_$TIME.log 21 # 保存堆 dump生产环境注意执行时机 jmap -dump:live,formatb,file$DIR/heap_$TIME.hprof $PID 21 # 保存 GC 统计快照 jstat -gcutil $PID 1000 10 $DIR/gcutil_$TIME.log 21 # 保存进程内存映射 ps -p $PID -o pid,rss,vsz,comm $DIR/ps_$TIME.txt 21 echo 诊断文件已保存至: $DIR这个脚本只是一个起点你可以根据团队环境扩展。注意jmap -dump:live会触发一次 Full GC因此在高压力生产环境执行时要非常谨慎尽量在业务低峰期或已经确认需要 dump 时再执行。7. 总结与学习路线这一整条链路走下来你会发现 OOM 并没有想象中那么“地狱”。它遵循的其实是一套固定流程先搞清楚 OOM 发生在哪个内存区域再通过 GC 日志判断内存压力趋势用线程栈锁定可疑线程用堆转储定位大对象最后回到业务代码确认根因。核心工具无非是jps、jstat、jmap、jstack、jcmd再配合 MAT 做离线分析。如果你刚接触这个方向建议按下面的顺序逐步加深先理解 JVM 内存区域划分能说出堆、元空间、虚拟机栈、直接内存各自存放什么。动手跑一遍文中的模拟 OOM 程序用jmap生成 dump用 MAT 打开观察静态集合里的对象。学习 GC 日志的读法能看懂 Young GC、Full GC、老年代使用率之间的关系。了解 Caffeine、Guava Cache 等本地缓存方案的过期和淘汰机制避免自己在代码里写“无限集合”。如果继续深入可以学习 Arthas 等在线诊断工具实现不重启进程的现场诊断。实际项目中OOM 排查最珍贵的不是某一条命令而是“第一时间保留现场”的意识和一套可重复执行的流程。下次再遇到内存告警先别急着说“这里是地狱”试着按这篇文章的链路走一遍一步步看数据说话问题通常比你想象中更清晰。如果本文对你有帮助可以收藏备用。也欢迎在评论区聊聊你印象最深的一次 OOM 排查经历分享出来大家都能一起少踩几个坑。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门