做Java后端的人,迟早会被GC问题找上门。我见过太多团队一遇到线上卡顿就搜“JVM调优参数”,然后照着网上那份流传多年的命令抄一遍,把-XX:MaxGCPauseMillis调到50、把-Xmx调大,结果重启之后停顿反而更频繁。并不是参数错了,而是很多人根本不清楚G1内部是怎么运作的,更不知道ZGC应该在什么条件下才值得切换。这篇文章我会把G1的核心参数逐个拆开讲透,同时说清楚ZGC的适用边界,最后用一次完整的调优案例复盘从告警到参数落地的全过程。
1. GC调优的边界:先想清楚"要不要调"和"调什么"
GC调优最忌讳一上来就动JVM参数。你需要先判断当前系统的GC压力到底来自哪里,再决定是调参数、换收集器,还是干脆改代码。很多情况下参数调整只是治标,代码层面的问题才是根源。
1.1 GC现象的根因分类:分配速率、对象存活时间与停顿来源
任何GC问题都可以归结为三个基础变量:内存分配速率(Allocation Rate)、对象存活时间(Object Lifetime)和堆容量配置。这三个变量决定了GC触发的频率和单次GC的工作量。
如果应用每秒分配100MB对象,但绝大多数对象在年轻代就死亡,那么Minor GC会被频繁触发,但每次停顿很短。如果大量对象存活超过几个GC周期,它们就会晋升到老年代,最终触发Mixed GC甚至Full GC。真正的调优思路是识别你的应用属于哪种模式:是"高分配低存活"还是"低分配高存活"。
注意:GC调优的目标从来不是"消除GC",而是让GC的停顿可控、频率合理。JVM无法避免GC,但可以让你掌握GC的节奏。
你可以在启动脚本中加入GC日志参数,观察一段时间内的规律。我个人习惯用下面的组合收集基线数据,比任何监控工具都直接:
-Xlog:gc*=info:file=/opt/logs/gc-%t.log:tags,uptime,level:filecount=5,filesize=64m这套参数会在每次重启时生成独立的GC日志文件,保留5个、每个64MB轮转,信息完整且不会撑爆磁盘。看到日志后,重点看三件事:Young GC的频率、各次GC后堆占用是否持续抬升、Mixed GC或Full GC的出现间隔。
1.2 调优前的指标采集:GC日志、JFR与实时监控的配合
GC日志只是基础,想定位具体是哪段代码在制造压力,还需要结合JFR(JDK Flight Recorder)和实时监控数据。
JFR是JDK自带的低开销采样工具,开启后对应用影响极小(通常低于1%)。启动参数可以加:
-XX:StartFlightRecording=filename=/opt/logs/app.jfr,settings=profile,duration=15m等JFR跑15分钟左右,用JMC打开文件,直接看"垃圾收集"和"对象分配"两个视图。这里能直观看到分配速率最高的线程和调用栈,定位到具体的类和方法。
实时监控侧,我建议至少关注四个指标:老年代使用率、GC暂停时间P99、堆内存使用率趋势和GC线程CPU占用。很多团队只盯着堆使用率曲线,看到涨到80%就紧张,但真正应该关心的是老年代使用率是否在每次GC后能回落到合理水位。如果老年代每次GC后只能下降10%,说明对象的晋升速率接近回收速率,这是一个危险信号。
2. G1核心参数逐个拆解:从原理到操作的完整解读
G1从JDK 9开始成为默认收集器,它把堆划分为多个Region,通过维护一个全局的"回收优先级列表"来决定每次回收哪些Region。G1的调优本质上是调节它对"停顿目标"和"回收效率"之间的权衡。理解了这一点,参数就不会记混。
2.1 MaxGCPauseMillis:期望值不等于保证值
-XX:MaxGCPauseMillis(默认200ms)是G1使用频率最高的参数,也是误解最深的参数。它叫"暂停时间目标",不是"暂停时间上限"。G1会通过调整年轻代大小、回收Region数量等手段去尝试满足这个目标,但代价可能是降低吞吐量、增加回收轮次。
我的经验是:不要把该值设到50ms以下。当目标过于激进时,G1每次只回收很少的Region,导致回收跟不上分配速率,老年代持续堆积,最终还是会在一次Mixed GC中集中爆发。常见的合理区间是100~300ms,具体值取决于业务对延迟的容忍度。例如在线交易系统追求稳定,一般设在100ms;批处理任务对单次停顿不敏感,设到500ms反而吞吐更高。
一个容易被忽略的问题是:MaxGCPauseMillis影响的不只是停顿时间,它还间接决定了年轻代Region的数量。目标越短,G1压得年轻代越小,Young GC越频繁,分配速率高的应用反而会因为年轻代太小而提前晋升对象。这类场景会出现一个典型现象:GC频率上升,但堆内存趋势没有改善。
2.2 Region与年轻代比例:HeapRegionSize、G1NewSizePercent与G1MaxNewSizePercent
Region大小是G1的物理基础。理论上G1希望堆能拆成约2048个Region,所以不显式设置-XX:G1HeapRegionSize时,JVM会根据堆大小自动推算。例如堆为4GB,Region就是2MB;堆为32GB,Region就是16MB。
多数情况下不需要手动指定Region大小,但有一个例外:当堆内存特别大(比如64GB以上)且对象大小分布极不均匀时,默认Region可能过大或过小。Region过大浪费空间,过小导致大对象跨Region,增加回收复杂度。手动设置的原则是让Region不小于应用中占比最高的大对象体积,通常用jmap -histo:live看一下对象分本就能判断。
年轻代占比由-XX:G1NewSizePercent(默认5%)和-XX:G1MaxNewSizePercent(默认60%)控制。注意这里的百分比是相对整个堆的。G1会根据停顿目标动态调节年轻代大小,但你仍然可以设一个下限,防止年轻代被压得太狠。
对于大多数Web应用,默认值就够用,真正值得关注的是Young GC的平均间隔和单次耗时。如果Young GC每秒触发好几次、每次耗时30~40ms,说明年轻代太小或分配速率太高。这时候盲调G1NewSizePercent作用有限,更合理的做法是先检查是否有大数组、大集合被反复创建,把分配速率降下来。
2.3 IHOP与混合回收:Mixed GC的节奏控制
IHOP全称是Initiating Heap Occupancy Percent,它决定G1何时启动并发标记。默认值是45%,意思是整个堆使用率达到45%时,G1开始并发标记,为后面的Mixed GC做准备。这个值太小会频繁触发标记,浪费CPU;太大则可能来不及标记,导致最终Full GC。
从JDK 8u40开始,G1默认启用-XX:+G1UseAdaptiveIHOP,会根据历史GC数据自动调整IHOP。理论上很美好,但实际中自适应IHOP依赖稳定的并发标记周期。如果应用内存使用率波动剧烈,自适应值可能反复跳动,造成Mixed GC节奏不稳定。定位到这类问题时,我会显式关闭自适应,手动设置一个偏保守的值。
Mixed GC阶段还有两个参数容易被混淆:-XX:G1MixedGCLiveThresholdPercent和-XX:G1HeapWastePercent。前者是Region内存活对象占比的阈值,存活占比超过该值的Region在混合回收时会被跳过,因为"回不来多少空间";后者是整个堆的可回收空间占比阈值,低于该值时G1认为"回收价值不大",不再发起Mixed GC。
经验值上,如果堆中碎片化严重、大量Region存活率在70%以上,可以试下调低G1MixedGCLiveThresholdPercent到85%,让更多Region参与回收。但这类参数是场景相关的,不要照搬。
2.4 预留与线程参数:ReservePercent、ParallelGCThreads与ConcGCThreads
-XX:G1ReservePercent(默认10%)的含义是:G1在堆中预留一部分空间用于"晋升失败"等突发情况。晋升失败指的是年轻代对象在GC过程中需要晋升到老年代,但老年代没有足够连续空间,触发担保机制甚至Full GC。
我见过不少人把这个参数调到20%以上,想减少Full GC,结果是白费内存。预留比例过大,相当于实际可用堆变小了,反而增加GC频率。保持在10%即可,真正的关键是把IHOP控制好,让老年代在Mixed GC期间维持足够空间。
并发线程参数通常是两个一起看:-XX:ParallelGCThreads(并行GC线程,默认按CPU核数推导)和-XX:ConcGCThreads(并发标记线程,默认约为ParallelGCThreads的25%)。在容器化环境下,JVM可能识别错CPU数,导致线程数过高。如果容器限了4核,而JVM识别为16核,并发标记线程会占满CPU,影响业务线程。建议在容器启动参数里显式设置:
-XX:ParallelGCThreads=4 -XX:ConcGCThreads=2如果是8核以上的物理机,ParallelGCThreads可以保留默认值,不必手动干预。
3. ZGC的适用边界:什么时候真的应该换掉G1
ZGC是低延迟场景的产物,核心目标是让GC停顿时间维持在10ms以内,并且停顿时间不随堆大小线性增长。但ZGC并不是免费的午餐,它的代价是更高的CPU开销和更复杂的内存管理。选择ZGC,本质上是拿CPU换延迟。
3.1 ZGC的工作模型:着色指针、读屏障与并发转移
ZGC的关键机制可以概括为三点:着色指针(Colored Pointers)、读屏障(Load Barrier)和并发转移(Concurrent Relocation)。着色指针利用64位指针的高位存储标记信息,在读指针时通过读屏障判断对象是否被移动,从而在不暂停应用线程的情况下完成对象转移。
说实话,读屏障带来的开销是实打实的。在ZGC下,每次从堆中读取引用都需要额外判断,这会导致CPU开销比G1高出约10%~20%。对于低延迟系统,这点开销值得;对于吞吐量敏感的批处理系统,这是纯浪费。
另一个重要变化是:ZGC没有传统意义上的分代结构(JDK 21开始分代ZGC转正,但默认仍推荐不分代方案),对象生命周期无法利用"朝生夕灭"的特性。这意味着即使对象很快死亡,ZGC也会对包含它们的页面做较重的标记处理。对于短生命周期对象占主导的应用,ZGC的并发标记成本偏高。
3.2 用数据判断:停顿时间、CPU成本与堆容量的真实关系
ZGC最适合的场景是:堆内存大(至少16GB以上)、对单次GC停顿极度敏感、应用能容忍CPU开销上升。如果你的服务堆内存只有2~4GB,用ZGC就是在浪费CPU;对分配速率很高、对象存活时间极短的场景,ZGC可能不如G1合适。
我整理过一个判断矩阵,绕开各种概念,直接用业务特征对号入座:
| 业务特征 | 更适合的收集器 | 判断依据 |
|---|---|---|
| 堆内存16GB以下,容忍100ms级停顿 | G1 | ZGC的CPU开销大于收益 |
| 堆内存大,但CPU核数紧张 | G1 | ZGC并发阶段会抢CPU |
| 要求10ms级停顿,大堆,CPU有富余 | ZGC | 停顿时间几乎与堆大小无关 |
| 对象存活率极高,老年代非常稳定 | ZGC | G1的混合回收收益很低 |
| 批量计算、吞吐优先 | G1 | 吞吐优先场景ZGC没有优势 |
这里的判断核心在于:ZGC的停顿时间不随堆增大而恶化,但CPU开销相对稳定地偏高。所以只有当堆足够大、业务对停顿足够敏感时,ZGC的优势才能盖过它的劣势。
3.3 不适合ZGC的情况:小堆、高分配速率与CPU敏感场景
我见过有人把ZGC直接用到4GB堆的网关服务上,结果Young阶段的分配压力让读屏障频繁触发,整机CPU上升了25%,延迟并没有明显下降。原因在于:小堆本身就撑不了多少分配压力,ZGC的并发处理被高频分配鞭打,GC线程一直处于忙状态。
高分配速率的场景也要慎重。ZGC的并发转移和标记速度快,但读屏障是每次引用访问都执行的低层逻辑。分配速率越高,业务线程访问引用次数越多,读屏障累积开销越明显。这时候把分配速率降下来,比换任何收集器都有效。
CPU敏感场景需要额外说明:容器环境下如果CPU限额有限,ZGC的并发线程需要抢CPU时间片,业务线程会出现处理器调度延迟。这时即使GC停顿很低,整体接口RT也可能升高。遇到这类情况,先用top -H -p <pid>看看GC线程CPU占比,如果超过15%,就要评估ZGC是否划算。
4. 从告警到落地的完整调优流程:一次真实案例的复盘
前面讲参数和理论,这一节我用一个虚拟但绝对典型的案例串一遍从发现问题到参数落地的完整过程。案例背景:某在线查询服务,高峰时QPS约5000,堆内存设置为16GB,运行在8核16GB的容器上,业务对单次请求的P99延迟要求在200ms以内。
4.1 现象与数据收集:从告警信息定位问题根源
某天监控告警:老年代使用率持续超过85%,P99延迟从120ms飙升到450ms,同时出现Full GC记录。我先拉取GC日志,看到最后几次关键GC节点:
[Full GC 11281.234s][GC Worker Total 6.234s, GC Worker Max 6.230s] [Eden: 2048M(2048M)->0B(2048M) Survivors: 128M->128M Heap: 15.8G(16G)->14.2G(16G)]Full GC耗时6.2秒,这在线上完全不能接受。堆回收后仍然占用14.2GB,富余空间连10%都不到。这说明老年代里大量对象长期存活,G1的Mixed GC已经拿不回空间了。
结合JFR数据,我定位到某接口会一次性加载配置表全量数据到内存,并缓存4小时。配置表数据量大、加载频率偏高,导致大量对象长时间存活。
4.2 参数调整与验证:为什么这样设置而不是那样设置
这个案例表面上像是GC参数问题,实际是缓存策略+堆水位问题。我做了两件事:
第一,代码侧把缓存T从4小时降到1小时,并改为按需分页加载,降低单次分配总量。这是真正的治本措施。第二,JVM参数上做配合调整。堆16GB、默认Region约8MB,没有改Region大小的必要。我把-XX:G1HeapWastePercent从5%降到3%,让Mixed GC更积极;同时设-XX:G1MixedGCCountTarget=16,增加单轮混合回收的次数,让回收更平缓。
这里说明一下为什么调这两项:缓存对象量大,单个Region的存活率差异很大,降低“垃圾占比阈值”会让更多低价值Region参与回收,而增加回收轮次则避免一次回收太多Region导致停顿过高。
等待日志确认时,我开启了GC日志并按老年代水位画了一条趋势线。调整后四个小时的曲线从"一路爬坡每次只降一点"变成"锯齿规律下降",Full GC消失,P99稳定在130~150ms。
4.3 效果对比与后续观察:测量驱动的调优习惯
调优完成后,我习惯做两个维度的对比:一是GC维度,观察Mixed GC的频率、单次耗时和回收量;二是业务维度,观察P99延迟和错误率。这里给出调整前后的关键对比:
| 指标 | 调整前 | 调整后 |
|---|---|---|
| Full GC频率 | 每40分钟1次 | 0 |
| Mixed GC平均耗时 | 750ms | 260ms |
| GC后老年代水位 | 14.2GB | 10.1GB |
| P99延迟 | 450ms | 140ms |
| CPU平均使用率 | 68% | 55% |
值得注意的是,CPU使用率反而下降了。原因是Full GC抢占了大量CPU,调整后并发标记和混合回收的节奏更合理,整体线程调度也平滑了。这类对比数据应该保留在每一次调优记录里,后续做容量评估和参数回归都非常有用。
5. 参数之外:那些没人写进文档的GC调优习惯与避坑经验
如果说G1参数是"术",那调优习惯才是"道"。很多问题不靠参数而靠排查路径和经验判断来解决,这部分内容我打算直接写操作中连续踩过几次坑才总结出来的要点。
5.1 参数组合优于单个参数:不要迷信某一条"神参数"
我经常看到有人把MaxGCPauseMillis从默认的200调成50,然后发现GC更频繁,就认定G1没用。真正的问题是参数之间是联动的:停顿目标变短,年轻代被压缩,晋升率升高,老年代更快堆积,IHOP被频繁触发,并发标记又被压缩……整个系统陷入恶性循环。
调参的基本逻辑是"先看堆水位,再调节奏,最后微调目标"。顺序错了,效果往往适得其反。以G1为例,我的调参顺序是:
- 先确认堆大小是否合理(
-Xms和-Xmx设为相同值,避免运行时堆扩容)。 - 观察老年代水位和Mixed GC频率,确定IHOP方向。
- 再调
MaxGCPauseMillis,让停顿时间跟业务容忍度对齐。 - 只有上面三步走了仍不理想,才动
G1ReservePercent、G1MixedGCLiveThresholdPercent这些细粒度参数。
这个顺序能避免大部分参数改动引发的次生问题。
5.2 自适应机制与手动设置:何时应该关闭G1UseAdaptiveIHOP
G1的G1UseAdaptiveIHOP在稳定负载下表现很好,但如果应用内存使用率天天大起大落,自适应IHOP会不断调整并发标记触发点,导致回收节奏混乱。判断方法很直接:看GC日志里并发标记周期之间的间隔是否规律。如果间隔忽长忽短且Mixed GC后老年代水位反弹很快,手动关闭自适应往往更稳。
关闭后需要手动设置-XX:InitiatingHeapOccupancyPercent。具体值可以根据历史GC日志推算:观察Full GC前老年代的最低水位,然后在此基础上降低5~10个百分点。例如Full GC前老年代最小是12GB,堆16GB,那么IHOP可以设在70%(11.2GB),留足时间给并发标记完成。
5.3 压测与灰度发布:没有流量验证的调优等于空谈
调完参数不上压测直接上线,是在赌运气。我见过把MaxGCPauseMillis从200调到50后测试环境一切正常、上线后直接雪崩的案例。区别在于线上的真实分配速率和对象存活模式,与压测数据差距很大。
稳妥做法是先在压测环境用真实流量回放跑至少一版,观测GC日志和P99延迟;灰度发布时先放10%流量,确认稳定后再逐步放量。每次变更只改一个参数或一个代码因素,否则出问题根本定位不到原因。
5.4 版本选择:同一个参数在不同JDK版本的行为差异
G1和ZGC在不同JDK版本上行为差异非常大。比如JDK 11的G1在Region回收策略上明显不如JDK 17优化充分;ZGC在JDK 15之前不支持分代,JDK 21以后分代ZGC才正式可用。如果你的应用是老版本JDK,照抄新版本博客的参数会翻车。
如果条件允许,建议至少升级到JDK 17再谈GC调优。新版本自带的改进往往比调几个参数更有效。ZGC更不用说,分代ZGC在JDK 21开始默认不启用但支持,用之前必须确认版本。
6. 从G1到ZGC的迁移路径:判断依据与操作步骤
很多人看完ZGC的优势就想直接迁移,但实际工作中从G1切换到ZGC更像一次小型的架构变更。最后这一节我讲讲迁移前要做哪些准备,以及切换后应该盯哪些指标。
6.1 迁移判断的量化标准:什么时候值得动手
在真正切ZGC之前,我会先做一个量化评估,分三个维度给分:
- 堆内存:应用常驻堆在16GB以下,ZGC收益不明显,不建议迁移。32GB以上,ZGC有明显优势。
- 停顿目标:G1在现有参数下无法稳定低于100ms,且排除了代码层面的问题后,才考虑ZGC。
- CPU余量:容器或物理机在高峰期CPU平均使用率低于70%,ZGC并发工作才有资源空间。
三个条件都满足才动手。只满足一个或两个的,先继续把G1调好。
实际操作上,切换ZGC只需要改两个启动参数:
-XX:+UnlockExperimentalVMOptions -XX:+UseZGCJDK 15以后不需要UnlockExperimentalVMOptions,直接-XX:+UseZGC即可。
6.2 切换后的重点观测项:不只看GC停顿,还要看CPU与线程调度
切换到ZGC后,很多人只盯着GC日志里的停顿时间,发现确实在10ms以内,就觉得大功告成。但ZGC的并发行为会改变整机的CPU和线程调度模式,尤其要注意下面三个指标:
第一,GC线程CPU占比。ZGC的并发标记、并发转移和并发重定位都会长时间占用CPU。如果占比超过20%,且业务RT反而上升,说明CPU余量不足,迁移失败。
第二,内存分配速率与页面碎片。ZGC在分配对象时使用64位着色指针,堆内存内部是"页面"(默认2MB)管理。如果分配以超大缓冲为主,页面的对象存储效率会下降,表现为堆内存占了但回收效率不高。
第三,线程停顿分布。用jstat -gcutil观察每轮GC周期的时间分布,ZGC的停顿极短但整体GC周期可能拉长到几十秒。如果你看到单次"GC周期"耗时40秒,但期间业务无感知,这就是ZGC的正常行为,不用担心。
我在一次实际迁移中还遇到过一个不在常规文档里的细节:ZGC下大对象分配引发的页迁移,会导致一个CPU核心长时间忙于搬运数据。这时候把-XX:ConcGCThreads适当调小,把并发工作分散到更多核心上,反而能让整体延迟更稳定。
6.3 迁移后回退方案:没有退路的迁移是不完整的
每次涉及收集器的变更,都必须准备回退方案。我的做法是保留整份旧启动脚本和旧版本镜像,新版本上线后持续观察至少一周,确认GC停顿、P99延迟和CPU三个维度全部达标,才移除旧配置。
如果中途发现ZGC导致CPU压力过高,回退G1不是简单换回启动参数就行的。G1的Region布局和ZGC的页面布局不同,回退时最好直接发布旧版本镜像,而不是在运行中动态切换。运行中切换Shenandoah或G1,JVM会执行一次完整重置,带来的STW反而比正常GC还可怕。
7. 几个容易误判的场景:用真实经验帮你少走弯路
GC调优的错误判断很多时候不是参数问题,而是对现象的理解出了问题。这里写三个我反复见过的误判,每个都对应一条"如果重来会怎样处理"的复盘。
7.1 误判一:把系统CPU飙升错怪GC,实际是真内存泄漏
某次线上CPU飙到90%,GC日志显示频繁Full GC,团队第一反应是GC参数有问题。我看了JFR后发现,某类对象数量在半小时内从5万涨到600万,显然是内存泄漏导致老年代持续暴涨,才诱发Full GC。GC只是背锅,根源是缓存Map的key没有清理。
这种情况调任何参数都没有意义。正确做法是用jmap -dump抓堆快照,再用MAT分析支配树,找到占内存最大的对象路径。GC调优的边界就是:当堆使用率直线上升且永不回落时,先查泄漏,再谈调优。
7.2 误判二:ZGC停顿为0,但接口RT长时间波动
ZGC把GC停顿降到了3ms以内,但接口的P99反而从120ms涨到180ms。排查后发现瓶颈不在GC,而是ZGC的读屏障增加了CPU指令数,8核容器满载后线程调度延迟上升。把容器扩到12核后,P99立刻回落到110ms。
这个案例说明:ZGC的低停顿是有代价的,代价就是CPU和内存带宽。迁移前必须确认CPU有富余,否则只是把GC延迟问题转移成了调度延迟问题。
7.3 误判三:无脑调大堆内存,以为能解决一切
堆内存从8GB调到32GB,Young GC频率确实降低了,但Mixed GC单次耗时从200ms涨到800ms,业务直接超时。因为堆变大后,并发标记和全堆扫描的对象基数也变大了。多数场景下,堆内存应该匹配业务对象总量和分配速率,而不是"越大越好"。
如果堆内存大但对象存活率低,反而建议控制在合适范围。判断依据是:堆使用率长期低于20%,说明堆配大了;GC后堆水位持续高于80%,说明堆可能偏小或存在存活率异常。两种极端都不健康。
8. 最后的经验小结:GC调优是持续迭代的过程
GC调优不是一次性任务,而是一个需要持续迭代的过程。每次版本发布、依赖升级、流量模型变化,都可能让之前的参数变得不再合适。我个人会定期检查GC日志,把Full GC次数、Mixed GC平均耗时和堆水位三个核心指标记录成趋势表,一旦发现数据偏离基线,就主动介入分析。
在各环境中保持相同的JVM参数也是一件容易被忽略的事。经常有人测试环境用G1、生产环境却用了默认的并行GC,导致压测数据完全不具备参考价值。JVM参数应该作为应用配置的一部分纳入版本管理,和代码一起评审、一起发布。
如果你刚开始接触GC调优,我建议先别急着动参数,把GC日志和JFR的运行习惯养好。工具和数据到位后,参数调整只是顺水推舟的事。最后分享一个我自己的习惯:每次调优后,都会在变更记录里写清楚"调了哪个参数、基于什么现象、预期什么结果、实际什么结果"。几个月后再看,这些记录就是最宝贵的经验库。