收到 OutOfMemoryError 告警的那一秒没有人能完全淡定。尤其是线上生产环境服务可能已经陆续报错、接口超时、监控面板满屏飘红。这种时刻最容易踩的坑是很多人第一反应就是赶紧把堆调大——但如果你连内存是被谁吃掉的都没搞清楚调大堆只是把炸弹往后推了一两周而已。这篇文章我想把 JVM 调优这条线从头到尾讲透核心是 OOM 根因排查当你在日志里真的看到java.lang.OutOfMemoryError时到底该按什么顺序、用什么工具、以什么思路找到真凶。内容上我会先从 JVM 内存区域和五类常见 OOM 报错讲起接着给出一套线上应急留证的标准动作然后用一个典型的堆内 OOM 案例完整走一遍定位流程最后落到参数调整、代码改造、压测验证和监控布防。无论你是刚接手 Java 服务的新人还是写过不少接口、但对 JVM 内部一直停留在大概知道状态的开发者这篇读完应该能形成一套自己的排查手路。先把话说在前面下面的命令和方案基于 JDK 8 环境默认你已经有部署和看日志的能力。好开始。1. 先把内存地图刻进脑子里五类 OOM 报错和它们的真正含义1.1 堆、元空间、栈和直接内存别把内存想成一个大水缸排查 OOM 之前我强烈建议你先在脑子里摆清一幅 JVM 内存地图。很多人以为 JVM 内存就是一块大蛋糕内存不够就等于蛋糕太小——这个误解会让排查方向直接偏掉。实际上 JVM 把内存划成了好几个相对独立的区域每个区域各有职责、各有上限爆掉的信息也完全不同。堆Heap是所有 new 出来的对象住的地方Order 对象、User 对象、List、HashMap 全在这里绝大多数 OOM 的主战场也是它。元空间Metaspace负责放类加载后的元数据JDK 8 之前叫永久代反射、CGLIB 代理、动态生成类都会往里塞东西。虚拟机栈VM Stack是每条线程单独拥有的主要存栈帧、局部变量和方法调用关系。再往外是直接内存Direct Buffer它不计入堆却是 NIO、Netty 这类框架最喜欢的堆外空间。把内存按区域拆开看最大的好处是你拿到报错信息时第一反应不是堆又炸了而是先判断这条 OOM 究竟属于哪个区域。不同区域爆掉背后的原因和使用的工具完全不同。这也是下面这张表的价值所在。1.2 先认识五类 OOM 报错报错信息会说话报错内容发生区域典型场景初步排查方向Java heap space堆对象堆积、无界缓存、内存泄漏堆转储 Dominator TreeGC overhead limit exceeded堆回收效率堆接近饱和GC 已经救不回来看 Full GC 后老年代是否下降Metaspace元空间动态类加载、热部署、代理生成看类加载器和类数量Direct buffer memory堆外直接内存Netty/NIO 缓冲未释放看进程 RSS 与实际堆占比unable to create new native thread操作系统线程线程数打满上限、线程泄漏数线程、查线程池配置第一类Java heap space意思是堆区对象总量超过了堆上限。最典型的场景是无界缓存不断写入、一次性把超大数据库结果集 load 进内存、ThreadLocal 对象使用后没有清理或者干脆就是对象泄漏。遇到它别急着加 Xmx先把堆转储做出来看清哪些对象占着内存。第二类GC overhead limit exceeded很多人都没真正理解。它并不是某种特殊的内存区域而是 JVM 发现 GC 已经无力回天时主动抛出的保护性错误——默认情况下如果 GC 花费超过 98% 的时间却回收不到 2% 的堆内存就会抛这个异常。它往往是堆即将爆满的最后警告回收机制已经缓解不了问题必须立刻转储分析。第三类MetaspaceOOM发生在元空间通常和动态生成类有关。比如用 Groovy 脚本现场编译、频繁热部署、大量创建代理类每产生一个新类它的元数据就会占据元空间而元数据默认要等类加载器被回收后才会卸载。我后面会有专门一节展开。第四类Direct buffer memory是堆外直接内存耗尽。典型现象是-Xmx只设了 3G但进程实际 RSS 已经到了 5G、6G日志里却报的是 Direct buffer memory。Netty 的堆外缓冲、NIO 的allocateDirect如果没有正确释放堆外空间就会悄悄被吃光。第五类unable to create new native thread严格说不是 JVM 内存区域的问题而是操作系统不允许进程再创建新线程了。线程池打满、线程泄漏、系统 nproc 限制都会报这个错。很多人一看到它就去调 Xss方向往往是错的后面我也会细讲。1.3 OOM 之前的那 30 分钟信号全在 GC 日志里线上 OOM 很少是瞬间发生的绝大多数是慢性病对象慢慢堆积GC 频率慢慢上升直到某一刻堆彻底被填满。因此如果你一早开了 GC 日志你在 OOM 之前 30 分钟甚至更早就能看到预警信号。典型信号有三条第一条Full GC 频率越来越高从每小时的几次变成每几分钟一次第二条每次 Full GC 之后老年代占用几乎不降说明回收掉的尽是些无关痛痒的边角料第三条老年代曲线呈现出台阶式上升每个台阶对应一次 Full GC台阶高度在不断增加而不是走平。用jstat能很直观地看到这个过程。比如跑jstat -gcutil pid 5000里面有个 OU 字段代表老年代已用空间。如果你盯 10 分钟发现 OU 稳定往上走、FGC 计数每次采样都在加那你基本可以断定有长命对象正在堆里积累。这时候别等它 OOM直接进入排查流程。2. 线上 OOM 应急手册抢在重启之前把证据留下2.1 先稳手现场保存的顺序比命令本身更重要我见过太多同学看到 OOM 的第一反应是kill -9重启重启之后一切如常第二天同一时段再炸一次。不是玄学是内存里的证据被你亲手删了。真正靠谱的应急顺序应该是这样的先确认进程是否还活着服务是否依然有响应如果进程活着且允许短暂卡顿立刻做一次堆转储无条件先保存jstack线程快照留一份线程当时的运行状态保存 GC 日志、监控面板截图、top、free、dmesg等系统层面的信息把该留的信息都留齐了再决定是否重启恢复业务。为什么顺序这么重要因为堆转储和线程快照只有在问题发生现场才有价值。重启之后内存被清空你只能去猜刚才到底什么东西占着 2G 内存。猜是排查里最贵的行为。2.2 最常用的四个命令和它们各自要注意的坑# 实时查看 GC 情况每 5 秒打印一次 jstat -gcutil pid 5000 # 查看堆配置和当前使用量 jmap -heap pid # 生成堆转储文件注意live 参数会让 JVM 先做一次 Full GC jmap -dump:live,formatb,file/data/dumps/heap.hprof pid # 生成线程快照 jstack pid /data/dumps/thread_$(date %s).txt这里有几个坑必须提前说清楚。第一jmap -dump:live会触发一次 Full GC。如果你希望分析的对象是当前真正存活的对象那没问题但如果你怀疑自己漏掉了某些本来可以回收的对象那 live 模式反而会掩盖真相。我的习惯是时间允许就直接 dump不轻易加 live。第二大堆 dump 真的很耗时。堆 4G 以上时dump 过程可能让应用停顿数秒到几十秒业务高峰期别硬来。宁可把 dump 计划到低峰期也不能在一个关键交易接口正忙的时候把整个进程卡死。第三jstack保存后至少看一眼线程数。如果线程栈里大量线程卡在同一个方法或同一个锁上它已经是一个重要线索了别把文件保存了就不管。如果线上不方便用 JDK 自带工具现在很多团队也会用 Arthas 做现场干预heapdump命令同样能生成 hprof 文件而且比jmap多了很多运行时观测能力。工具只是手段关键是把现场证据留下来这个动作本身。2.3 把事后抢救变成事前布防这几个参数必须提前开如果所有机器完全没有提前开启诊断参数那 OOM 发生时我们只能干瞪眼。所以我每次评估一个 Java 服务上线第一件事就是确认 JVM 参数里有没有这几项-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dumps -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.logJDK 9 之后PrintGCDetails和Xloggc被统一替换成-Xlog:gc*:file/data/logs/gc.log新项目尽量用新写法。这几项参数加起来无非是多写几个文件但真出事时HeapDumpPath 下的 hprof 文件能让你少加一整夜班。顺带说一个很反直觉的细节开启了HeapDumpOnOutOfMemoryError之后OOM 发生时 JVM 会尝试生成 dump 文件再退出。如果磁盘空间不够、或者 dump 目录没有写权限这个参数会失效。所以除了加参数还要定期检查目录别等真正炸的时候才发现文件写不进去。3. 实战走查一次典型堆内 OOM 的完整定位链路3.1 场景设定一个重启就好、过两天复发的服务为了让你完整体会排查链路我们走一个虚拟场景——一个订单查询服务JVM 堆 3G白天高峰期偶尔抛Java heap space每次重启后能正常一两天然后又复发。接口平时 RT 大概 50msOOM 前涨到三五秒客户描述这个现象的时候都是一脸无奈明明重启就好了为什么总是复发这类模式本身就很有信息量。它说明问题不是瞬时的大流突发而是一种慢速堆积有东西在持续占用内存占满 3G 需要一两天。确认了慢速堆积我们已经有很大把握推断问题大概率出在某些长期存活、不断新增的对象上而不是短暂的并发峰值。3.2 第一手证据jstat 和 GC 日志锁定老年代我们先用jstat盯了 5 分钟输出大概长这样S0C S1C S0U S1U EC EU OC OU MC MU YGC YGCT FGC FGCT 1024.0 1024.0 0.0 512.0 12288.0 10240.0 204800.0 196800.0 65536.0 60000.0 1823 98.55 187 42.10注意最后一列的 FGC从早上的几十次涨到现在的 187 次而 OU 老年代已用空间始终在 19 万 MB 附近不下来。再看 GC 日志里 Full GC 的行格式大概是[Full GC (Allocation Failure) 3.0G-2.9G(3.0G), 2.350s] [Full GC (Allocation Failure) 3.0G-2.95G(3.0G), 2.610s]最刺眼的不是停顿 2 秒而是 GC 完之后老年代基本没降。这说明堆里有大量存活但无价值的对象——它们能活过 Full GC却又不是业务真正需要长期驻留的东西。到这里判断方向已经清晰大概率是某个缓存或上下文结构在不断写入对象且没有淘汰机制。3.3 决定性动作堆转储 MAT 找内存大头下一步低峰期执行jmap -dump:formatb,file/data/dumps/heap.hprof pid把 hprof 拿到手后用 MAT 打开。MAT 的 Leak Suspects 直接给出提示一个HashMap占了大约 1.3G 内存顺着 Dominator Tree 往里看Map 的 value 是一个ArrayList里面躺着大量订单对象key 是 Long 类型的用户 ID。到这一步很多人会冲回去改代码。但先别急我习惯再多问三个问题这个缓存是谁写入的写入频率有多高有没有清理策略只看 dump 结果你不知道它是启动时一次性加载还是每次请求都往里面塞这两个场景的修复方案完全不同。于是我们再翻jstack和代码调用链。发现这个 Map 是一个静态缓存接口每次收到用户查订单详情的请求都会把该用户的全量订单列表从数据库查出来放进缓存里。写入频率高、无上限、无过期——这就是老年代台阶增长的直接来源。3.4 根因复盘无界缓存的慢性压力以及修复后的验证根因可以说非常典型无界缓存 全量加载。用户量少的时候这 3G 堆它撑得下用户量涨上去之后老年代每天都被大量订单对象填满最后 Full GC 救不回来直接 OOM。修复我分了三步。第一步把HashMap换成 Caffeine 的有界缓存配置maximumSize和expireAfterWrite超过上限自动淘汰第二步聚合查询改成按页加载单次只取当前页面需要的数据避免一个用户在缓存里塞几千条订单第三步对超大用户做旁路处理缓存只覆盖典型规模用户超大用户直接走实时查询。修复后我们用和之前相同的流量压测 30 分钟观察jstat里的 OU 和 FGC得到的结果对比非常明显观察项调整前调整后老年代 OU 走势持续台阶式上涨稳定在 60% 以下走平Full GC 频率5 分钟 3 次以上接近 0接口 RT P99峰值时 3 秒以上回到 80ms 内这个案例最大的教训是调堆和不调堆都不解决缓存无限膨胀的问题。堆再大也只是把一周后的 OOM 拖成一个月后的 OOM根治永远在代码层。4. 堆外世界的三块暗礁Metaspace、直接内存、线程耗光4.1 Metaspace OOM动态类和类加载器的生命线Metaspace 的 OOM 之所以隐蔽是因为很多人根本想不到类也能把内存吃光。元空间存的是类元数据类一旦加载默认要等对应的类加载器被回收它才能被卸载。如果你的业务代码频繁现场造类元空间就会像堆一样涨上去。最常见的高发场景是动态脚本。有的平台会用一个 GroovyClassLoader 去编译和执行用户提交的脚本每次请求都new ClassLoader再编译编译后的类全部落在元空间。上线初期没事跑到一段时间后某一次动态编译直接抛Metaspace OOM。排查方式不复杂盯着jstat里的 MC 和 MU看元空间已用空间是否单调上涨再用jmap -clstats看看类加载器数量和每个加载器加载的类数量。如果发现大量相同类名的副本基本就是类加载器泄漏。修复方向是复用脚本引擎、缓存编译产物或者把动态脚本改成预编译。-XX:MaxMetaspaceSize只是兜底上限它能挡住进程崩溃但挡不住业务在元空间里持续造垃圾。4.2 Direct buffer memory堆外内存泄漏为什么很难发现直接内存的 OOM表现非常有迷惑性JVM 堆的使用率不高-Xmx余量很充足但进程的 RSS 一路狂飙日志忽然报Direct buffer memory。核心原因在于 DirectByteBuffer 对象本身很小真正常驻的是对象背后的堆外内存块它不受堆大小的限制也不由常规 GC 直接管理。Netty 是重灾区。如果你在代码里频繁申请堆外缓冲用完又没有归还到池里日积月累就会吃光堆外空间。排查时我一般会做两件事第一pmap -x pid看进程的内存分布确认是否存在大量 anon 内存第二jcmd pid VM.native_memory看 native 区的明细。如果确认是 Netty 或 NIO 的缓冲未释放优先检查是否该用PooledByteBufAllocator、ByteBuffer 用完是否 release、-XX:MaxDirectMemorySize是否和实际分配匹配。直接内存的坑比堆上问题更依赖会不会正确使用框架。4.3 unable to create new native thread不是调大 Xss 就能解决的最后这个报错我想特别纠正一个常见误解。很多人看到unable to create new native thread第一反应是线程栈太小把 Xss 调大但真正的瓶颈往往不是栈大小而是进程能创建的线程数已经到顶。每个线程都要占用系统资源和虚拟内存当线程总数达到操作系统限制比如 ulimit -u、cgroup 的 pid 限制JVM 就再也 new 不出线程。排查命令通常是cat /proc/pid/status | grep Threads看当前线程数top -H -p pid看线程总量和 CPU 使用再用jstack统计线程栈里的线程名看看是不是有大量线程卡在同一个地方。我见过最典型的场景是某个模块每次调用都new Thread(...)也不知道用线程池并发一上来线程数瞬间打满整个进程再也没法处理新请求。修复思路第一所有异步任务必须走线程池禁止裸new Thread第二检查线程池参数是否核心线程数设置过大、队列是否合理第三如果确实并发量非常高考虑横向扩容而不是单机硬扛。调-Xss在这种场景下只是把每线程的栈空间调小一点线程总数不会因此有本质提升。5. 从根因反推调优JVM 参数是按场景算出来的不是抄来的5.1 先做内存预算再动 Xmx容器环境下尤其要算清楚现在说到调参数。我见过最被高估的参数就是-Xmx很多人看到机器 32G 内存直接-Xmx 28G结果 Full GC 时暂停几十秒容器还因为 RSS 超限被编排系统杀掉。如果你在容器里跑 Java-Xmx绝对不能按物理内存的 80% 拍脑袋。正确的做法是先做内存预算。拿一个 8G 容器的服务举例我会这么分配内存项目建议分配说明JVM 堆4G 左右给对象留出的主空间Metaspace512M 左右动态编译/代理类多时往上提直接内存1G 以内涉及 Netty/NIO 必须预留线程栈视线程数而定每线程 512K~1M200 线程约 200M系统及其他至少留 1G避免容器 OOMKilled粗算下来-Xmx4g -Xms4g -XX:MaxMetaspaceSize512m -XX:MaxDirectMemorySize1g是一个合理的起步组合。注意-Xms和-Xmx我直接设成一样避免 JVM 在启动之后反复扩容收缩堆带来额外的性能抖动。5.2 几个被反复问起的参数背后是同一个逻辑参数不要死记每个参数背后都有一个你能说清楚的道理。-Xmn或-XX:NewRatio控制年轻代大小。年轻代越大短命对象在多轮 Minor GC 里存活的时间越长越不容易被过早晋升进老年代但年轻代也不能无限大否则老年代空间被压缩Full GC 反而变频繁。一般用-XX:NewRatio2或3起步结合对象的实际存活曲线再调。-XX:SurvivorRatio默认 8也就是一个 Survivor 区只有 Eden 的 1/8。如果晋升率很高说明 Survivor 空间太小很多对象还没熬过几轮就被挤进老年代这种情况可以把 SurvivorRatio 调小一些。注意这些调整都只是改善对象在哪一代被回收的节奏真正减少对象总量要靠代码。垃圾收集器选型方面我的判断逻辑很简单追求吞吐量、对停顿不敏感用 Parallel ScavengeJDK 8 默认就是它堆较大且希望停顿可控用 G1JDK 11 之后的默认选择堆超大、RT 极敏感的低延迟场景ZGC 或 Shenandoah 更合适。-XX:MaxGCPauseMillis只是软目标不代表设置成 100ms 就一定能得到 100ms 内的停顿。还有一个容易踩的坑是-XX:DisableExplicitGC。禁用System.gc()可以减少不必要的 Full GC但某些基础框架比如 RMI、堆外内存的清理依赖显式 GC 来触发清理动作。没有确认依赖关系之前不要随便禁。5.3 参数只能改善回收节奏真正治本是对象、缓存、批处理把参数聊完我必须强调一句调参永远不能替代代码修复。一个每秒new几万个大对象的服务你再怎么调 G1、再怎么给堆扩容也只是把问题延后。真正治本的地方永远是减少对象压力。代码层建议最常用的有三条。第一所有缓存必须是有界的Caffeine、Guava Cache 都要配置maximumSize和过期策略裸HashMap当缓存是万恶之源第二批量场景必须分页或者流式处理一次查一万条订单塞进内存和一次查一百条分十次处理内存差了两个数量级第三高并发路径里避免无谓的重复对象创建比如循环里做字符串拼接用StringBuilder字典结构初始化时估算初始容量以减少扩容。架构层有时候更有效。比如读多写少的数据用 Redis 前置缓存把热点从堆里挪到堆外再比如把一次性的大批量任务改造成异步分片处理用队列削峰。堆压力小了GC 自然就稳定了。6. 上线前验证与长期布防让 OOM 不再半夜叫人起床6.1 压测做到什么程度才能证明内存问题真的解决了很多人验证内存修复的方式是把服务启动起来用 100 QPS 跑五分钟看没报错就说没问题。这种验证说实话连问题复现都谈不上。要让压测结果真正可信我认为至少要满足三个条件。第一流量模型贴近线上不能只有平均 QPS必须有峰值段而且峰值段要持续 15 到 30 分钟让慢速堆积类问题有时间暴露第二观察指标不是有没有报错而是堆水位是否收敛、Full GC 频率是否稳定、FGC 前后老年代占用是否有明显波动第三做一次极限压测用目标峰的 1.5 倍流量去冲一冲看服务在什么时候退化、会不会 OOM以此评估余量。压测过程里我会盯着jstat的输出看 30 分钟以上的趋势。修复好的服务OU 曲线应该是一条走平的线没修好的服务OOM 前 20 分钟曲线就会明显翘头。6.2 监控指标与告警阈值少看一个都不稳妥没有监控布防的调优等于拆完一颗雷后继续在雷区里裸奔。我的最低配监控表是这样的监控指标建议告警阈值动作堆内存使用率持续 5 分钟超过 80%提前排查别等 OOMFull GC 频率5 分钟内超过 3 次立即转储分析Full GC 单次停顿 P99超过 1 秒关注延迟敏感业务Metaspace 使用率超过 80%检查类加载器是否泄漏进程 RSS超过容器限制 90%检查堆外内存/线程数线程数超过预期容量 80%检查线程池是否打满OOM 事件出现即告警应急流程立即启动这些阈值不是死的业务不同阈值可以不同但有一个原则要记住告警最好在 OOM 前 30 分钟甚至更早就发出。等 OOM 告警出来已经是事故在途了你做的所有事都叫抢救而不是预防。6.3 发布检查清单每一次变更前过一遍最后分享一份我每次发布内存敏感变更前都会过的检查清单确认已开启-XX:HeapDumpOnOutOfMemoryError和 GC 日志确认 dump 目录存在、有写入权限、磁盘空间充足内存相关改动是否经过稳态压测有没有历史基线可以对比是否评估了新增框架对 Metaspace、直接内存、线程数的影响是否明确回滚方案回滚是否能恢复到上一个稳定版本的参数和代码上线后计划观察多久——我的标准是至少连续观察 24 到 48 小时覆盖完整的高峰和低谷。清单内容不复杂但它能强制你想一遍这次改动到底动了哪些内存而不是上线后等监控告警来提醒你。最后再说一个我自己的习惯。排查 OOM 时我从来不会把调大堆放在第一个方案里。每次都是先把谁在消耗内存这件事讲清楚再决定是修代码、换缓存结构还是加资源。哪怕到最后确实要调-Xmx也要让这个数字背后有完整的测算依据。另一个习惯是我会把每一次 OOM 的处理过程整理成简短的事故笔记记录当时的 GC 日志截图、dump 结论和最终改动。下次再遇到类似问题翻旧笔记的定位速度远比你从零重新推演一遍要快。JVM 调优这件事说白了就是让每一次线上事故都变成你下一次排查的复利。
阅读完成 · 觉得有帮助?