资讯动态

JVM调优实战:Full GC、OOM排查与GC收集器选型

发布时间:2026/10/29 12:45:41 来源:尧图企业网站定制
简介《深入理解Java虚拟机JVM高级特性与最佳实践第3版》电子版面向Java后端开发者、准备性能调优与面试进阶的技术人员系统梳理JVM运行时内存区域、垃圾收集器、类加载机制、字节码执行引擎及故障诊断工具帮助读者建立从原理到实战的知识框架。压缩包内仅含1个PDF文件大小约28.65MB便于在电脑或平板上阅读与检索。已有747人学习下载具备一定参考热度。内容覆盖HotSpot对象探秘、OutOfMemoryError实战、Serial至G1及Shenandoah/ZGC等收集器、jps/jstat/jmap/jstack与JConsole/VisualVM等工具并附调优案例与Eclipse调优实战适合按章节系统精读也可在排查内存溢出、停顿和收集器选型时对照查阅获取可落地的分析思路与调优经验。1. 线上 Full GC 打到 800ms 那次我又把这本书翻了出来一次压测8C16G 的容器、堆开到 12G接口 P99 从 80ms 抖到 700ms。jstat 显示 Full GC 每三分钟来一次每次接近 800ms。组里有人说堆调大点有人说直接换收集器没人说得清到底是晋升失败、元空间在长还是卡表扫描拖的。那晚翻回《深入理解Java虚拟机JVM高级特性与最佳实践第3版》第3章讲收集器与内存分配、第4章讲监控工具、第5章讲调优案例能对着现场一条条查。这本第3版覆盖 JVM 五大块——走近 Java、自动内存管理、执行子系统、编译优化、高效并发13 章外加 5 个附录技术基线停在 JDK 12/13 那个时间点。它不适合只想背八股的人适合写过两年以上 Java、被 OOM 和 GC 停顿真实折腾过的开发者、调优岗和架构岗。2. 七块运行时区域把内存溢出现场做成可复现的工程很多人对内存区域的认知停在「堆和栈」真出问题时才发现OutOfMemoryError后面那半句才是关键——是Java heap space是Metaspace是unable to create new native thread还是Direct buffer memory。这四种报错对应四块完全不同的区域、四套完全不同的排查路径。这一章把书里第2章的内容拆成可以自己跑一遍的最小工程跑完你对「哪块区域溢出长什么样」会有肌肉记忆。2.1 七块区域的边界与各自的溢出「长相」先把边界钉死。程序计数器是唯一不会抛 OOM 的区域虚拟机栈和本地方法栈在 HotSpot 里合二为一栈深度超限抛StackOverflowError栈帧申请不到本地内存抛OutOfMemoryError堆是对象实例的主场方法区在 JDK 8 之后由元空间实现跑在本地内存里运行时常量池逻辑上属于方法区但字符串常量池从 JDK 7 起已经搬到了堆里这个搬家动作是很多「常量池溢出」实验对不上号的根本原因直接内存不由 JVM 管理靠-XX:MaxDirectMemorySize约束。区域存放内容溢出表现常用观测手段程序计数器当前字节码行号不会 OOM无虚拟机栈栈帧、局部变量表StackOverflowError / OOMjstack、-Xss调小复现本地方法栈native 方法栈帧同上jstack、线程数统计Java 堆对象实例、数组Java heap spacejmap -histo、jcmd GC.heap_info方法区/元空间类元信息、运行时常量Metaspacejmap -clstats、jcmd GC.class_stats运行时常量池符号引用、字面量Metaspace或堆溢出jmap -histo看 String 数量直接内存NIO DirectByteBufferDirect buffer memory-XX:MaxDirectMemorySize、NMT提示字符串常量池在 JDK 7 之后位于堆中用String.intern()做「常量池溢出」实验时要确认-Xmx而不是-XX:MaxMetaspaceSize才是决定因素。2.2 四类 OutOfMemoryError 的最小复现工程下面四段代码我都尽量压到最短每一段配一组启动参数本地跑一遍十几秒就能看到报错。跑之前建议单独建一个空工程避免被框架的类加载干扰。import java.util.ArrayList; import java.util.List; public class HeapOOM { private static final int _1MB 1024 * 1024; public static void main(String[] args) { // 启动参数-Xms20m -Xmx20m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath./heap.hprof Listbyte[] holder new ArrayList(); int i 0; while (true) { holder.add(new byte[_1MB]); // 每轮占 1MB持有强引用防止被回收 i; } } }这段代码的逻辑很直白用一个静态 List 持有所有字节数组堆无法回收撑到-Xmx20m上限就抛java.lang.OutOfMemoryError: Java heap space。-XX:HeapDumpOnOutOfMemoryError是后悔药导出 hprof 之后用 MAT 或 JProfiler 打开看 Dominator Tree 就能定位是谁在持有这批对象。-XX:HeapDumpPath建议指向空间充足的盘生产上别放在容器临时层。public class StackOOM { private int depth 0; public void recursion() { depth; recursion(); // 无出口递归持续压栈帧 } public static void main(String[] args) { // 启动参数-Xss180k越小越容易溢出也越接近真实业务里的深递归 StackOOM demo new StackOOM(); try { demo.recursion(); } catch (Throwable t) { System.out.println(max depth demo.depth); throw t; } } }-Xss决定单线程栈大小HotSpot 在 64 位 Linux 上默认约 1MB。把它压到 180k 后递归深度会迅速触顶并抛StackOverflowError。这里真正有价值的不是报错本身而是打印出来的max depth——它约等于「栈大小 ÷ 单帧大小」能让你对业务里那种嵌套几十层的递归调用是否危险有个量感。注意栈溢出和「创建线程失败」是两件事后者在-Xss调大之后反而更容易出现因为每个线程都要占一块本地内存。import net.sf.cglib.proxy.Enhancer; public class MetaspaceOOM { public static void main(String[] args) { // 启动参数-XX:MaxMetaspaceSize32m -XX:HeapDumpOnOutOfMemoryError long count 0; while (true) { Enhancer enhancer new Enhancer(); enhancer.setSuperclass(Object.class); enhancer.setUseCache(false); // 关键关掉类缓存才会不断生成新的 Class enhancer.create(); count; } } }元空间溢出必须靠「不断产生新的类型」才能复现。JDK 自带的Proxy.getProxyClass对同一组接口会返回缓存类跑不出效果CGLIB 的setUseCache(false)才能绕开缓存每次生成一个全新的Object$$EnhancerByCGLIB$$xxx类。跑起来之后控制台会抛java.lang.OutOfMemoryError: Metaspace。这条路径和线上真实事故高度同构——动态代理、字节码增强、热部署凡是「运行时生成类」的地方元空间都会缓慢地往上爬。import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; public class DirectOOM { public static void main(String[] args) { // 启动参数-Xmx20m -XX:MaxDirectMemorySize10m ListByteBuffer list new ArrayList(); while (true) { list.add(ByteBuffer.allocateDirect(1024 * 1024)); // 每轮申请 1MB 堆外内存 } } }ByteBuffer.allocateDirect走的是本地内存不受-Xmx约束只受-XX:MaxDirectMemorySize限制不显式设置时默认约等于最大堆。报错是java.lang.OutOfMemoryError: Direct buffer memory而且堆 dump 里往往看不出异常——这正是堆外内存事故最坑的地方堆干干净净容器却被 kill 了。2.3 参数映射表每个区域对应哪个开关书里第2章散落在各节的参数我按区域整理成一张表配着上面的实验一起看更容易记住。区域参数含义实践取值建议堆-Xms/-Xmx初始/最大堆生产设成相等避免动态扩容抖动堆-Xmn新生代大小G1 下不生效Parallel 下常用堆-XX:SurvivorRatioEden 与单个 Survivor 比默认 8对象存活率高的服务可降到 4栈-Xss单线程栈大小默认 1M线程数多时降到 256k~512k元空间-XX:MetaspaceSize首次触发元空间 GC 的阈值设 128m~256m避免早期频繁 Full GC元空间-XX:MaxMetaspaceSize元空间上限必须显式设置否则会吃光容器内存直接内存-XX:MaxDirectMemorySize堆外上限按 Netty/RPC 框架用量估留 20% 余量堆转储-XX:HeapDumpOnOutOfMemoryErrorOOM 时导出生产必开路径指向持久盘本地内存追踪-XX:NativeMemoryTrackingsummaryNMT 开关排查堆外问题时开有约 5% 性能开销这段表格里最容易被忽略的是-XX:MetaspaceSize。它不是上限是「第一次触发元空间回收的阈值」默认值很小意味着应用刚起步就要经历几次元空间扩容外加 Full GC。把它调到 128m 以上是性价比极高的一次配置修正。2.4 用 jcmd 把区域占用读出来书里第4章的工具章给了很多命令我这里只留最常用的两条组合都是在容器里能直接跑的。# 看堆的实时用量与提交量比 jstat 更直观 jcmd 1 GC.heap_info # 看元空间里各个 ClassLoader 加载了多少类排查类泄漏必用 jcmd 1 GC.class_stats -all | sort -k3 -n -r | head -20GC.heap_info输出里重点看used与committed的差差得越多说明堆还没真正用满但已经向 OS 申请了内存committed接近-Xmx就要警惕了。GC.class_stats需要加-XX:UnlockDiagnosticVMOptions才能用输出里第三列是类数量排序后看 Top20 的 ClassLoader——如果某个自定义加载器的类数量在持续增长基本可以锁定类泄漏。这两条命令配合-Xlog:gc的日志能把「哪块区域在长」这个问题回答清楚大半。3. 垃圾收集器选型从 Serial 到 ZGC 的一条决策线第3章是这本书里被翻得最旧的一章。它没有停留在「标记-清除会碎片化」这种程度而是把分代假说、记忆集、卡表、写屏障、并发标记的三色抽象一路讲到收集器的实现差异。选型这件事之所以难是因为它不是一个性能问题而是一个「你的堆多大、你的停顿预算多少、你的 JDK 版本在哪」的三维约束问题。3.1 分代假说没变但「怎么回收」变了三次分代收集的理论基础是两条弱分代假说绝大多数对象朝生夕灭熬过越多次回收的对象越难死。基于这两条堆被切成新生代和老年代新生代用复制算法换低停顿老年代用标记-清除或标记-整理换空间利用率。跨代引用怎么办靠记忆集HotSpot 里落地成卡表每 512 字节一张卡对象引用变更时通过写屏障把对应卡标记为脏回收时只扫脏卡。这套机制解释了为什么「堆越大Full GC 越慢」——不是对象多是根节点枚举和卡表扫描的工作量上去了。也解释了 CMS 和 G1 为什么要搞并发标记把可达性分析拆成初始标记、并发标记、重新标记三段只让初始标记和重新标记停顿中间那段和应用线程一起跑。书里对「三色标记 增量更新 / 原始快照」的对比讲得比较细这段内容是理解 CMS 的concurrent mode failure和 G1 的 SATB 队列溢出的前提。我第一次读懂「为什么 CMS 用增量更新、G1 用原始快照」的时候才明白这两个收集器对「漏标」的容忍方向是反的参数调优的直觉也才立起来。3.2 七款收集器的选型表与开启参数书里第3.5、3.6节按「经典」和「低延迟」分了两组我把它们压成一张决策表。这里要提前说清楚CMS 在 JDK 14 已经被移除本文列的版本是这本第3版成书时的技术基线你在新版本 JDK 上不要去开它。收集器作用区域适用堆规模停顿特征开启参数典型场景Serial / Serial Old新生代 / 老年代 100MB单线程 STW停顿明显-XX:UseSerialGC客户端、命令行工具、容器边车ParNew新生代中等Serial 的多线程版-XX:UseParNewGC与 CMS 搭配的历史组合Parallel Scavenge / Old新生代 / 老年代1G~8G吞吐优先停顿可控性弱-XX:UseParallelGC离线批处理、计算密集任务CMS老年代4G~8G低停顿但有并发失败风险-XX:UseConcMarkSweepGCJDK 8 时代的低延迟服务G1整堆4G~32G可设停顿目标Region 化管理-XX:UseG1GC现在的主流默认选择Shenandoah整堆中大堆并发整理停顿与堆大小弱相关-XX:UseShenandoahGC对停顿极敏感、能接受吞吐损失ZGC整堆超大堆TB 级染色指针 读屏障停顿 10ms-XX:UseZGC大堆、低延迟、JDK 17 更稳选型的判断顺序我一般是这样先看停顿预算如果 SLA 要求 P99 在 100ms 以内且堆超过 8G直接在 G1 和 ZGC 之间选再看吞吐是不是第一优先级批处理任务用 Parallel 反而比 G1 快因为它不做并发标记的额外开销最后看版本CMS 只应作为存量系统维持新项目不要引入。G1 的关键参数不止-XX:MaxGCPauseMillis。-XX:G1HeapRegionSize决定 Region 大小1~32MB建议按堆大小除以 2048 取 2 的幂-XX:G1NewSizePercent/-XX:G1MaxNewSizePercent控制新生代弹性区间默认 5%~60%-XX:G1MixedGCLiveThresholdPercent决定一个 Region 存活率超过多少就不参与混合回收。这几个参数之间会互相打架比如把MaxGCPauseMillis设成 50ms 会导致 Young GC 频繁但每次只回收少量 Region最终吞吐掉下去——这类「调了反而更慢」的现象书里第3.7节的「收集器的权衡」讲得比参数手册清楚。3.3 GC 日志的采集姿势与一份日志的逐行读法日志是排查 GC 问题的唯一可信输入但 JDK 8 和 JDK 9 的开启方式完全不同混用会得到一份空文件。# JDK 8 及以前 -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintGCTimeStamps \ -Xloggc:/var/log/app/gc.log -XX:UseGCLogFileRotation \ -XX:NumberOfGCLogFiles5 -XX:GCLogFileSize32M # JDK 9 统一日志框架推荐按 tag 精细过滤 -Xlog:gc*,safepointinfo:file/var/log/app/gc.log:time,uptime,level,tags:filecount5,filesize32M-Xlog的语法是-Xlog:tag 集合级别:输出位置:装饰器:轮转策略。gc*表示所有以 gc 开头的 tag 全开safepointinfo单独把安全点日志拉进来这一条在后面排查「GC 日志干净但接口抖动」时是决定性的。装饰器加上time,uptime,level,tags之后每行日志都带时间戳和来源 tag多线程输出也不会串行。一份 G1 的 Young GC 日志大致长这样数值为示意[2024-05-11T10:12:31.8820800][info][gc,start ] GC(12) Pause Young (Normal) (G1 Evacuation Pause) [2024-05-11T10:12:31.8830800][info][gc,phases ] GC(12) Pre Evacuate Collection Set: 0.2ms [2024-05-11T10:12:31.8830800][info][gc,phases ] GC(12) Evacuate Collection Set: 18.4ms [2024-05-11T10:12:31.8830800][info][gc,phases ] GC(12) Post Evacuate Collection Set: 2.1ms [2024-05-11T10:12:31.8830800][info][gc,heap ] GC(12) Eden regions: 240-0(240) [2024-05-11T10:12:31.8830800][info][gc,heap ] GC(12) Survivor regions: 12-30(30) [2024-05-11T10:12:31.8830800][info][gc,heap ] GC(12) Old regions: 380-382(382) [2024-05-11T10:12:31.8830800][info][gc,heap ] GC(12) Humongous regions: 6-6(6) [2024-05-11T10:12:31.8830800][info][gc,heap ] GC(12) Metaspace: 98304K-98304K(1056768K) [2024-05-11T10:12:31.8830800][info][gc ] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 638M-418M(2048M) 21.0ms读法上抓四个点最后一行括号里的638M-418M(2048M)是回收前、回收后、堆总容量如果回收后长期贴着总容量走说明老年代快满了Evacuate Collection Set的耗时是真正的复制成本它涨说明存活对象多Humongous regions不为 0 且持续增长说明有大对象超过 Region 一半在反复分配这是很多「莫名其妙的 GC」的源头Metaspace那一行如果每次都在涨直接去查类加载。3.4 用三个实验验证分配与晋升规则书里第3.8节列了五条分配规则我挑了三条最容易在线上被违反的做成可验证的小实验。跑之前统一加参数-Xms60m -Xmx60m -Xmn20m -XX:SurvivorRatio8 -XX:PrintGCDetailsJDK 9 把最后一项换成-Xlog:gc*。public class AllocationDemo { private static final int _1MB 1024 * 1024; public static void main(String[] args) throws Exception { // 实验一Eden 分配。20m 新生代Eden 约 16m分配 12m 不会触发 GC byte[] a1 new byte[2 * _1MB]; byte[] a2 new byte[2 * _1MB]; byte[] a3 new byte[2 * _1MB]; // 实验二大对象直接进老年代阈值由 -XX:PretenureSizeThreshold 控制 // 注意该参数只对 Serial / ParNew 有效G1 下不生效 byte[] big new byte[4 * _1MB]; // 实验三长期存活对象晋升。循环 8 次小对象分配逼 Survivor 轮转 for (int i 0; i 8; i) { byte[] tmp new byte[_1MB / 4]; Thread.sleep(10); } System.out.println(a1.length a2.length a3.length big.length); } }三个实验分别验证三件事前三个 2MB 对象加起来 6MBEden 装得下日志里不应该出现 Young GC-XX:PretenureSizeThreshold3m加上之后那个 4MB 的big会跳过 Eden 直接落到老年代日志里老年代容量会立刻增加 4MB最后那个循环配合-XX:MaxTenuringThreshold1和-XX:PrintTenuringDistribution能看到对象年龄分布验证「每熬过一次 Minor GC 年龄加一」。至于动态年龄判定——Survivor 中同龄对象总和超过一半就提前晋升——它没有开关只能通过PrintTenuringDistribution的输出反推这一点书里讲得比较克制实际排查时容易被忽略。4. 类加载与字节码双亲委派被打破之后问题出在哪第6到第9章这一块很多人读的时候觉得「离业务远」直到遇见NoClassDefFoundError、ClassCastException: xxx cannot be cast to xxx同一个类名却转不过去、或者热部署之后元空间一路涨。这些问题的根都在类加载器上而类加载器的行为又由 Class 文件结构和双亲委派模型共同决定。4.1 Class 文件结构与常量池NoClassDefFoundError 的第一现场Class 文件的结构是魔数0xCAFEBABE、次版本号与主版本号、常量池、访问标志、类索引/父类索引/接口索引、字段表、方法表、属性表。其中主版本号决定了这个 class 能不能被当前 JVM 加载——JDK 8 对应 52JDK 11 对应 55JDK 17 对应 61。版本不匹配时报的是UnsupportedClassVersionError比NoClassDefFoundError明确得多。真正难查的是常量池。常量池里有两类内容字面量文本字符串、final 常量值和符号引用类和接口的全限定名、字段名、方法名与方法描述符。类加载的「解析」阶段就是把这些符号引用替换成直接引用。如果解析时找不到对应目标抛的就是NoClassDefFoundError或NoSuchMethodError——注意这两个错误的触发时机是「第一次主动使用这个类」而不是启动时所以经常出现在「上线跑了三天才炸」的场景里。注意NoClassDefFoundError和ClassNotFoundException不是一回事。前者是「编译期存在、运行期找不到」多半是依赖冲突或打包漏了后者是「显式用字符串名去加载但没找到」通常出在反射、SPI、自定义加载器的路径上。书里第6章还专门讲了 JDK 9 之后为模块化新增的Module、ModulePackages、ModuleMainClass三个属性以及 JDK 11 为嵌套类访问控制新增的NestHost/NestMembers。这些新增属性听起来冷门但在你做字节码增强比如 APM 探针、AOP 框架时如果增强工具不认识这些属性就会在增强之后把类改坏报出非常诡异的验证错误。4.2 类加载五个阶段的时序与初始化的六个触发点加载、验证、准备、解析、初始化这五个阶段里只有加载、验证、准备、初始化是严格按顺序开始的解析可以推迟到初始化之后为了支持动态绑定。准备阶段给静态变量分配内存并设零值注意是零值不是代码里的初始值——public static int value 123在准备阶段之后是 0赋 123 发生在clinit()里也就是初始化阶段。初始化的触发时机书里列了六种我按「线上最容易踩」的顺序重排new、读取或设置类的静态字段被 final 修饰且编译期可确定值的除外、调用类的静态方法反射调用初始化子类时先初始化父类虚拟机启动时指定了main方法的主类使用java.lang.invoke.MethodHandle解析到REF_getStatic等句柄时接口中定义了 default 方法实现类初始化时接口要先初始化。这套时序解释了一个经典现象静态代码块里的日志没打出来但类明明被用到了。原因通常是这个类只是「被加载」而没有「被初始化」比如只做了类型声明、只用了它的常量、或者通过数组定义引用了它。4.3 双亲委派被打破的三种典型架构双亲委派模型的逻辑是收到加载请求先委派给父加载器父加载器加载不了才自己加载。它的价值是保证java.lang.Object这类基础类在整个 JVM 里只有一份。但这个模型在三种场景下必须被打破书里第9章给了完整案例。场景打破方式典型现象SPI / JDBC 驱动加载父加载器请求子加载器加载实现类线程上下文类加载器驱动类明明在 classpath 里却报NoClassDefFoundErrorWeb 容器多应用隔离每个应用一个 WebApp ClassLoader先自己加载再委派两个应用依赖同一个库的不同版本互相覆盖OSGi / 热部署网状委派按包级别导入导出同一个类被两个加载器加载出现 ClassCastException第一种是「父加载器看不见子加载器的类」后两种是「同一个类被加载了多份」。后者最典型的表现就是明明类型一样却转不过去因为 JVM 判定两个类是否相同看的是「加载器 全限定名」缺一不可。这也是热部署做多了元空间会涨的原因旧的 ClassLoader 只要还有一个引用没断它加载的所有类都卸载不掉。4.4 用 javap 和自定义 ClassLoader 验证加载顺序验证类结构最快的工具是javapJDK 自带不用装任何东西。# -v 输出常量池、版本号、属性表等完整信息 javap -v -p target/classes/com/example/Demo.class demo.txt # 只看常量池和方法签名排查 NoSuchMethodError 时更快 javap -s -p target/classes/com/example/Demo.class # 直接把某段代码编译后的字节码指令打出来 javap -c target/classes/com/example/Demo.class-v的输出里开头几行就是major version: 52这样的版本号跟当前 JVM 对不上就是版本问题中间一大段是Constant pool每一项前面有序号#12 Methodref这种就是符号引用再往下是方法的Code属性里面是真正的字节码指令。-c更适合快速对照源码看某一行编译成了什么指令比如i和i的差异在这层看得一清二楚。下面这个自定义加载器用来看「委派到底先给了谁」跑起来会打印每次loadClass的调用链。import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; public class TraceClassLoader extends ClassLoader { private final Path classDir; public TraceClassLoader(Path classDir, ClassLoader parent) { super(parent); // 传入 parent 才会走双亲委派传 null 则走 Bootstrap this.classDir classDir; } Override protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { System.out.println([loadClass] name resolve resolve); // 默认实现就是双亲委派先 parent.loadClass抛 CNFE 后再 findClass // 想观察「破坏委派」的效果把下面这行注释掉改成先 findClass 再委派 return super.loadClass(name, resolve); } Override protected Class? findClass(String name) throws ClassNotFoundException { String path name.replace(., /) .class; try { byte[] bytes Files.readAllBytes(classDir.resolve(path)); // defineClass 是 native 方法负责把字节数组转成 Class 对象 return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } }这段代码的关键在super(parent)和super.loadClass两处。父加载器传null表示直接用 Bootstrap ClassLoader 作为父节点通常只有 JDK 自身的加载器会这么干传应用类加载器则是最常见的做法。loadClass里的打印顺序直接反映了委派链先打出来的是最外层请求然后你会看到同一批java.lang.*的类被逐级向上传最终由 Bootstrap 命中并返回。想验证「打破委派」把super.loadClass换成本类先findClass、失败再委派然后加载一个和 classpath 里同名的类就能观察到两个不同加载器加载出的同名类无法互相赋值——ClassCastException就是这么来的。5. 五个反复翻车的现场排查清单与避坑记录这一章是我自己攒的。书里第5章的案例偏「方法论」实战里遇到的坑往往更碎下面五个是我在不同项目里真实撞上、并且每次都要重新想一遍的问题。每条按「现象 → 原因 → 解决」写你可以直接拿去对号入座。5.1 容器被 killJVM 日志里什么都没有现象Pod 状态变成OOMKilledexit code 137应用日志最后一行是正常业务输出没有 OOM 堆栈heap dump 也没生成。原因这里是两层叠加。第一层JDK 8u191 之前的版本不识别 cgroup 限制-Xmx是按物理机内存比例算的容器限 2G 它敢开 8G第二层即使识别了 cgroup堆也只是进程内存的一部分元空间、线程栈线程数 ×-Xss、直接内存、代码缓存、GC 自身的数据结构加起来经常超过 limit 的 30%。容器是按整个进程的 RSS 杀的JVM 根本来不及抛 OOM。解决显式设-XX:MaxRAMPercentage70.0配合-XX:UseContainerSupport8u191 默认开启-XX:MaxMetaspaceSize256m-XX:MaxDirectMemorySize按框架用量给-Xss从默认 1M 降到 512k 控制线程栈总量。上线前用-XX:NativeMemoryTrackingsummary跑一轮压测jcmd pid VM.native_memory summary看各分区实际占用把 30% 的余量落到实处而不是拍脑袋。5.2 GC 日志干干净净接口还是抖现象-Xlog:gc*里 Young GC 每次 20ms 左右频率也不高但接口 P99 每隔几分钟就冒一个 300ms 的尖刺抓不到规律。原因停顿不一定来自 GC。安全点Safepoint进入停顿、偏向锁批量撤销、System.gc()的隐式调用、甚至 JIT 的反优化都会造成 STW而它们默认不出现在 GC 日志里。尤其是「线程到达安全点的时间」TTSP如果某个线程在跑一个没有安全点轮询的长循环其他所有线程都得等它。解决日志里加上safepointtagJDK 9 的-Xlog:safepointinfo或者 JDK 8 下用-XX:PrintGCApplicationStoppedTime -XX:PrintSafepointStatistics。看到Total time for which application threads were stopped明显大于 GC 自身耗时就去查那段时间谁在跑长循环。另外-XX:PrintSafepointStatistics会打出vmop类型no vm operation那行如果耗时很长基本就是安全点等待而不是 GC。5.3 CMS 的 concurrent mode failure 反复出现现象日志里CMS Concurrent Mode Failure之后紧跟一次Full GC (Allocation Failure)停顿直接从几十毫秒跳到两秒以上而且越到业务高峰期越频繁。原因CMS 的并发清理阶段和应用线程同时跑期间新晋升的对象就是「浮动垃圾」必须留出足够的老年代空间给它们。如果CMSInitiatingOccupancyFraction设得太高比如 92%或者对象晋升速度超过预期老年代在并发清理还没结束时就满了只能退化成 Serial Old 做一次全停顿的压缩。解决把触发阈值降下来-XX:CMSInitiatingOccupancyFraction70 -XX:UseCMSInitiatingOccupancyOnly开启-XX:CMSScavengeBeforeRemark在重新标记前先做一次 Young GC减少跨代引用的扫描量如果新生代偏小导致晋升过快适当增大-Xmn。这些都是续命手段根子上还是应该评估迁到 G1 或 ZGC毕竟这个收集器在新版本 JDK 里已经不存在了。5.4 元空间缓慢爬升直到 Metaspace OOM现象监控上 Metaspace 曲线是一条稳定上升的斜线重启就归零跑一两周后抛OutOfMemoryError: Metaspace堆内存完全正常。原因类卸载的条件很苛刻——该类的所有实例都被回收、加载该类的 ClassLoader 被回收、对应的 Class 对象没有任何引用三条同时满足才会卸载。动态代理、CGLIB 增强、脚本引擎、热部署这些场景只要有一处缓存了 ClassLoader 或者 Class 对象整批类就永远卸载不掉。框架层的缓存是最隐蔽的比如某个 Bean 容器把Class?存进了静态 Map。解决先用jcmd pid GC.class_stats -all按 ClassLoader 聚合类数量找到持续增长的那个加载器再用jmap -clstats pid看它的classes和liveBytes最后用 heap dump 反查谁持有这个 ClassLoader 的引用。参数层面把-XX:MetaspaceSize设到 128m 以上让元空间更早触发回收能把问题从「OOM」变成「GC 频率上升」给你争取排查时间。5.5 堆调大之后反而更慢了现象把-Xmx从 8G 提到 24G期望减少 GC 次数结果 Young GC 次数确实少了但单次停顿从 30ms 涨到 200ms整体吞吐还降了。原因堆越大单次回收要扫描和复制的对象越多卡表/记忆集的规模也跟着涨。G1 虽然在 Region 层面做了增量回收但-XX:MaxGCPauseMillis设得过于激进时它会把每次 Young GC 的回收量压得很小导致 GC 次数暴增、晋升提前。另外大堆在 NUMA 架构上容易出现跨节点内存访问分配延迟上升。解决先把MaxGCPauseMillis从默认 200ms 调到一个现实值比如 100ms 到 300ms 之间别设 50ms-XX:G1HeapRegionSize按堆大小 / 2048 取 2 的幂24G 堆对应 16MB 比较合适如果单实例堆确实需要超过 32G直接上 ZGC 而不是继续调 G1。我一般更倾向于「多实例小堆」而不是「单实例大堆」8 个 4G 的实例在故障隔离和停顿控制上都比 1 个 32G 的实例好管。6. 把「读过」变成「验证过」一套二十分钟的现场自检流程书里第4章把工具讲全了但工具清单和排查流程是两回事。我在实际项目里沉淀出一套二十分钟能跑完的自检动作新接手一个服务或者线上报警时先走一遍比直接打开监控大盘有效。# 1. 三秒钟看清 JVM 全貌版本、参数、堆、GC 次数 jcmd $PID VM.version jcmd $PID VM.flags | tr \n | grep -E Xmx|Xms|Xmn|Xss|Metaspace|DirectMemory|Use.*GC # 2. 堆与元空间的实时水位 jcmd $PID GC.heap_info # 3. 连续采样 10 次间隔 1 秒看 GC 频率和堆变化趋势 jstat -gcutil $PID 1000 10 # 4. 线程层面有没有阻塞、死锁、大量 WAITING jstack -l $PID /tmp/stack_$(date %s).log # 5. 类加载器聚合排查类泄漏 jcmd $PID GC.class_stats -all | awk NR1{print $3, $1} | sort -rn | head -20这五步的价值在于建立基线。第 3 步的jstat输出里OU老年代使用率如果在 10 次采样中单边上涨、FGC计数在增加但FGCT增量很小说明是频繁的小型 Full GC多半是元空间或System.gc()如果FGCT单次增量超过 500ms才需要去啃收集器参数。第 4 步的jstack我一般连采三次间隔 5 秒用来区分「瞬时阻塞」和「持续阻塞」——只看一次很容易被一个正在做 IO 的线程误导。第 5 步的awk是把GC.class_stats输出里第三列类数量和第一列ClassLoader 名对调后倒排比人眼扫一百行靠谱。两个经验分享一下。第一jstat的GCT是累计值两次采样相减才是这段时间的停顿总量直接看绝对值没有意义这一点我第一次用时也栽过。第二jcmd的所有子命令都可以先跑jcmd $PID help看列表不同 JDK 版本支持的子命令差异不小比如GC.class_stats需要-XX:UnlockDiagnosticVMOptions而GC.heap_info是通用的脚本里要按版本做降级处理。最后说个习惯。从那以后我每次上线新服务都会在压测阶段强制走一遍这套流程并把输出存档尤其是VM.flags和第一次GC.heap_info——线上出问题时你手上有一份「健康时的样子」比任何监控面板都值钱。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑