JVM调优实战:G1与ZGC垃圾收集器的原理、配置与选型指南

发布时间:2026/10/9 7:33:34
JVM调优实战:G1与ZGC垃圾收集器的原理、配置与选型指南
从第一次接触JVM调优到现在我前前后后在好几个低延迟交易系统和高并发服务上用G1和ZGC做过主力垃圾收集器中间踩的坑比想象中多得多。说白了GC选型和调优这事网上能查到的一堆参数列表只是入门真正折磨人的永远是那些“为什么这个参数在这个场景下失效了”的细节。这篇东西我不会去复制官方文档只把我自己真实压测、上线、救火的经验整理出来讲讲G1和ZGC到底是什么、各自适合什么场景、怎么配置、怎么从日志里看出问题以及几个我至今觉得很有价值的排查思路。很多人第一次接触这两个收集器时脑子里最深的疑问是为什么CMS退出历史舞台之后大家默认就往G1上靠而ZGC、Shenandoah又一直被认为是“下一代”要回答这个得先弄清楚JVM内存模型是怎么运作的。HotSpot把堆分成新生代和老年代新生代里又细分成Eden和两个Survivor区绝大部分对象先在Eden分配Minor GC时存活对象在两块Survivor之间来回拷贝年龄够了再晋升到老年代。这套分代模型至今仍然有效G1也没有推翻它只是把“整块连续空间”换成了“很多个大小相同的Region”从这个点出发一切都开始不一样了。我先把JVM层面的几个热词背景简单交代一下再把G1和ZGC拆开揉碎地讲中间穿插大量我实测过的配置和日志解读保证所有东西拿去就能用。1. JVM内存模型与GC基础为什么会有G1和ZGC1.1 运行时数据区与分代假设JVM的运行时数据区划分是理解和调优一切垃圾收集器的前提。程序计数器、虚拟机栈、本地方法栈是线程私有的堆和方法区HotSpot里常表述为Metaspace是线程共享的。堆里又按对象年龄逻辑上分成新生代和老年代这种分代的根本依据是“弱代假设”——绝大多数对象活不了多久少数对象会存活很长时间。新生代里再拆出Eden、From Survivor、To Survivor是为了让Minor GC能通过“标记-复制”把存活对象挪到另一个空Survivor里一次Minor GC之后Eden和一个Survivor变空下次继续用另一块循环往复。HotSpot里的编译器这个话题顺便说一句经常有人搞混。“java jvm编译器有几种”这个问题如果指HotSpot内置的JIT编译器主流就是C1Client Compiler和C2Server CompilerC1编译快但优化少C2编译慢但激进优化多分层编译Tiered Compilation会在两者之间动态切换用解释器先跑、热点代码升级成C1、再升级到C2。JDK 9之后Graal JIT作为实验特性也能启用但生产环境里用C1C2的组合仍然是绝对主流。GC和编译器之间有个隐藏的相互作用编译器生成的代码会影响对象分配模式和存活路径C2的逃逸分析如果做得好很多对象会直接在栈上分配或者干脆消除分配GC压力明显变小。所以调优GC之前先确认代码层面没有明显分配热点否则收集器选择得再好也救不了。JRE和JVM之间的关系也是新手经常绕晕的概念。JRE是Java运行环境包含JVM、核心类库比如rt.jar、java.lang、java.util这些和启动命令。JVM只是JRE里的一个组件负责加载字节码、执行字节码、管理内存。JDK则是开发工具包在JRE之上还包含了javac、jar、jconsole等工具。生产服务器上如果只跑编译好的应用装JRE就够了要做诊断、用jcmd、jstat、jmc这些工具最好还是装完整JDK因为很多诊断命令在精简JRE里并没有。1.2 从Serial到CMS过去几十年的GC演进逻辑最早的Serial GC是单线程的简单可靠但停顿时间不可控适合客户端小应用。Parallel Scavenge和Parallel Old主打吞吐量适合后台批处理任务榨干CPU算完就是赢。CMS的出现是为了解决老年代回收时长时间停顿的问题它把并发标记、并发清除做到和业务线程并行执行理论上停顿能压到很低。但CMS有两个致命弱点一是采用标记-清除算法老年代会产生大量碎片并发失败Concurrent Mode Failure后降级为Serial Old时停顿时间极其恐怖二是CMS对CPU很敏感并发阶段抢占了业务线程的算力在CPU核数不多、负载高的机器上会明显拖慢吞吐。我在一个交易中间件上曾经吃过CMS的亏业务高峰期老年代碎片化严重单次Full GC停顿超过了17秒整个集群P99直接从50毫秒飙到秒级。这类场景出现一次你就明白为什么G1会被寄予厚望它想做的是用一套统一机制同时管理年轻代和老年代而且让停顿时间可预测。1.3 G1和ZGC解决了什么核心问题G1的核心目标是“可预测的停顿时间”。它不再按物理连续空间划分新生代和老年代而是把堆拆成多个大小一致的Region逻辑上新生代、老年代都是一组Region的集合。G1会对所有Region的垃圾程度做排序优先回收垃圾最多的Region这就是Garbage First名字的来历。你设定一个期望停顿时间-XX:MaxGCPauseMillis默认200msG1会尽量在这个预算内决定回收多少个Region做到了“软实时”。ZGC的目标更进一步把停顿时间压到接近与堆大小无关。它通过染色指针、读屏障、多重映射这些技术把标记、转移、重映射的大部分工作做成并发执行理论上停顿只有几个毫秒甚至更低。ZGC刚出来的时候官方给出的数据是10ms以内我当时在128GB堆上实测过很多次平均停顿经常在2-4ms徘徊让人印象深刻。但它也不是免费的并发处理需要额外CPU吞吐量会比G1略低而且大堆、低延迟优先的场景才真正体现出价值。2. G1垃圾收集器深度拆解设计原理与实战配置2.1 Region、RSet与SATBG1三大基石G1把堆划分成等大小的Region默认2048个Region大小可以通过-XX:G1HeapRegionSize显式指定但更常见的是让JVM根据堆大小自己计算规则是让Region数量接近2048大小在1MB到32MB之间按2的幂取。Region被标记为EEden、SSurvivor、OOld、HHumongous四种角色其中H专门存放超过Region大小一半的大对象。RSetRemembered Set是G1能实现跨Region引用跟踪的关键。每个Region维护一个RSet记录“有哪些其他Region的对象引用了我这里的对象”。回收某个Region时不需要扫描整个堆去找谁引用了它只需要看RSet里的记录这大大降低了回收时的扫描范围。但RSet本身也有内存开销对象引用关系越复杂RSet占用的空间越大所以G1调优里有个隐藏点如果应用的对象图过于混乱RSet膨胀会导致GC开销异常。SATBSnapshot-At-The-Beginning是G1并发标记阶段使用的算法。它在开始标记时给对象图拍一个逻辑快照并发标记过程中所有在快照中存活的对象都被认为是存活的。为了做到这一点并发标记期间发生引用变更时会把变更前的值记录下来保证不会漏标记。这个设计避免了CMS那种增量更新算法容易漏标的缺陷代价是可能保留一些本可以回收的浮动垃圾这些垃圾只能等下一轮GC回收。2.2 G1的GC周期拆解Young GC、Mixed GC、Full GCG1的回收周期远不是“Young GC Full GC”两个词能概括的。在正常状态下G1主要做Young GCEden区耗尽时触发把存活对象复制进Survivor区或者直接晋升到老年代。老年代的使用率达到-XX:InitiatingHeapOccupancyPercent默认45%时G1会进入并发标记周期这个周期里包含初始标记、根区域扫描、并发标记、重新标记、清理五个阶段其中初始标记和重新标记会STW并发标记则和业务线程并发执行。并发标记结束后G1进入Mixed GC阶段它会选择部分老年代Region和所有年轻代Region一起回收目标是逐步清理老年代垃圾。Mixed GC通常分多轮进行-XX:G1MixedGCCountTarget默认8控制最多分几轮。如果没有在预定轮数内把老年代清到足够低的水平后续轮次会回收更多Region兜底机制是-XX:G1MixedGCLiveThresholdPercent默认85%高于这个存活率的Region不回收因为复制成本不划算。真正的Full GC在G1里依然是STW的使用Serial Old算法完整标记-整理整个堆。什么情况下会触发最常见的两种一是对象分配过快Mixed GC来不及回收足够空间导致Humongous分配失败或者年轻代晋升失败二是并发标记周期耗时过长老年代持续增长触发了回收失败。遇到Full GC不要只盯着调GC参数先通过-XX:PrintGCDetails确认是“Pause Full”还是“Pause Young (Concurrent Start)”两种的应对方向完全不同。2.3 G1关键参数与调优路径G1的参数非常多但真正在生产环境里值得手动调整的我认为就那么几个。-XX:UseG1GC启用G1JDK 9之后默认就是G1不用显式加但在脚本里加上有助于消除歧义。-XX:MaxGCPauseMillis200期望停顿时间G1会用它作为规划回收的预算。不要把这个值设得极端小比如20ms那会导致每次GC只回收很少Region长期下来老年代堆积垃圾反而容易触发Full GC。我一般从150ms或者200ms起步再根据实际观测调。-XX:G1NewSizePercent5和-XX:G1MaxNewSizePercent60年轻代大小上下限。G1支持动态调整年轻代大小但调整幅度受这两个值约束。如果Young GC频率太高可以调大G1MaxNewSizePercent让年轻代更大一些但注意不要侵占老年代太多空间。-XX:InitiatingHeapOccupancyPercent45老年代触发并发标记的阈值。老年代占用大且Mixed GC迟迟不触发时可以适当调低如果并发标记频繁导致CPU开销高可以调高。-XX:ConcGCThreads并发标记/回收线程数。默认值是堆大小的函数但CPU核数少、业务本身对延迟敏感时建议手动控制别让并发线程把业务线程挤成瓶颈。-XX:G1HeapRegionSize显式指定Region大小。小对象很多时过大的Region会浪费内存大对象很多时Region太小会让大对象直接变成Humongous而Humongous分配容易触发早期的并发周期。需要根据对象大小特征做权衡。我整理一个基于实际压测得到的参考配置场景是4核8GB堆的在线服务-XX:UseG1GC -Xms8g -Xmx8g -XX:MaxGCPauseMillis150 -XX:G1NewSizePercent10 -XX:G1MaxNewSizePercent50 -XX:InitiatingHeapOccupancyPercent60 -XX:ConcGCThreads2 -XX:ParallelRefProcEnabled -XX:AlwaysPreTouch这里有个容易被忽视的点-XX:AlwaysPreTouch。它让JVM启动时就把物理内存全部触达并锁定避免运行期间向操作系统申请内存页时的缺页异常。堆越大这个效果越明显但启动时间会变长。内存不紧张的前提下我强烈建议加上这参数GC的延迟曲线会平稳非常多。ParallelRefProcEnabled这段也值得聊聊。Reference对象的处理在GC里是串行阶段典型场景是大量虚引用、弱引用比如某些缓存库、Netty的资源回收都依赖Reference机制这个阶段如果串行处理停顿时间可能飙高。开启后Reference处理会并行GC停顿能缩短不少。2.4 G1调优的真实案例一个高并发订单服务的GC曲线之前在做一个订单状态机服务QPS峰值大概12000单机堆16GB对象分配速率非常快Young GC间隔不到500毫秒老年代增长也快经常几个小时后出现老年代回收用户支付回调偶尔出现延迟毛刺。当时的GC日志里能看到明显的“Pause Young (Mixed)”耗时越来越长从几十毫秒涨到300多毫秒。我先加了-XX:PrintGCDetails -XX:PrintGCDateStamps日志输出到独立文件然后分析对象分配速率和晋升速率。发现两个问题一是G1MaxNewSizePercent默认偏小年轻代被压得很窄Young GC太频繁二是创建订单时大量临时对象被误判为长期存活晋升速度过快。调整思路是把G1MaxNewSizePercent从默认60改成45注意不是调小是从上限上留出空间给老年代同时确保年轻代能增长到足够宽InitiatingHeapOccupancyPercent从45调到55降低并发标记频次。最关键的一步是用jmap -histo:live和Async Profiler抓了一批调用栈发现有个JSON序列化组件在每次请求时创建了超大char数组正好跨过Humongous阈值。优化掉这个后Humongous分配基本消失Mixed GC压力骤降。最终效果是Young GC P99从200ms降到80msFull GC几乎不再出现。这件事给了一个很明显的教训GC调优永远不只是改JVM参数代码层面的对象分配模式才是根本。参数填得再漂亮分配速率不减垃圾收集器再聪明也是疲于奔命。3. ZGC深度解析染色指针、读屏障与并发转移3.1 ZGC为什么能做到几毫秒停顿ZGC的核心是三条技术染色指针、读屏障、多重映射。染色指针Colored Pointers是ZGC最激进的设计。它直接在64位指针上借用了部分位来存放GC元数据在x86-64 Linux平台上ZGC使用64位寻址中的48位做对象地址剩下的位里拿出一部分标记Finalizable、Remapped、Marked0、Marked1四种状态。这样ZGC在标记、转移过程中不需要访问对象本身就能知道对象是否被标记、是否已重映射极大减少了元数据访问的开销。读屏障Load Barrier则嵌入到JIT编译后的代码里每次从堆中读取对象引用时ZGC会在指令流前段插入一段检查逻辑如果对象的状态显示需要重映射就按记录的函数去修正引用。这个设计让ZGC把“指针修正”从STW阶段挪到了业务线程读写对象的那一刻并发完成所以重映射阶段几乎不产生停顿。代价是每条对象引用读取指令都多了几条判断指令摊下来的CPU开销会高一些。多重映射Multi-Mapping是ZGC把同一块物理内存映射到多个虚拟地址空间的技巧。它用虚拟地址的别名方式让不同状态下的同一对象在多个地址空间都存在映射JVM在推进GC阶段时只需要改映射关系就能让并发线程看到一致的对象视图。何谓优雅这件事在没有如此精妙设计之前你敢想指针颜色和映射机制能共同解决并发GC里最头疼的“访问不一致”问题吗。3.2 ZGC的GC周期并发标记、并发转移、并发重映射ZGC的GC周期大致分成这几个阶段。暂停时间极短的Pause Mark Start阶段这个阶段单次停顿通常不到1ms主要做根对象标记随后进入并发标记阶段业务线程和标记线程并行跑用读屏障标记对象图。标记完成后Pause Mark End有一个非常短的停顿做收尾然后所有存活对象会被移动到新的Region这个过程叫并发转移Concurrent Relocate。转移过程中业务线程通过读屏障感知自己被访问的对象已经“搬家”自动解析新地址。最后的并发重映射阶段清理旧的引用关系让地址空间逐渐一致。ZGC看起来复杂但用起来比G1“傻瓜”不少它没有分代JDK 21的ZGC引入分代之前也不区分Young/Old不设InitiatingHeapOccupancyPercent那种老年代阈值概念。它就是一套统一的全堆回收机制按照内存分配速率和空闲Region数量动态决定何时并发回收。JDK 21里分代ZGC依然在演进但在JDK 17、21的生产实践中无分代ZGC已经足够稳定我这里讨论的主要是16和17版本。3.3 ZGC参数配置和适用场景启用ZGC很简单-XX:UseZGC。盒子上要注意的是ZGC要求Linux内核版本在3.10长期支持版本没问题并且堆大小建议至少2GB以上太小的堆反而ZGC的各种并发机制产生不了收益。值得手动关注的参数-XX:ZCollectionInterval120秒强制两次GC之间的最大间隔。大堆应用里如果业务侧分配压力不大ZGC可能长时间不做回收空闲Region不够时突然启动一次回收瞬时压力反而大。设置一个间隔有助于把GC“摊平”。-XX:ZAllocationSpikeTolerance2分配速率尖峰容忍度默认2调大后ZGC会更激进地提前回收以应对突发分配但可能增加GC频率。-XX:ZGenerational这个参数是JDK 21引入分代ZGC后的开关JDK 21用户想体验分代行为可以显式打开否则默认还是无分代模式具体以你使用的发行版为准。-XX:ConcGCThreads并发线程数经验上建议核数的1/4到1/8别太大ZGC的并发处理已经比较高效线程给多了纯粹抢CPU。我自己的经验是ZGC最适合那种大堆20GB以上低延迟P99目标几十毫秒内的服务。比如实时风控决策引擎单次请求需要访问大量对象堆上长期驻留数是几百GB级别用G1时Full GC仍然偶发切到ZGC后基本消除。需要强调的是ZGC的吞吐量会比G1略低在纯CPU密集、实时性要求不高的后台任务里G1或者Parallel反而是更合理的选择。3.4 ZGC的低延迟优势只有堆大了才能体现这句话我是在实际对比后才有深刻体会的。8GB以内的堆G1和ZGC的延迟差异其实没那么大有时候G1甚至表现更好因为ZGC的读屏障在每次对象访问时都要付出额外代价。而一旦堆到32GB、64GB甚至更大G1的标记和重新标记阶段可能要停顿100毫秒以上ZGC的并发优势才真正显现出来——它反正都并发做堆大小对它本身影响极小。所以如果有人问“我应该用G1还是ZGC”我一般反问他几个问题堆多大P99目标是多少CPU有没有余量如果堆小于16GB老老实实用G1没有悬念。如果堆大、延迟诉求极高、CPU核数多ZGC就是最合理的选项。4. G1与ZGC的全面对比选型与迁移路径4.1 对比表格停顿、吞吐、适用堆大小、调优复杂度我用一个表格把长期实测结果浓缩一下方便做选型。维度G1ZGC目标停顿软实时通常在50~300ms之间波动硬实时感强典型停顿1~5ms堆大小敏感度高堆越大标记/整理成本越高低与堆大小关系不大吞吐量中等偏上整体表现均衡略低于G1读屏障有额外开销适用堆大小4GB~100GB都可以更稳健建议16GB以上越大越划算调优复杂度中高参数众多且互相影响较低主要关注并发线程数和触发策略代表性技术Region, RSet, SATB, Mixed GC染色指针, 读屏障, 并发转移适合应用大部分在线Web服务、微服务超大堆、极低P99、实时决策类系统JDK支持JDK 9默认JDK 11实验JDK 15生产可用这个表不是说ZGC全面优于G1而是两者技术侧重不同。G1的复杂度来自于它要平衡吞吐和停顿ZGC把停顿做绝了但代价是吞吐被牺牲一些。什么时候该选谁取决于你的核心矛盾到底在延迟还是吞吐。4.2 迁移G1到ZGC的路径和注意点从G1迁到ZGC不是改一行配置就行因为我前面说了ZGC对CPU的消耗模式和G1完全不同。我建议按下面几步来先小规模压测验证。选一两台性能压测机复制线上流量把-XX:UseG1GC换成-XX:UseZGC保持其他参数不动先观察GC日志和P99曲线。这个阶段的目的是确认没有因为ZGC特有机制导致明显的分配失败或异常比如ZGC对某些第三方库的踩内存方式是否兼容。多数现代库没问题但你要注意用JNI或者Unsafe直接访问对象的场景ZGC的移动对象机制配合读屏障会让这类代码出奇奇怪怪的BUG。然后灰度低流量节点。在真实集群里选流量最小的节点切换观察几个小时重点看Full GCZGC按设计没有Full GC但如果出现那就是严重兼容问题、CPU使用率、GC周期频率、业务接口延迟。我当时灰度过程中发现ZGC周期频率偏高后来发现是没有设置ZCollectionInterval导致ZGC在空闲时也频繁发起并发回收调了之后立刻安静下来。最后放大范围并持续监控。ZGC上热搜词里“jvm调优”其实一半指的就是这类观察和调整。ZGC的日志比G1简洁但也更抽象建议打开-XX:LogZPhaseStatistics它会定期输出阶段统计能清楚看到并发标记、并发转移分别花了多少时间和并发线程数量。迁移这条路上最大的风险往往不是收集器本身而是团队对新GC的行为模型不熟悉。G1的日志看多了能秒懂ZGC的阶段逻辑刚开始会有点懵但只要掌握了并发周期概念调试起来其实比G1轻松。4.3 踩坑记录ZGC里我用过的错误参数再分享几个实际踩过的坑。某次我在一个64GB堆的服务上为了让ZGC更积极回收把-XX:ZAllocationSpikeTolerance调到了8结果GC次数暴增CPU利用率直接从40%飙到70%业务延迟不降反升。这个参数不是越大越好它控制的是ZGC对分配速率尖峰的预测速度倍率调太高会导致ZGC频繁提前回收白费资源。还有一次在SCI-FI项目的AI推理服务里我误把-XX:ConcGCThreads设成16而机器只有36核并发GC线程和业务线程互相抢CPU最终P99烂得没法看。后来压到8效果立刻好转。ZGC并发线程数的选择要结合业务线程数不是堆越大线程越多。对了还有一个反直觉的ZGC在JDK 16之前的版本对容器环境支持不完善如果使用Docker限制CPU或内存容易在容器边界上踩到奇怪问题。JDK 16修复了大部分此类问题但我仍然建议在容器里跑ZGC前先确认JDK版本和容器资源限制的关系特别是cgroup版本和CPU配额参数。5. 实操过程GC日志解读、压测方法与故障排查实录5.1 看懂G1日志关键指标与判断逻辑GC日志是调优的核心情报源。开启日志的标准姿势-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintGCTimeStamps -Xloggc:/path/to/gc.log -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/to/dumpG1日志里最关键的一段是Young GC[GC pause (G1 Evacuation Pause) (young), 0.0123456 secs] Parallel Time: 8.0 ms GC Workers: 8 Evacuation Failure: 0看Evacuation Failure这个字段只要有非0意味着年轻代晋升时老年代空间不足对象没能成功搬移这是Full GC的前兆。碰到这个值优先检查老年代占用率和晋升速率别急着调MaxGCPauseMillis。Mixed GC日志会额外出现to-space exhausted字段一旦出现说明回收目标Region里的对象复制不出去了要么堆设置偏小要么存活对象比预想的高得多。我处理过一次原因是某个缓存容器被不恰当地设置成了强引用所有记录都活着G1根本清不动。应该关注的几个关键指标GC频率、平均停顿、P99停顿、分配速率Alloc Rate、晋升速率Promotion Rate。其中分配速率可以通过jstat -gcutil观察也可以用-XX:PrintAdaptiveSizePolicy输出自适应调整日志来分析。晋升速率过高意味着大量对象在年轻代快速存活多轮才被回收可能导致老年代提前触发并发标记。5.2 看懂ZGC日志理解不同阶段的时间线ZGC日志的格式跟G1差异很大。常见的片段[gc] Concurrent Mark 32768M - 32768M [gc] Concurrent Mark 3301M - 3802M [gc] Concurrent Process Non-Strong References 250M - 250M [gc] Concurrent Reset RelocationSet 3328M - 3328M [gc] Concurrent Select RelocationSet 3328M - 3328M [gc] Concurrent Prepare RelocationSet 3328M - 3328M [gc] Concurrent Relocate 3328M - 3328M特别注意Concurrent Mark阶段前后的堆占用变化如果标记结束时“-”箭头两边的数字差距很小说明这次回收能清理的垃圾很少RC高但真正死亡对象很少意味着应用可能存在大量长期存活对象ZGC的回收收益有限。这个情况不一定是GC问题更可能是业务缓存策略导致堆里存活对象过多需要从设计层面解决。ZGC日志里另一个要盯的是每个GC周期的间隔我用-XX:ZCollectionInterval120设置后日志里会出现“Collector: 120s interval”之类的提示这样能确认强制回收策略是否生效。总体来看ZGC日志的核心价值是验证“并发做干净了没有”而不是像G1那样抓异常字段。5.3 压测方法如何模拟真实GC压力GC压测最容易犯的错是拿普通接口压测工具一顿猛怼结果堆里全是临时对象GC压力跟真实场景完全不一样。真实生产环境的对象分配模式有很强的业务特征比如有些阶段会产生大量生命周期较长的对象有些阶段几乎纯读缓存。我建议用压测流量录制回放的方式而不是纯随机请求。我用过几次比较完整的方案用JMeter录制核心接口的真实请求序列配比好不同请求类型的比例按生产流量模型的二八比例回放同时在测试节点上用jstat -gc 每200毫秒采样一次记录堆使用和GC频率跑两小时以上观察老年代增长曲线。如果压测过程里Full GC出现多次这个版本就不具备上线条件。另一个有用的工具是GCeasy上传GC日志就能自动解析出分配速率、晋升速率、停顿P99等指标比人肉翻日志高效得多。第一次用它看到G1日志里自动标注出“Promotion Rate过高”的警告时我才顿悟之前手动分析的效率有多低。5.4 常见问题速查表G1和ZGC的综合排查方向我把几年里反复遇到的问题整理成一个速查表方便遇到实际问题时按图索骥。症状可能原因排查手段解决方向G1触发Full GC速率高晋升空间不足或Humongous分配失败查看GC日志中to-space exhausted如果堆小扩容如果堆仍充足查代码分配逻辑和大对象G1 Young GC停顿飙升并发标记/回收线程抢CPU观察CPU负载曲线调低ConcGCThreads或降低IOHP阈值让回收更分散G1 Mixed GC轮数过多老年代Region存活率太高用jmap查看老年代对象检查长生命周期缓存考虑按业务清理引用ZGC周期过于频繁ZAllocationSpikeTolerance偏高或分配速率异常看阶段统计日志调整该参数或者查分配热点ZGC并发周期CPU爆高ConcGCThreads设太大观察并发阶段CPU降并发线程数趋近1/8核数ZGC出现意外Full GCJDK版本过低或堆设置过小查日志定位触发阶段升级JDK扩容堆检查JNI/Unsafe使用这张表不是一个固定的标准答案但每条都是我从真实故障里归纳的有效起点。GC问题一定要结合日志、CPU曲线、业务流量模型综合看不分析现场就套参数大概率会把问题带偏。6. 结尾我从实践中沉淀的几条GC选择建议聊到这儿该把话说透了。GC调优这件事最忌讳的就是人云亦云地追求“最新最好”。ZGC确实代表更先进的技术方向但如果你服务只有6GB堆CPU还有一半被业务线程占满硬切ZGC只会让吞吐下降延迟问题可能还没原来G1处理得好。技术选型始终是为业务目标服务的。根据我个人的经验做GC选型和调优时有几条原则基本不会出错。堆小于16GB、追求稳定均衡G1就是最稳妥的选择把MaxGCPauseMillis、NewSizePercent、IOHP这三个方向调好绝大多数在线服务都能获得不错的延迟表现。堆大、P99目标极低、CPU还有可用余量ZGC值得认真评估它会给你带来G1给不了的稳定低延迟。任何GC参数的调整都必须配合GC日志、监控曲线和对象分配分析先定位问题再动手。GC调优不是玄学更不是参数堆砌它是把JVM的行为模型和你自己的业务模型对齐的过程——这中间唯一可靠的办法就是一次一次地压测、看日志、调参数。最后再分享一个小技巧很多人做GC压测喜欢开一堆工具我后来反而倾向于在最朴素的环境里跑一个JDK自带的jstat一份GC日志一个能记录接口延迟的压测工具。工具越少越能逼着自己去理解日志里的每一行背后到底发生了什么。等你真正能不看额外监控就通过GC日志推演出应用的分配模式这才算入了JVM调优的门。