JVM OOM排查实战:从内存布局到案例复盘

发布时间:2026/9/19 4:02:53
JVM OOM排查实战:从内存布局到案例复盘 周一凌晨一点值班群炸了。订单服务的某个节点堆内存使用率持续在 95% 以上紧接着一条告警弹出来容器触发 OOM 被自动重启。等我打开监控面板看到那条又陡又斜的曲线心里基本有数了——又是 OutOfMemoryError内存溢出。做 Java 后端这些年OOM 大概是出现频率最高、也最容易被误判的故障之一。很多人一遇到 OOM 就条件反射式地重启、加内存、明天再看但真正把它当成一类有规律可循的问题去系统排查的人并不多。这篇文章整理的是我在多个项目里排查 OOM 的实际经验和完整路径。我会从 JVM 底层的内存划分讲起把几种常见 OOM 的表现和成因说清楚然后再给出一套可以直接照着做的排查流程配合三个真实案例复盘最后聊代码、JVM 参数、架构三个层面的优化手段。无论你是刚接触 JVM 的新人还是被线上告警折磨过几次的后端开发这篇都能当作战术手册用。1. 先搞懂OOM的本质它不是一个问题而是一类问题很多人在排查 OOM 时第一反应是堆内存不够了于是直接去调-Xmx。这个思路在少数场景下有用但更多时候会掩盖真正的问题。要想高效排查首先得建立正确的认知OOM 不是某一个单一故障而是 JVM 内部多个内存区域各自被耗尽的统称。1.1 JVM到底在什么情况下会抛出OutOfMemoryErrorJVM 在执行 Java 程序时会把内存划分为堆、元空间、线程栈、直接内存等区域。程序运行时如果某个区域需要分配内存但可用空间已经不足JVM 会先尝试触发垃圾回收。如果回收之后依然腾不出足够空间就会抛出OutOfMemoryError。注意OutOfMemoryError是Error而不是Exception它通常意味着进程已经进入了不健康状态。即使你在代码里手动 catch 住它后续请求大概率还是会继续失败因为内存已经被占满GC 也无力回天。所以排查 OOM 的目标不是捕获异常而是找到谁把内存吃掉了。这里有个容易忽略的细节JVM 的各个内存区域是独立管理的任何一个区域告急都会以 OOM 的形式表现出来。比如 Metaspace 满了抛的是OutOfMemoryError: Metaspace直接内存满了抛的是OutOfMemoryError: Direct buffer memory。如果你只盯着堆内存看很容易漏掉真正的病灶。1.2 别把OOM都当成堆不够常见的类型有这些我把实际开发和运维中经常遇到的 OOM 类型整理成了一个表。遇到问题时先对照一下异常信息基本能确定排查方向。异常信息对应内存区域典型原因Java heap space堆大对象过多、对象泄漏、瞬时流量峰值GC overhead limit exceeded堆GC 几乎占满 CPU 但仍回收不到内存Metaspace元空间动态生成类、CGLIB 代理、热部署残留Unable to create new native thread线程栈/操作系统内存线程数耗尽、线程池无上限Direct buffer memory直接内存NIO/Netty 堆外 Buffer 分配后未释放GC overhead limit exceeded是Java heap space的一种特殊形态。JVM 发现 GC 一直在跑但堆内存回收效果极差如果 GC 时间超过了总时间的一定比例默认 98%就会直接抛出这个错误来止损。它背后通常不是一次超大对象而是大量生命周期极短、互相引用的对象堆积导致 GC 疲于奔命。1.3 StackOverflowError是OOM的亲戚但容易被误判StackOverflowError严格来说不是OutOfMemoryError但它在某一块内存耗尽这件事上和 OOM 同源。线程的栈内存是固定的每个方法调用都会占用一个栈帧如果递归没有终止条件或者方法调用层级过深栈帧就会把栈内存彻底填满抛出StackOverflowError。为什么把它放在 OOM 体系里一起讲因为两者在排查思路上完全一致看异常栈、找最深的调用链、定位是谁在无限递归。我见过不少排查记录把StackOverflowError当成堆内存问题去调-Xmx方向从一开始就错了。后面第二个案例会详细展开一个和 Redis 相关的栈溢出场景那个问题表面上在opsForZSet().add()根因却藏得很深。2. JVM内存布局决定OOM的表现每个区域都有自己的死法既然要排查 OOM就必须对 JVM 内存布局有清晰的认知。这一节我把堆、元空间、直接内存、线程栈四个区域逐个拆开讲因为它们的溢出机制和排查方式完全不同。2.1 堆内存溢出要么泄漏要么峰值过大堆是 Java 对象的主要存放区域也是 OOM 的重灾区。堆内部又分为新生代Eden 区和两个 Survivor 区和老年代。大部分对象先在 Eden 区分配经过几轮 Minor GC 后仍然存活的对象会进入老年代。堆溢出从成因上可以分为两类这两类的排查方向完全不同第一类是对象泄漏。某个对象被全局容器比如static Map、Spring 单例 Bean 里的 List持有引用导致 GC 回收不掉。这种问题在内存曲线上表现为缓慢持续上升而且没有任何回落的趋势直到触顶崩溃。排查时重点找谁持有这些对象常见的泄漏源包括缓存没有过期策略、事件监听器注册后没有注销、ThreadLocal 里的对象在线程复用时没有清理等。第二类是对象峰值过大。某个瞬间大量请求同时进入或者某个业务操作一次性加载了海量数据比如把全表查出来做导出导致堆内存瞬间被打满。这种问题在曲线上表现为瞬时陡升尖峰。排查时重点看业务入口的流量变化以及是否有大集合、大数组的分配。这两种成因对应的处理方式完全不一样。泄漏类问题要修代码逻辑峰值类问题要加限流、分批或减少单次数据加载量。一上来就调-Xmx虽然能延缓下次 OOM 的时间但问题的根还在。2.2 Metaspace溢出动态生成类才是元凶JDK 8 之后方法区被移除类的元数据移到了本地内存中的 Metaspace。Metaspace 默认只受本地内存大小限制所以很多人容易忽略它。但只要你的应用在运行期间不断生成新的类Metaspace 就会像滚雪球一样膨胀。哪些场景会大量生成类最常见的是 CGLIB 或 Javassist 动态代理。比如某些框架在运行时根据实体类动态生成代理类每次重新加载或新建代理都会生成一个全新的类。还有热部署环境每一次热部署都会产生一个新的 ClassLoader如果旧的 ClassLoader 没有被释放其加载的所有类元数据都会残留在 Metaspace 里。这类 OOM 的日志特征是OutOfMemoryError: Metaspace。排查方法也比较固定在 JVM 参数中显式设置-XX:MaxMetaspaceSize上限这样至少在出问题时有告警再用jstat观察 Metaspace 的使用变化曲线最后从代码层面找动态生成类的源头。曾经有个项目每隔几分钟就会生成几千个代理类原因是工具类里每次方法调用都创建了一次 Enhancer后来改成缓存代理类实例Metaspace 立刻平稳了。2.3 直接内存溢出堆外内存的隐性问题直接内存Direct Memory是 JVM 通过ByteBuffer.allocateDirect或 NIO 框架比如 Netty分配的堆外内存。它不占用堆空间但受-XX:MaxDirectMemorySize限制。很多新手不知道这个参数的默认值其实等于-Xmx也就是说如果你把堆设成 4G那么直接内存最多也可能开到 4G再加上元空间和线程栈Total native memory 很容易超过物理内存。堆外内存的回收机制和堆内对象不一样。它通过与 GC 关联的 Cleaner 来回收但实际回收时机有很大延迟。如果代码里频繁分配 DirectByteBuffer 又忘记释放就会出现一种很诡异的情况堆内存正常、GC 正常但服务还是报OutOfMemoryError: Direct buffer memory。排查这种问题可以用 Netty 的PooledByteBufAllocator池化分配器减少频繁向操作系统申请内存业务侧要检查是否有大量 ByteBuffer 的分配没有走工具类、没有设置复用。曾经有一个文件上传服务频繁出现 Direct buffer memory最后定位到是每个请求都新建了 ByteBuffer 用来做流拷贝改成复用之后问题消失。2.4 线程栈耗尽一种经常被误会的假OOMUnable to create new native thread这句话很容易让人以为线程栈内存爆了其实它本质上是操作系统层面的内存分配失败。每个线程在创建时都需要分配一块栈空间默认大小在 512KB 到 1MB 之间。当一个 Java 进程创建的线程数量过多超出系统可用内存或进程线程数上限时就会抛出这个错误。这种 OOM 有个特点堆内存可能还很空闲甚至 GC 曲线非常漂亮但服务就是起不了新线程。常见原因包括线程池的核心线程数被设置得过大或者线程池的任务队列无限长导致线程被创建后一直占着资源等任务再或者代码里每来一个请求就new Thread(...).start()完全没有任何复用。排查时用jstack或 Arthas 的thread命令看当前线程数量和线程状态分布重点看是否有大量线程卡在同一个地方。如果想要快速止血可以调大ulimit -u上限、减少-Xss栈大小但真正靠谱的做法还是把线程资源统一收敛到有界线程池中并给线程池设置合理的拒绝策略。3. 标准排查流水线从保留现场到堆转储分析OOM 排查的难点在于现场往往转瞬即逝。服务一崩、容器一重启堆里的证据就全没了。所以成熟的排查体系第一步永远不是看代码而是留证据。3.1 第一步不是重启是保留现场线上服务发生 OOM 时最忌讳的是立刻kill -9然后拉起新容器。你应该先确认服务还没死的情况下立刻生成堆转储快照把现场固定下来。建议在生产环境的启动参数里提前加上这两个配置-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump意思是 JVM 在抛出 OOM 的同时自动把当时的堆快照保存到指定目录。这是最简单也最容易被忽略的一条配置。很多团队直到出问题才发现 heap dump 没开只能对着日志里的几行异常栈干瞪眼。我还建议预留一块独立的磁盘目录专门放 dump 文件。OOM 时的堆快照体积可能接近-Xmx的大小如果写到根目录一波操作下来磁盘先满了雪上加霜。3.2 日志、监控与命令行工具的组合使用保留现场之后再按顺序做这几件事看日志、看监控、用工具做在线分析。第一是看日志。包括应用日志、OOM 日志和 GC 日志。GC 日志能告诉你堆内存的使用趋势、GC 频率、各代区域的活动情况。JDK 8 用这几个参数开启-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.logJDK 9 之后用统一的日志参数-Xlog:gc*:file/data/logs/gc.log拿到 GC 日志之后重点看 Full GC 的频率和耗时。如果 Full GC 越来越频繁说明老年代内存持续膨胀对象回收不掉。第二是看监控曲线。正常情况下面临 OOM 前堆内存、GC 频率、线程数等指标会有一个持续恶化的过程。把这些曲线调出来能迅速判断是缓慢泄漏还是瞬时尖峰。第三是命令行工具。最常用的一组命令是# 找到 Java 进程的 PID jps -l # 查看堆各区域使用情况和 GC 次数 jstat -gcutil pid 1000 # 生成堆转储快照推荐 jcmd jcmd pid GC.heap_dump /data/dump/heap.hprof # 老版本 JDK 使用 jmap jmap -dump:live,formatb,file/data/dump/heap.hprof pid这里有个实际经验jmap -dump在生成快照时可能会触发 Full GC如果服务已经岌岌可危这个操作可能让服务直接卡死。所以优先用jcmd它相对轻量。另外-dump:live只保留活对象冻结引用关系的同时会执行一次 Full GC如果怀疑是对象泄漏最好生成不带live参数的完整 dump保留所有对象信息。Arthas 在线上排查中也很好用。通过dashboard看整体内存和线程情况thread -n 3找到 CPU 占用最高的线程heapdump命令则可以直接在线导出堆快照。Java Flight RecorderJFR也可以记录内存分配详情适合事后深挖。3.3 用MAT分析堆转储的具体路径拿到hprof文件之后最常用的分析工具是 Eclipse MAT。它是图形化界面加载大文件时有点吃内存建议分析时把 MAT 的虚拟机内存也调大一些。打开快照后按以下顺序走先看 Overview 页面获取堆的总体使用情况和最大的对象类型。然后点 Leak Suspects ReportMAT 会给出它认为可疑的泄漏点和对象持有的引用链。这个报告不是万能的但它能帮你快速锁定大方向。接着看 Histogram按 Retained Heap保留堆大小排序找到哪个类的实例占用内存最大。再对可疑的大对象右键查看 Path to GC Roots选择exclude weak references看它到底是被什么强引用持有的。这一步是定位泄漏源头的关键——如果 GC Roots 指向某个全局静态容器那基本可以断定问题出在缓存或单例 Bean 的维护上。实际操作中Leak Suspects 经常给出多个无关的怀疑点所以不要依赖它一步到位。更稳妥的方式是先通过 MAT 找到 Retained Heap 最大的几个对象再反查它们的引用链和业务含义。这一步需要结合业务代码工具只负责把内存里的案发现场还原出来。4. 三个线上案例复盘POI导出、Redis序列化、消息堆积理论说再多不如踏踏实实复盘几个真实案例。这三个案例分别对应堆溢出、栈溢出、以及业务设计缺陷间接引发 OOM三种模式是我认为最典型的代表。4.1 案例一POI导出Excel触发的Java heap space现象。运营后台的报表导出功能数据量大概二十多万行用户一点导出服务直接报java.lang.OutOfMemoryError: Java heap space。服务堆设了 2G平时内存占用很平稳只要导出就崩。排查。把堆转储快照拉到 MAT 分析Dominator Tree 里有一个对象占到了堆的 90% 以上类名是org.apache.poi.xssf.usermodel.XSSFWorkbook下面挂了大量的CTString、SharedStringsTable以及行和单元格对象。根因。这就是 POI 组件最经典的坑XSSFWorkbook是 DOM 模型它会把整个 Excel 文档的行、列、单元格、共享字符串表全部构建在内存中。二十万行数据意味着内存里同时存在几十万个 Java 对象2G 堆根本撑不住。修复。把XSSFWorkbook替换成 POI 提供的流式 APISXSSFWorkbook核心代码如下// 错误示范直接把所有数据一次性写入 XSSFWorkbook Workbook workbook new XSSFWorkbook(); Sheet sheet workbook.createSheet(报表); for (RowData row : allData) { // allData 是几十万条全量数据 Row r sheet.createRow(index); // ... } // 修复使用 SXSSFWorkbook 流式处理 SXSSFWorkbook workbook new SXSSFWorkbook(100); // 窗口大小 try (workbook) { Sheet sheet workbook.createSheet(报表); int rowIndex 0; // 数据分批查询每批 5000 条 for (ListRowData batch : queryByPage()) { for (RowData row : batch) { Row r sheet.createRow(rowIndex); // 写入单元格 } // 将超过窗口大小的行刷新到临时文件释放内存 ((SXSSFSheet) sheet).flushRows(100); } }SXSSFWorkbook(100)表示内存中只保留最近 100 行更早的行会被刷新到磁盘临时文件内存占用被大幅压缩。配合数据分页查询导出几十万行数据稳稳的。补充提醒。SXSSFWorkbook不支持读取已有模板中的公式求值结果也不支持某些复杂样式。如果你有特殊模板需求要提前做兼容性验证。另外流式导出会产生临时文件确认临时目录的磁盘空间和权限足够否则会报java.io.IOException: No space left on device。4.2 案例二RedisTemplate zset批量写入时的StackOverflowError现象。运营活动要做排行榜数据导入代码里用一个 for 循环调用redisTemplate.opsForZSet().add(rankKey, member, score)往 Redis 写入大量成员。跑到一半服务抛出了java.lang.StackOverflowError。排查。看到栈溢出第一反应是先确定线程的调用链。用 Arthas 的thread命令查看异常线程的完整栈发现栈深超过几百层但栈顶并不是 Redis 相关代码而是我们自己的JsonRedisSerializer.serialize()再往下反复出现两个对象互相调用的序列化逻辑。继续往深处查发现业务对象 A 里引用了一个包含业务对象 B 的集合而对象 B 里面又直接或间接引用了对象 A —— 自定义序列化器在转 JSON 时根本没有处理循环引用递归序列化无限往下走最终把线程栈填满。而redisTemplate.opsForZSet().add()在这里的作用是最后一根稻草它每次调用都会触发 key 和 value 的序列化从而把隐藏的循环引用问题暴露出来。如果换成一个不复用序列化器的接口错误可能永远不会出现。根因。两个层面一是对象关系存在循环引用且序列化策略没有做循环引用检测二是批量写入 Redis 的方式过于粗暴逐条提交一旦序列化出问题整个调用链被无限放大。修复。优先解决序列化问题要么在对象关系上加上JsonIgnore或JsonIdentityInfo注解打破循环要么在ObjectMapper中配置循环引用处理机制。然后改造批量写入逻辑使用 Redis Pipeline 一次性提交// 错误示范循环里逐条调用 for (Member member : members) { redisTemplate.opsForZSet().add(rankKey, member, member.getScore()); } // 修复使用 pipeline 批量提交 redisTemplate.executePipelined((RedisCallbackObject) connection - { for (Member member : members) { connection.zAdd( rankKey.getBytes(StandardCharsets.UTF_8), member.getScore(), serialize(member) ); } return null; });Pipeline 的好处是所有命令一次性发给 Redis中间不再有频繁的连接获取和网络往返。即使没有序列化问题几万条数据逐条写入 Redis 也会产生巨量的网络开销和线程等待性能差距在百倍级别。补充提醒。如果你的代码确实没有循环引用但StackOverflowError还是在opsForZSet()附近出现也要检查另一个点RedisTemplate的keySerializer在序列化一个本身就非常大的对象时递归层级很深也可能导致栈溢出。此外Redis 的-Xss设置太小比如 256k会让这个问题更容易暴露。改成 Pipeline 并检查序列化对象的结构基本能覆盖绝大多数场景。4.3 案例三通信服务422报文堆积导致的OOM现象。一个通信前置服务负责接收外部系统上报的数据报文解析后转发给业务系统。某天开始频繁出现OutOfMemoryError同时日志里打出了大量业务错误码 422含义是报文解码失败。排查。最开始团队以为是上游报文格式变更导致解码失败于是联系对方修改了格式。但 OOM 并没有消失而且内存曲线显示是一个持续缓慢上升、没有回落的过程。导出堆快照分析后发现内存里有一个ArrayList非常大里面存储的全是解码失败的原始报文每条报文有几 KB 到几十 KB累积下来把堆直接填满了。再看代码问题就很清晰了通信服务在解码失败后把原始报文追加到一个全局 List 里目的是供后续排查使用。但这个 List 没有任何容量上限也没有任何线程去消费它相当于一个无限增长的缓存。上游持续发来坏报文List 就持续增长最终触发 OOM。根因。业务设计缺陷。错误处理链路没有设置上限失败数据无限累积在内存中既没有落盘也没有重试次数的约束。422 本身是一个业务错误码但它背后隐藏的是失败数据没有闭环处理的问题。修复。做了四件事失败报文不再写入内存 List改为写入本地文件或消息队列的死信 Topic后续单独消费分析。如果确实需要保留一定数量的原始报文用于短期排查使用有界队列比如ArrayBlockingQueue并设置容量上限配合拒绝策略。增加重试计数器超过阈值比如 3 次直接标记为死信不再继续缓存。给队列长度、失败报文数量加监控告警提前暴露问题趋势。这个案例给我的触动很大。很多 OOM 的根因并不在 JVM 参数也不在高深的内存模型而是一个不经意间写下的无上限集合。排查时一定要把谁持有引用这条链路查透。5. 分层次优化从代码、JVM参数到架构兜底优化 OOM 不能只靠调参数要有层次感代码层是第一道防线JVM 参数层是兜底架构层则负责在高并发下从源头削减压力。5.1 代码层对象生命周期与批量思想的落地内存问题大多数是代码问题。以下几个看似不起眼的习惯能在源头上减少 OOM 概率。第一大集合别一次性加载。查数据库时能用分页就分页哪怕是一次性导出报表这种场景也要在应用层做分批处理。一次加载十万条记录到 List 里堆瞬间就涨上去换成每批五千条逐批处理内存峰值会低一个数量级。第二流式思想。能边读边处理就别全部读进内存。文件解析用BufferedReader逐行读Excel 导出用SXSSFWorkbookHTTP 大文件传输用流式接口不要readAllBytes()一把梭。这个思想在 Java 里到处都能落地。第三集合和缓存必须设置生命边界。static Map不是洪水猛兽但如果你往里放东西却没有对应的清理机制它就是内存泄漏的温床。所有缓存都要有最大容量、过期时间或淘汰策略哪怕只是简单的ConcurrentHashMap加定时清理也比只增不减好。第四复用可复用的对象。线程池、连接池、StringBuilder、ByteBuffer能池化的就池化。对象的重复创建不仅增加 GC 压力在高并发下也可能成为内存飙升的加速器。5.2 JVM参数层参数不能盲抄堆与GC的取舍网上流传着各种JVM 调优参数大全但直接抄是要出事的。最典型的问题是-Xmx设置过大。在容器或物理机内存有限的情况下-Xmx设得越大留给 Metaspace、线程栈、直接内存以及操作系统本身的空间就越小。一个 4G 内存的机器上运行单个 Java 服务-Xmx在 2G 到 2.5G 之间是相对合理的如果机器上还同时部署了 MySQL、Redis 等中间件就必须进一步调低。Metaspace 建议显式设置上下限比如-Xms2g -Xmx2g -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m-XX:MetaspaceSize是初始触发元空间 GC 的阈值MaxMetaspaceSize是硬上限。显式设置的意义在于一旦元空间出现异常增长能被监控捕获并告警而不是等到物理内存耗尽才被发现。GC 选型的核心原则是吞吐量优先选 Parallel延迟敏感选 G1超大堆低延迟选 ZGC。JDK 8 默认是 Parallel Scavenge Serial Old 的组合到 JDK 9 之后默认切换为 G1。对于大多数互联网业务G1 配合-XX:MaxGCPauseMillis200是性价比较高的组合。如果堆非常大比如超过 64G且对延迟要求极苛刻可以评估 ZGC——它在 JDK 15 之后已经达到生产可用级别GC 停顿时间可以控制在亚毫秒堆越大优势越明显。GC 收集器适用场景常用参数特点ParallelGC批量计算、吞吐量优先-XX:UseParallelGC停顿时间较长G1主流后端服务、大堆-XX:UseG1GC -XX:MaxGCPauseMillis200区域化收集停顿可控ZGC超大堆、低延迟敏感-XX:UseZGC停顿亚毫秒JDK 15 生产可用真正的 JVM 参数调优不是把别人的配置抄过来而是基于你服务的 QPS、堆内对象分配速率、GC 日志表现做渐进式调整。每次只改一个参数观察一个周期的 GC 和内存指标再决定下一步。5.3 架构层限流、削峰、有界队列高并发场景下就算代码写得再干净流量打进来的时候内存一样会暴涨。架构层的优化是为了让系统在流量洪峰面前从容地拒绝而不是崩溃地返回错误。入口限流是第一道闸门。网关对接口做 QPS 限制防止突发流量直接穿透到业务进程。常见的限流算法有固定窗口、滑动窗口、令牌桶、漏桶用 Guava RateLimiter、Resilience4j 或 Sentinel 都可以实现。消息队列削峰是标配。大流量写入不直接打到业务接口而是先进 MQ消费端按自己的处理能力拉取。这样即使消息瞬时积压也只是 MQ 延迟变高不会撑爆应用堆内存。线程池和队列必须是有界的。线程池的等待队列如果用LinkedBlockingQueue但没设容量上限任务就会无限堆积队列里的对象会慢慢吃掉堆内存。正确姿势是设置合理大小的有界队列比如ArrayBlockingQueue(1000)配合CallerRunsPolicy或AbortPolicy让上游感知到压力而不是让内存默默爆炸。5.4 监控与应急把OOM消灭在告警之前没有监控的 OOM 排查等于半夜爬起来看日志盲猜。一套完整的内存监控体系包含以下指标堆内存使用率Eden、Survivor、Old 分区Metaspace 使用率GC 次数与耗时尤其是 Full GC线程数量与状态分布直接内存使用量这些指标可以通过 Micrometer Prometheus Grafana 做展示JVM 相关的 exporter 已经非常成熟。告警规则建议这样设计老年代使用率持续超过 80% 超过 10 分钟、Full GC 次数在短时间内陡增、线程数超过基线 2 倍以上分别触发不同等级的告警。有了这些指标OOM 发生前就能有预警而不是等到自动重启完才收到通知。应急层面除了必须开启的HeapDumpOnOutOfMemoryError还要在应急预案里写清楚dump 文件保存在哪个目录、如何快速下载、由谁负责分析、SLA 要求是什么。一次 OOM 从发生到定位如果能控制在半小时内靠的一定是预案而不是临场发挥。6. 踩过几十次坑之后我留下的几条排障心得最后聊几条比较实在的经验也是我在多次处理 OOM 之后总结出来的认知。6.1 不要迷信调大Xmx也不要迷信重启治百病凡是出了 OOM 就加内存、加完内存重启、重启完不管的人大概率会在深夜再次被同一个告警叫醒。调大-Xmx只是把崩溃的时间点往后推迟并没有解决谁在吃内存这件事。真正负责任的做法是每次 OOM 都要留下 dump都要有人把根因分析出来并且验证修复手段是有效的而不是重启之后看着服务能跑了就默认问题消失。另外记住一个原则排查 OOM 不要急着重启先保留现场。代码可以明天改JVM 参数可以在下一轮发布时调整但堆转储和线程栈错过了就再也找不回来了。很多看起来无解的 OOM回头复盘时发现就是当时手太快kill -9把证据全弄没了。6.2 排障方法论是可迁移的从OOM到Kafka lag、慢SQL、死锁排查 OOM 积累的这套方法其实可以复用在一系列系统排查场景中。Kafka consumer lag 持续升高流程是先看消费端监控曲线再抓线程栈定位消费者卡点排查下游处理瓶颈。慢 SQL 优化流程是先看执行计划再看表结构索引最后根据瓶颈做针对性修改。死锁排查流程是jstack抓线程快照看两个线程互相等待什么锁再回到代码里查锁的获取顺序。它们的共同点是先看现象曲线再抓现场证据最后定位热点而不是一上来就翻代码看逻辑。这套顺序能帮你快速缩小排查范围把大海捞针变成按图索骥。6.3 留一台压测环境比什么都管用最后一个建议。OOM 这种问题最怕的是在生产环境偶发、在测试环境复现不出来。如果你所在团队资源允许建议保留一个和生产配置尤其是内存和 JVM 参数一致的压测环境每次大版本发布前用预期峰值的流量做一次内存压力测试。很多内存问题的成长曲线很长要压测才能暴露出来。压测环境里出现的 OOM 不可怕因为它给了你一个不会惊动领导的试错机会真正可怕的是问题永远只在夜深人静的生产环境出现而你手上只有一坨已经被重启清空的尸体。