
凌晨一点半手机在床头柜震个不停。值班群里的消息一条接一条弹出来某个核心服务监控报警GC 停顿异常飙高紧接着就是触目惊心的java.lang.OutOfMemoryError: Java heap space。登录跳板机一看几千台机器里的某一台容器已经处于半瘫痪状态重启能救急但谁都知道不找到根因今晚它还会再爆一次。这就是 JVM 调优最真实的日常——它不是某个架构师拿着参数表精雕细琢的表演而是一场必须在几分钟内判断是参数问题还是代码问题、是内存泄漏还是流量突增的急诊手术。这篇文章我想完整梳理一套我自己在线上反复验证过的 JVM 调优与内存问题排查方法论从内存泄漏和 OOM 的本质讲起到 Arthas 实战命令集怎么用再到一条从报警到修复的完整排查链路。适合刚从 CRUD 走向线上问题排查的后端开发也适合被 OOM 折腾过、想建立系统化排查思路的运维和 SRE。1. 调优前的整体思路诊断永远排在调整参数之前1.1 为什么说调优90% 的时间其实花在排查上很多人对 JVM 调优有个误解以为调优就是搜一份最有价值的 JVM 参数清单然后照抄到启动脚本里。实际上参数调整是整个链路里最不需要技术含量的一步。真正难的是搞清楚一个问题当前 JVM 的运行状态到底哪里不对。我见过太多反面案例。某个服务频繁 Full GC操作人员上来就加-Xmx从 2G 加到 8G结果 Full GC 次数没减少单次停顿时间反而更长因为堆大了GC 扫描和复制对象的耗时也上去了。后来 dump 出来一查是代码里有个静态 Map 只往里写、从不移除明明 1G 堆都能测出泄漏加到 8G 只是延缓了崩溃时间。所以我的经验是遇到 JVM 性能问题第一反应应该是收集现场而不是改参数。现场信息包括 GC 日志、堆 dump、线程栈、系统负载和 Arthas 采集的运行时数据。拿到这些数据之后你才能区分出当前属于哪一类问题内存泄漏型堆占用随时间单调递增GC 后内存水位降不下去短期流量冲击型GC 后内存能恢复但请求洪峰来得太快老年代瞬时打满参数不当型分配过小引发频繁 GC 或直接 OOM分配过大导致 GC 停顿不可控资源耗尽型线程数到上限、文件句柄耗尽、直接内存溢出堆本身看起来没问题这四种病的治疗方案完全不同。诊断排在最前面参数调整永远放在最后。1.2 JVM 内存布局速览这些区域各自会怎么爆要诊断问题脑子里必须有完整的 JVM 内存地图。我一般会把运行时数据区分成几个区域来记忆因为它们的溢出特征和排查工具完全不同。堆内内存是最常出问题的区域所有对象实例都分配在这里。堆内部又分成新生代Eden、S0、S1和老年代。新生代对象存活时间短用复制算法做 Minor GC老年代存放长期存活的大对象触发 Major GC 或 Full GC。堆溢出的报错通常是Java heap space。元空间Metaspace存放类的元数据。JDK 8 之前叫永久代现在叫元空间而且默认情况下它使用的是本地内存不像堆那样受-Xmx限制。如果代码里动态生成大量类或者在容器里反复热加载应用而没有清理类加载器就会爆出Metaspace错误。这个在排查时经常被忽略因为很多人只盯着堆。虚拟机栈每个线程占一块默认大小 1M 左右存栈帧、局部变量、方法调用。栈溢出会抛StackOverflowError无限递归就是这么来的。创建线程过多时报错往往是Unable to create new native thread这不是栈本身的问题而是操作系统线程数量达到上限。直接内存是 JDK NIO 和 Netty 最常用的区域通过DirectByteBuffer操作堆外内存。默认大小跟堆最大值一样如果频繁分配堆外 buffer 而没释放就会报Direct buffer memory。这块在堆内存、GC 指标完全正常的情况下最容易让人摸不着头脑。所以排查内存问题时jvm命令看一眼各个区域的使用率是必须的第一步不能只管堆。1.3 顺带澄清几个容易混淆的基础概念很多人分不清 JVM、JRE、JDK 三者的边界面试里也经常被问。简单说JVM 是 Java 程序的运行引擎负责执行字节码、管理内存和垃圾回收JRE 是运行环境在 JVM 之外加上核心类库JDK 是开发工具包在 JRE 基础上再加编译器javac、诊断工具等。你在命令行里执行java -version看到的是 JRE 的版本信息而javac -version看到的才是 JDK 编译器版本。我在实战中碰到过一个相关的高频报错就是编译时提示无法编译为 JVM 目标 17 配置的模块。这个问题的本质是编译期的 target 版本和当前 JDK 的源版本冲突比如项目 JDK 版本低于 17却强制指定了--release 17或-source/-target 17。解决办法不是去调 JVM 参数而是对齐项目 JDK 与编译目标版本检查 IDE 的 Project SDK、Maven 的maven.compiler.source属性是否一致。这类启动或编译报错经常被误当成JVM 调优问题但很多时候只是环境版本错配。2. 深入理解内存泄漏与 OOM先分清病根再对症下药2.1 内存泄漏和内存溢出到底差在哪内存泄漏和内存溢出是两道不同的题。内存泄漏是指对象已经不需要了却仍然被某些引用链一路拽着GC 的标记-清除阶段发现它还能被触达于是一直不去回收。这样堆里的垃圾实际占着位置可用内存一点点变少。内存溢出则是申请内存时堆不能再满足分配请求直接抛出 OOM。二者是因果关系但不能混为一谈。泄漏是存量被污染溢出是总量不够的表现。一个服务如果堆内存持续上升即使把-Xmx调大也只是把泄漏的爆发时间往后推。反过来一个本身健康的服务如果把堆设成 256M请求量上来之后照样会 OOM。区分方法其实很简单看 GC 后内存能不能回落到合理水位。我通常用 GCBasis 的日志推演或者用 Arthas 的memory命令多采样几次。如果每次 Minor GC 后 Eden 区都能清零老年代的增长曲线却一直向上且每次 Full GC 之后老年代使用量并不明显下降那大概率就是泄漏。如果 Full GC 后内存能回落到低位只是回落之后快速再次上涨那多半是突发流量或对象生命周期设计不合理。2.2 六种常见 OOM 类型与典型场景OOM 类型报错特征常见触发场景优先排查方向Java heap spaceOutOfMemoryError: Java heap space对象创建量超过堆上限、内存泄漏堆 dump看大对象与 GC Roots 引用链GC overhead limit exceededGC overhead limit exceededGC 回收后内存依然不足GC 占用 CPU 超过 98% 却收效甚微堆占用曲线、泄漏嫌疑、堆是否过小MetaspaceOutOfMemoryError: Metaspace动态生成类、热部署未清理旧类加载器类加载器数量、加载类数量Direct buffer memoryDirect buffer memoryNIO/Netty 堆外 Buffer 未释放-XX:MaxDirectMemorySize、堆外内存监控Unable to create new native threadunable to create new native thread线程数达到 OS 上限、线程泄漏thread线程数量、系统ulimit、pthread 上限Requested array size exceeds VM limitRequested array size exceeds VM limit声明超大数组、单对象超大代码逻辑、数据结构选型这里重点提醒一下GC overhead limit exceeded它容易给人迷惑性。报错文案很长但本质是 JVM 预判即使花大量时间做 GC 也回收不出可用空间主动抛 OOM 来止损。它跟Java heap space经常是同一个根因只是 JVM 提前拦截了。排查思路一样先看堆 dump。2.3 高频内存泄漏代码场景盘点从代码层面看JVM 常见的内存泄漏大多是这些写法引起的静态集合无脑放数据。最常见的就是private static final MapString, Session或者ListObject缓存。静态变量生命周期跟 JVM 一样长如果你只 put 不 remove、只 add 不清理引用就一直挂在类上GC 永远无法回收。修复手段是换成带失效策略的缓存组件或者定时清理并控制容量。ThreadLocal 用完不清理。ThreadLocal 的值存在线程的 ThreadLocalMap 里key 是弱引用但 value 是强引用。线程池里的线程会复用不清除 ThreadLocal 的话value 会一直在线程生命周期内存活。尤其是 Web 容器线程池这种空闲也不销毁线程的场景反复设置大对象进去老年代很快就顶不住。各种连接和流未关闭。数据库连接、HTTP Client、文件流、输入输出流看着像资源问题实际上也直接表现为内存泄漏。因为这些连接对象内部会创建 Socket、缓存 buffer持有大量堆内和堆外内存。连接池对象如果只借不还或者归还异常时没有走 finally 释放连接数和内存会同步飙升。监听器和回调注册后未反注册。在某个全局组件里addListener或addObserver对象移除时没有移除监听关系被监听对象会通过监听器反向持有监听者的引用。这种问题在重试机制或动态代理叠加的时候特别隐蔽。ClassLoader 泄漏。应用热部署后旧的 WebAppClassLoader 如果还被静态变量或线程上下文类加载器引用整个类加载器以及它加载的所有 class 都无法回收。这类泄漏排查时需要数一数 Metaspace 里的类加载器实例数量。Arthas 的classloader命令可以直接看到每个加载器加载了多少个类。3. Arthas 实战命令集线上定位问题的手术刀3.1 接入 Arthas从部署到进入交互命令行Arthas 是阿里开源的 Java 诊断工具可以 attach 到一个正在运行的 JVM 进程上不重启应用就能查看运行时数据、追踪方法调用、甚至动态调整日志级别。它对生产环境是侵入性比较小的方案也是现阶段我做线上排查的首选工具。接入方式很简单。到 GitHub release 页下载 arthas-boot.jar然后在服务器上执行java -jar arthas-boot.jar启动后会列出当前机器上所有 Java 进程输入序号回车就 attach 成功。如果你在一个进程特别多、不好辨认的容器里可以直接指定 PIDjava -jar arthas-boot.jar 1234attach 成功后会进入 Arthas 交互式命令行默认连接端口是 3658。生产环境要注意的是Arthas 实际上也是一个 Java 进程它会占用一定资源和端口。用完一定要stop或exit退出不要挂在那个环境里一直不释放。我踩过坑线上容器同时开多个 Arthas 会话内存和 CPU 反而被诊断工具本身拖累了。3.2 全局体检三连dashboard、jvm、memory进入 Arthas 之后第一步不是急着抓 bug而是看全局。dashboard命令会打印当前进程的整体运行情况包括 CPU 占用最高的几个线程、堆内存使用、GC 次数和耗时。这个命令非常像进程级别的 top能快速告诉你是否真的异常、异常集中在哪个区域。dashboard接着用jvm命令看 JVM 各区域的详细数据比如类加载总数、GC 收集器类型、JIT 编译耗时、操作系统信息。memory命令则更加直接把堆内、堆外、CodeCache、Metaspace 等各内存池的使用情况分项列出来。我排查时的固定动作是dashboard看总体、memory看内存分布、jvm看收集器和类加载侧信息三张图拼起来再决定下一步往哪个方向深挖。3.3 线程与 GCthread 命令的两个高频用法thread命令用于查看线程状态最实用的用法有两个一是看当前 CPU 占用最高的前 N 个线程thread -n 3这个输出能直接暴露哪些线程在疯狂消耗 CPU比如 GC 线程占用过高说明 JVM 正在不停回收某个业务线程占用过高说明热点代码被卡死在自旋或者循环里。二是thread -b用于定位死锁thread -b它会把阻塞其他线程的肇事线程找出来并打印对应的锁对象。这个在排查线程死锁或者线程池耗尽时非常有效。配合thread --state WAITING可以看所有处于等待状态的线程判断线程池是否因为任务积压导致大量线程都阻塞在等待某个资源。GC 状态在 dashboard 的输出里也会显示包括 GC 次数和耗时。如果 Full GC 次数明显异常比如每秒都在 Full GC那基本不用怀疑内存分配或泄漏已经非常严重了下一步就是抓 dump 和对象引用链。3.4 类和对象追踪sc、watch、trace、stack、monitor如果你怀疑某个业务方法有问题Arthas 的命令矩阵可以让你像调试本地代码一样检查线上行为。sc用来搜索类sc -d com.example.FooService会输出类的加载器、注解、字段等详细信息。sm类似用来搜索方法。watch是我最常用的命令它可以观察某个方法执行时的入参、返回值和异常watch com.example.OrderService createOrder {params, returnObj, throwExp} -x 2-x 2控制对象展开深度避免打印嵌套过深导致输出爆炸。如果某个方法偶发返回异常这句命令可以直接抓现场。trace用来追踪方法内部调用链的耗时分布这是性能瓶颈分析的神器trace com.example.OrderService createOrder #cost 100#cost 100是过滤条件只打印耗时超过 100ms 的调用。它会展示方法内每个子调用的耗时一眼就能定位慢在哪个环节。stack命令则用来输出当前方法被调用的调用栈适合反向寻找入口路径。比如某个方法突然被大量传入异常参数用stack看看是哪些上游在调它。monitor是方法调用监控会统计一段时间内的调用次数、成功次数、失败次数、平均耗时。适合对怀疑对象做持续观测比如确认修复后调用耗时是否回落。3.5 对象与内存现场heapdump、redefine、logger、profilerheapdump命令可以在不重启进程的情况下导出一份堆 dump 文件heapdump /tmp/app.hprof加上--live参数可以只保留 live 对象减少文件体积但注意这样会先触发一次 Full GC生产环境要斟酌时机。Dump 文件导出后再用 MAT 或 JProfiler 做离线分析这是定位内存泄漏的黄金路线。redefine命令支持热替换已加载类的字节码。线上有个紧急 bug 时如果改动范围很小可以在本地改完代码、javac编译好 class 文件然后通过redefine直接替换线上运行中的类。这个命令虽然好用但有一定风险它不会校验新旧版本兼容性替换后如果方法签名变了会直接报错。我一般只在组件类 bug 导致不可用、重启成本极高时使用平时不推荐。logger命令不仅可以查看日志配置还能动态修改指定 logger 的日志级别。排查问题时临时把 DEBUG 级别打开定位完再改回 INFO比改配置重启服务效率高太多。profiler命令可以生成火焰图用来分析 CPU 热点profiler start # 等待一段时间采集 profiler stop --format html --file /tmp/cpu.html火焰图对于CPU 被打满但线程栈看不出问题的场景非常有效能看到热点函数的占比。4. 完整实战一次线上 OOM 从报警到修复的全流程4.1 现象描述与现场初判假设我们现在遇到一个真实场景某检索服务运行在 4C8G 的容器里JDK 8G1 收集器堆内存设置-Xmx4g -Xms4g。下午两点左右开始报警监控面板显示老年代占用持续上涨从 40% 一路顶到 95%Full GC 次数从每小时 1 次变成每 5 分钟 1 次紧接着触发 OOM 告警。这是典型的GC 后内存降不下来信号。我的第一反应是大概率是内存泄漏不是流量突增。但为了排除流量因素我顺手看了下 QPS 曲线和响应耗时。QPS 稳定没有明显突刺排除瞬时大流量导致的老年代打满。4.2 数据取证先抓 GC 日志和 Dump 再谈分析先做的不是重启而是抓现场。好在服务已经通过 JVM 参数开启了 OOM 自动导出 Dump 能力-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/。报错之后文件目录下已经多了一个.hprof文件体积大概 3.8G接近堆上限。同时把 GC 日志拉出来。如果没开 GC 日志我喜欢用jstat先采样但启动参数里的 GC 日志才是事后分析的完整依据-verbose:gc -Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStampsGC 日志里可以看到老年代的使用量、GC 前后的占用变化。关键观察点是每次 Full GC 之前老年代占用 90%之后只降到 85%说明回收效果很差大量对象根本回收不掉。4.3 用 Arthas 看现场线程、内存、类加载器逐项排查由于服务还没有完全宕机老年代 95% 但还能勉强运行我用 Arthas attach 上去看实时状态。首先是memory命令memory输出里老年代的使用量以肉眼可见的速度在上涨每 10 秒涨几 MB。而 Eden 区的使用率反而正常说明新对象分配并没有暴涨老年代上涨不是新生代晋升的自然结果更像是有东西在持续往老年代里塞对象。然后是thread -n 5。结果里 G1 的 GC 线程 CPU 占用比较高但这是内存不足导致的症状不是根因。有两个业务线程在持续调用某个缓存预热方法CacheManager.loadAll()这个方法会加载全量业务配置到内存中的静态 Map 里。看到这里思路就清晰了很多缓存类对象持续膨胀占了老年代大量空间。但缓存膨胀到底是数据量真的大还是 Map 里存在泄漏、旧数据没被清理还需要看对象引用链。4.4 深挖原图用 MAT 找出泄漏根因现场数据收集完之后我把 heap dump 下载到本地用 Eclipse MAT 打开。MAT 打开后会生成一份Leak Suspects报告它会自动把最可疑的对象找出来并估算这些对象占用的 Retained Heap 大小。这个报告不一定直接给出根因但它会告诉你大概率是谁在占用内存。我看到的报告第一嫌疑是一个java.util.HashMap实例Retained Heap 占了 2.1G也就是堆内存的一半以上。右键点击这个对象选择Path to GC Roots它会显示从 GC Roots 到该对象的最短引用链。结果指向了CacheManager的静态字段configCache。继续看这个 HashMap 的键值结构发现 key 是配置项 IDvalue 是一个自定义对象ConfigItem。这个对象内部又持有上次的查询结果列表ListString resultCache。问题就出在这里loadAll()方法每次加载时不是新建 ConfigItem而是从 Map 里取出已有的对象更新部分字段但resultCache是直接addAll进去的旧数据没有被清空。每次定时全量加载resultCache就膨胀一倍直到把老年代打爆。这个泄漏非常典型不是简单只加不减而是更新逻辑里忘了先clear()再addAll()。4.5 代码修复与参数调整落地修复代码比较简单在更新 resultCache 前先清空集合或者每次重新 new 一个 List 再赋值。但这只是止血我还需要验证调整参数是否必要。排查发现堆内存设置为 4G 本身在这个服务规模下是够用的问题纯粹是泄漏导致。所以我没动-Xmx只做了一处防御性参数调整把元空间最大值从默认调到一个明确的上限避免以后动态类加载失控时再次把容器内存拖垮。同时在启动参数里补上 GC 日志、OOM Dump、以及 JDK 8 下的-XX:ExitOnOutOfMemoryError让 OOM 时进程直接退出交给容器编排自动拉起而不是让一个内存已经畸形的进程继续半死不活地提供服务。-Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis100 -Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/ -XX:ExitOnOutOfMemoryError这里有个细节值得说-Xms和-Xmx保持一致。生产环境如果两者不一致JVM 会在流量高峰期拼命扩容堆过程中伴随 STW 停顿增加 Full GC 风险。直接固定成同一值少一个不稳定因素。4.6 回归验证与上线后观察修复后先在灰度环境压测。压测脚本模拟双倍 QPS 跑 30 分钟观察老年代占用曲线稳定在 50%60% 上下波动不再单边膨胀。Full GC 次数从每小时 12 次降到每 6 小时 1 次左右。上线后我继续盯了两天。用 Arthas 的memory命令每 30 分钟采样一次确认老年代维持在稳定水位没有再出现向上攀爬的走势。同时配合监控面板把老年代使用率、Full GC 频率、GC 耗时三个指标加进日常巡检看板。这些指标比 CPU 和 QPS 更能早期暴露内存问题。5. JVM 核心参数调优策略与 GC 选型避坑指南5.1 堆与分代参数这些数值到底怎么定此处进入常规参数设计。堆内存大小没有标准答案但有几个合理参考先看容器物理内存。假设是 8G 的容器操作系统本身和 Cgroup 里各种 Agent 进程要留一部分JVM 之外还可能跑日志收集、监控采集等组件。我一般把堆设为物理内存的 50%70%也就是 4G5G。堆外还要给元空间、直接内存、线程栈留空间。如果设得太满比如 6GOS 内存不够时会被 OOM Killer 直接杀掉反而不如 4G 稳定。对于分代比例新生代大小直接影响 Minor GC 频率。Java 8 默认-XX:NewRatio2老年代是新生代的 2 倍也就是新生代约占堆的三分之一。如果服务创建了大量临时对象可以稍微调大新生代占比比如-Xmn2g给新生代指定大小。但新生代不是越大越好如果新生代过大老年代被压缩大对象晋升没有足够空间Full GC 反而变频繁。SurvivorRatio默认是 8也就是 Eden 区和两个 Survivor 区的大小比例是 8:1:1。这个比例通常不用调但如果 Minor GC 后频繁有对象进入老年代排除代码问题后可以考虑调大 Survivor 区或提高晋升阈值。5.2 CMS、G1、ZGC 选型对比与 G1 常用参数垃圾收集器选型直接影响停顿时间。JDK 8 时代最流行的是 CMS 和 G1JDK 11 之后 G1 成为默认CMS 在 JDK 14 被移除。JDK 17 开始 ZGC 已经算成熟能够把停顿时间做到毫秒级但代价是 CPU 占用会高一些。收集器适用场景核心特点主要问题Parallel批量计算、追求吞吐吞吐优先停顿时间长不适合延迟敏感的服务CMS老年代并发收集低停顿但会产生碎片JDK 14 已废弃G1多核大内存、默认首选可预测停顿、区域化管理超大堆下需要调参ZGC大堆 超低延迟停顿控制在 10ms 内高 CPU 消耗JDK 17 更成熟我当前的主力 JDK 版本是 11 和 17默认 G1 就够用。G1 的核心调优参数是这几个-XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:G1HeapRegionSize8m -XX:InitiatingHeapOccupancyPercent45MaxGCPauseMillis是 G1 的软目标建议设 100200ms 之间不用设到 10ms那是误导G1 会为了追求停顿目标而频繁做并发标记CPU 开销会明显上升。G1HeapRegionSize默认会自动计算小堆和高并发的场景下把它设为 1M8M 有助于更精细地管理大对象。InitiatingHeapOccupancyPercent默认是 45表示堆使用到 45% 时启动并发标记周期。如果老年代上涨比较快可以降到 35提前开始标记减少并发标记阶段突发的 Full GC。5.3 必配的保全证据参数GC 日志与 OOM Dump排查过几次线上问题之后我最大的教训是如果不配置诊断参数出了问题就只能靠猜。所以所有 Java 服务上线的启动脚本里我强制要求包含以下几组参数GC 日志。JDK 8 用-verbose:gc -Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps。JDK 11 用新的方式-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount5,filesize20m。日志要配置轮转不然长时间运行会撑爆磁盘。OOM 现场。-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/这两项必备。OOM 时自动导出的 dump 是定位泄漏最直接的一手资料。另外JDK 8u92 可以加-XX:ExitOnOutOfMemoryError让 OOM 时进程自动退出配合容器自动重启比卡死在那里更容易止损。错误日志。-XX:ErrorFile/data/logs/hs_err_pid%p.logJVM 崩溃时的原生错误日志。虽然不常用但真遇到 JIT 崩溃或者本地方法段错误时这份日志是救命稻草。5.4 调优误区常见的翻车点参数越大越好。把-Xmx设满整台机器的内存结果堆外内存、元空间、线程栈一起把物理内存挤爆被系统 OOM Killer 杀掉。内存分配应当给容器整体留出 30% 左右的余量。所有服务同一套参数。有的服务是 IO 密集型有的是计算密集型有的启动几十个线程池。统一套参数等于没调优。每个服务至少应该根据堆外内存需求、对象分配速度、延迟敏感度来单独评估。盲目追求低停顿。MaxGCPauseMillis设太低G1 频繁做并发周期CPU 飙升吞吐下降。GC 调优的目标是在业务可接受的延迟范围内尽量不出现不可控的长停顿而不是把每次 GC 压到最短。JDK 版本和参数不匹配。很多老文章提到 CMS 参数在 JDK 17 上使用会直接报Unrecognized VM option。升级 JDK 时一定要先审查启动脚本里的参数是否兼容。6. 常见问题与排查技巧实录6.1 高频问题速查表现象可能原因排查手段快速缓解老年代持续上涨Full GC 后不回落内存泄漏heapdump MAT 分析 GC Roots重启服务短期加大堆多次 GC 仍 OOM报GC overhead limit exceeded堆太小或泄漏看 GC 日志、堆 dump立即重启事后分析Metaspace OOM动态类生成或热部署泄漏Arthasclassloader -t查看加载器数量重启检查热部署方案CPU 满载但业务无明显流量GC 线程占比高 / JIT 异常dashboard 看线程、profiler 火焰图抓线程栈查热点方法线程数飙升到上限后报 native thread线程池未复用、线程泄漏thread -n、系统pstree -p重启评估线程池配置堆内存正常但进程 RSS 持续增长直接内存 / 元空间 / native 内存泄漏memory查看堆外内存、NMT 工具调大或限制直接内存检查 Netty 缓冲池6.2 我踩过的坑与独家技巧第一个坑是不考虑容器识别就调堆大小。早期在容器里跑 Java 服务JDK 8 默认不识别 Cgroup 限制-Xmx设成 4G但操作系统看到的是物理机整机内存这时候一旦容器内存超限直接被杀。后来统一改用 JDK 8u191并显式用-XX:MaxRAMPercentage50.0这类按百分比分配的方式让 JVM 按容器配额计算默认堆大小而不是按物理机内存。第二个坑是 dump 文件太大根本下载不下来。一次 4G 堆的 dump 文件有 4G 左右从生产环境传到本地特别费劲。我的做法是先用jmap -dump:live抓 live 对象或者用 Arthasheapdump --live先把大部分垃圾过滤掉再分析。实在要分析全量 dump可以在服务器上直接跑 MAT 的 headless 模式生成报告只把报告文本拉回本地。第三个技巧是关于thread -n的误判。某个服务 CPU 高thread -n 3显示最忙的是 GC 线程新手很容易以为是 GC 配置问题。但 GC 线程忙只是结果不是原因。要顺着往上看是谁在分配对象、是谁在触发 GC。此时用trace追踪热门业务方法的对象分配或者用async-profiler分配剖析才能找到真正的源头。第四个技巧是 OOM 后不要急着重启。我知道很多人看到 OOM 第一反应是赶紧重启恢复服务。但如果服务还能撑住几秒钟建议先把 dump 导出来再重启尤其是用 Arthasheapdump手动导出 OOM 现场。没有 dump你只能靠猜。如果已经配置了HeapDumpOnOutOfMemoryError重启前先确认.hprof文件已经生成完毕不要因为异常处理流程中途退出导致丢失现场。6.3 再说说面试和日常中常被追问的几个点很多面试官喜欢问 JVM 内存模型和调优问题实际上调优思路比参数背得熟更值钱。我判断一个人是否真的处理过线上问题就看两件事一是他会不会先说 先看 GC 日志和 dump 再动参数二是他能否讲清楚jstack、jmap、Arthas各自适合什么场景。从实际工作角度线上 OOM 怎么快速定位的答案可以浓缩成五步看 GC 日志判断是分配过猛还是回收失效用memory看哪个区域爆了用thread和工具定位可疑线程用 heapdump 加 MAT 分析泄漏根因最后才是修正代码或调整参数。这套路径我已经跑通了很多次希望这篇文章也能让你在下次遇到 OOM 时不再是摸着石头过河而是拿着地图进山。