最近在给一个跑在JDK17上的订单服务做GC调优过程挺有意思的趁着还没忘干净把思路和踩过的坑整理出来。这个服务不算复杂堆内存4G左右平时流量稳定但每到晚高峰P99响应时间就有肉眼可见的抖动看监控满屏的GC暂停标记。很多人一听到“GC调优”就觉得高深实际上大部分问题不需要你精通编译原理只要搞清楚三件事目标是什么、现状是什么、改哪个参数能产生预期效果。这篇内容主要讲JDK17环境下G1的调优路径也会提一下ZGC什么时候值得切最后整理了我在实际环境中遇到的问题和排查思路。适合正在做线上服务性能排查的后端开发者也适合准备从JDK8迁到JDK17、担心GC行为变化的同学。JDK17作为长期支持版本其GC形态和JDK8时代已经有很大区别很多老经验要重新校正。1. 先想清楚目标再碰参数调优是从问题倒推的1.1 三个指标之间的取舍关系GC调优永远是在三件事之间做权衡延迟、吞吐量、内存占用。不可能三个全都要。延迟指的是GC暂停带来的停顿时间吞吐量指的是业务线程执行时间占整体运行时间的比例内存占用则指堆空间的大小。很多新手调优一上来就堆参数结果把年轻代调得巨大GC频率下降了但一次Full GC能把整个服务卡成PPT这属于典型的“只看到频率、没看到时长”。我先讲一个实用的标准如果你的服务是面向用户的在线接口优先保障延迟宁愿GC频率高一点也要把单次暂停控制在几十毫秒内如果服务是离线计算、批量处理优先保障吞吐量可以容忍偶尔一次较长的GC但要尽量让总体GC时间占总运行时间的比例低。JDK17里默认的G1就是为了平衡这两种场景而设计的。开始动手前把目标写清楚会让整个调优过程舒服很多。比如“希望P99响应时间从250ms降到120ms以内”“希望GC暂停超过200ms的次数降为零”这种目标是可以衡量的。泛泛的“让系统快一点”没有意义因为你无法验证到底调没调好。我经历过一个项目团队花了两周调GC结果发现瓶颈根本不在GC而在数据库连接池配置——这就是没有先定义目标就去乱动参数的代价。1.2 如何量化现状GC日志就是唯一真相不要靠感觉也不要靠监控工具的聚合曲线最靠谱的分析材料是GC日志。JDK17里开启GC日志的方式和JDK8完全不同JDK8时代用-XX:PrintGCDetails和-XX:PrintGCDateStampsJDK17统一成了-Xlogjava -Xlog:gc*,gcheapdebug:filegc.log:utctime,uptimemillis,level,tags:filecount10,filesize64m -jar your-service.jar这行参数的含义我解释一下gc*表示记录所有gc相关标签gcheapdebug把堆变化细节也带上输出到gc.log按64MB轮转保留10个文件避免日志无限膨胀。加utctime和uptimemillis是为了在日志里直接看到UTC时间和进程启动后的毫秒数方便和你自己的监控时间对齐。日志到手之后第一眼看三个指标平均Young GC暂停时长、暂停间隔、有没有Full GC。我见过很多服务其实Young GC一直在50ms以内只是每隔一段时间冒一次500ms甚至更长的Full GC这种情况下先查Full GC的原因往往比盲目调整整个堆配置更高效。这里有一个很多人会忽略的点监控工具里看到的GC暂停和GC日志里的数据经常对不上。原因在于监控工具抓的是JMX指标默认采样间隔可能超过1秒短暂停会被平滑掉。所以真正做决策时务必以GC日志为准监控曲线只能作为宏观趋势参考。2. JDK17的默认GC选型G1的工作方式与参数拆解2.1 为什么JDK17默认是G1而不是别的JDK9起默认GC就是G1JDK17延续了这个选择。G1的设计目标非常明确在“大堆”场景下可控暂停时间。注意这里说的“大堆”不是指几十G那种大型机而是指比JDK8常见的单代CMS更适合的、几个G到几十个G的堆空间。CMS是并发标记清除它在内存碎片整理方面有天然弱势而且从JDK14开始CMS正式被移除JDK17里你根本无法用-XX:UseConcMarkSweepGC启动应用别指望搬出老配置照抄。理解G1的关键在于它把堆切成了一个个大小相等的Region区域每个Region物理上不要求连续动态决定是Eden、Survivor还是Old区。所以G1能做的事情是在大范围内部分回收、不全局停顿通过跟踪每个Region的回收价值优先回收“垃圾最多、回收最快”的Region这就是“Garbage First”名字的来源。很多人刚接触G1最大的误解是G1是并发GC所以没有暂停。这句话是错的。G1的并发只体现在标记阶段和部分整理阶段对象复制阶段依然需要Stop The World。只是它把这个停顿控制得比传统Full GC短得多同时也更可控。这也是为什么G1适合追求可控延迟的服务而不是追求零暂停。2.2 几个真正值得调整的G1参数JDK17的G1已经非常自适应性JVM启动时会根据机器配置和堆大小自动决定Region大小、线程数、新生代大小所以你不需要像JDK8时代那样事无巨细地指定一堆参数。我自己跑下来真正值得手工干预的G1参数就这几个参数作用我的建议-XX:MaxGCPauseMillis期望最大暂停时间设置成100~200不是硬性指标是软目标JVM会尽力-XX:G1HeapRegionSizeRegion大小堆较大或大对象较多时可以手动指定一般1MB~32MB-XX:G1NewSizePercent/-XX:G1MaxNewSizePercent年轻代占比上下限默认下限5%、上限60%通常不需要动除非Young GC异常频繁-XX:ParallelGCThreadsGC工作线程数默认根据CPU核数自适应容器环境下建议显式设置-XX:ConcGCThread并发标记线程数默认偏保守标记阶段拖慢时可以适度调大-XX:AlwaysPreTouch启动时预占物理内存追求启动即稳定时打开减少运行期页面换页其中-XX:ParallelGCThreads是容器环境最容易出问题的参数。很多服务在物理机上跑得好好的迁到容器里之后GC暂停变长原因就是JVM拿到的是宿主机的CPU核心数自动算出了过多的GC线程导致上下文切换激烈。解决办法简单粗暴手动指定为容器实际分配的CPU核数比如容器是4核就加-XX:ParallelGCThreads4并发线程数再在这个基础上按比例降。还有一个参数值得单独说-XX:AlwaysPreTouch。它会在JVM启动时把所有堆内存页一次性触碰并分配物理内存而不是等运行期逐步分配。开这个参数的好处是启动阶段多花几秒换来运行期内存分配更平滑、GC行为更稳定。对延迟敏感的服务我一般建议开内存本来就预留给了JVM不如提前落地。那怎么决定Region大小G1的Region大小默认是让堆被分为大约2048个Region然后向上取整为2的幂。比如4G堆2048个Region每个约2MB。如果你发现日志里频繁出现Humongous Allocation大对象分配导致GC波动可以考虑手动指定Region大小把这个大对象尽量装进普通Region减少大对象进入special region的频率。比如一个大对象8MB默认Region 2MB它会被当作Humongous对象直接占据多个Region这里面的分配和回收都比较特殊稍后在常见问题部分细讲。3. 一次完整的G1调优某订单系统的实测过程3.1 第一步现状量化与问题定义拿我手头的一个订单服务举例。这个服务运行在JDK17上堆内存配置-Xms4g -Xmx4g容器分配了4核8G内存峰值QPS大约三千核心操作包括订单创建、查询、状态流转。线上反馈是每到晚高峰P99响应时间从平日的80ms跳到250ms以上监控上能看到GC暂停频繁。我先采集了24小时GC日志统计结果如下Young GC平均每3~5秒一次单次暂停30~60ms看起来不算夸张但架不住太频繁总GC时间占了运行时间的5%左右更刺眼的是每半小时左右出现一次Full GC暂停时间在800ms~1.5s之间。这个组合拳直接导致接口P99抖动。下一步是找出Full GC的诱因。我用的工具是jstat先看宏观再用jmap -histo:live强行触发一次Full GC看对象分布这个操作在低峰期做线上别乱来最后在GC日志里看Full GC前后的堆占用变化。日志显示Full GC发生时Metaspace并没有问题Old区占用也就80%左右按理说不该触发Full GC。继续翻日志才发现是System.gc()被调用了——某个第三方SDK在特定场景下会主动触发全堆GC。这个排查过程也让我养成了一个习惯Full GC问题出现时第一反应不一定是堆容量不够而是先查System.gc(),因为很多组件代码里都“埋了雷”。3.2 第二步制定参数方案定位到主要诱因是第三方SDK的System.gc()之后我先解决最直接的问题加-XX:DisableExplicitGC禁止显式GC调用。这个参数对多数服务是安全的前提是你要确认业务代码没有依赖System.gc()来做主动内存释放的场景。加了之后Full GC频率肉眼可见地下降一整天几乎只出现几次JVM自己触发的自然Full GC。然后处理Young GC过于频繁的问题。我用一个笨办法来观察把堆内存从4G临时调整到5G观察GC频率变化发现Young GC间隔立刻拉长说明堆容量确实偏紧。于是我把堆配置从-Xms4g -Xmx4g改成-Xms5g -Xmx5g同时加-XX:AlwaysPreTouch让JVM启动时就分配好这5G物理内存。这里有前提——容器本身8G内存够用而且这个服务属于延迟敏感型宁可牺牲一点空闲内存来换取更松的GC环境。G1自身停顿控制的软目标我也顺手设置了-XX:MaxGCPauseMillis150。有人问这个参数到底有没有用我的体会是在堆充足、对象分配均匀的情况下G1会尽力去满足这个目标但如果你想伺候的目标值严重不切实际比如设置成10msG1反而会不断调低新生代大小来压低暂停最后导致Young GC极其频繁、吞吐量骤降。所以这个参数是“软目标”不是“承诺书”要合理设定。完整参数集合最终是这样的-Xms5g -Xmx5g -XX:AlwaysPreTouch -XX:MaxGCPauseMillis150 -XX:ParallelGCThreads4 -XX:ConcGCThreads2 -XX:DisableExplicitGC -Xlog:gc*,gcheapdebug:filegc.log:utctime,uptimemillis,level,tags:filecount10,filesize64m这里-XX:ConcGCThreads2是参考公式算出来的约为ParallelGCThreads的四分之一配上4个并行GC线程既能保证并发标记有力量又不至于过度抢占业务线程。3.3 第三步验证效果与回归调整完后观察48小时效果很直接Young GC间隔从平均4秒拉长到8秒左右单次平均暂停40ms左右Full GC两天内出现了两次每次也控制在400ms以内不再是之前那种1秒多的硬停顿。P99响应时间稳定回落到90ms附近晚高峰抖动从250ms降到了130ms以内。这次调优没有做“一步到位”而是每一步都保留了回退预案这也是线上调优最重要的原则——一次只改一个变量。为了确保优化没有掩盖其他问题我额外做了流量回归测试在测试环境里按峰值流量压测同时观察GC日志、CPU占用、年轻代晋升率。确认没有出现“GC少了但CPU飙高”的副作用。因为调堆之后对象的晋升模式也会跟着变如果Old区晋升率异常升高说明年轻代设大了导致对象过早晋升那时候就需要反向调整。在这个阶段我的经验是不要追求“漂亮的GC曲线”要追求业务指标变好。GC曲线再好看P99不过关等于白调。4. 低延迟场景何时把ZGC纳入备选4.1 G1做到极限后要不要换ZGC如果你的服务对延迟极其敏感比如每笔交易的响应时间都直接影响收入并且G1的暂停已经无法压到可接受范围这时候可以考虑JDK17里的ZGC。JDK17的ZGC已经从实验性功能转为正式功能不需要再加-XX:UnlockExperimentalVMOptions直接-XX:UseZGC即可运行。它的特点是暂停时间几乎不随堆大小增长几十G的堆也能保持几次毫秒级的暂停。什么场景值得切ZGC我做过一个简单的判断清单堆大于8G、业务对延迟极度敏感、请求长尾效应明显、团队能接受更换GC带来的未知风险。如果堆只有2GG1已经很轻松了切ZGC的收益感知不明显反而要面对新的调优曲线——不划算。ZGC的核心缺陷是吞吐量表现不如G1。它在并发处理上更激进对CPU资源的消耗更高尤其在标记阶段会跟业务线程抢CPU。所以对于CPU资源紧张、追求吞吐量的服务ZGC不是优先选择。这也是为什么很多团队在大内存低延迟的场景用ZGC而在常规业务里继续留在G1。4.2 ZGC的实践参数与关键限制如果决定换ZGC我建议从最简配置开始不要叠加花哨参数-Xms8g -Xmx8g -XX:UseZGC -XX:ParallelGCThreads4 -XX:ConcGCThreads2 -Xlog:gc*:filezgc.log:utctime,uptimemillis,level,tags:filecount10,filesize64m这里最需要注意的点是ZGC在JDK17里还是不分代的意味着一整堆扫描和标记的范围比较大虽然暂停很短但背后的CPU消耗客观存在。后续的JDK版本里分代ZGC会逐渐成熟如果你现在的服务暂时不用换也可以先留个心眼等未来升级时再做考虑。另一个容易踩的坑是ZGC的暂停非常短很多监控工具根本采不到。这时候你不能依赖监控面板判断“ZGC有没有在工作”必须直接看zgc.log。日志里会出现大量类似ZGC: Garbage Collection的标记行每一行都带耗时信息。至少跑一周的日志确认长尾暂停真的消失了再做最终切换决定。同时要做好回滚预案。GC切换不像参数调整它是全局性的一旦线上跑了几天出现诡异问题回滚最干净利落的方式是直接切回G1并恢复之前的参数组合。所以在做计划时就要准备一套“切换前GC参数快照”别等到出事才翻历史记录。5. 常见问题与排查技巧实录5.1 为什么JDK17里不能继续用CMS这个问题在迁移期被问过太多次。CMS在JDK9被标记废弃JDK14被正式移除JDK17里你传-XX:UseConcMarkSweepGC直接报错Unrecognized VM option UseConcMarkSweepGC。很多老项目的配置还是从JDK8时代抄来的换到JDK17后启动就失败然后第一反应不是改配置而是怀疑JDK版本问题。正确做法是拿G1替换CMS因为G1本身的设计目标就包含了“可控停顿”和“避免长时间Full GC”这两点。如果你的业务属性原本用CMS调得很好迁移后大概率只需要微调G1参数不需要太多心理负担。我见过最夸张的一个项目从JDK8迁移到JDK17GC参数一个没动只是把CMS那行去掉用默认G1直接跑P99表现反而更好。5.2 频繁Full GC的系统性排查思路我建议按下面这个流程排查效率最高先看GC日志里Full GC发生时间对比业务发布记录和流量曲线排除人为因素。查System.gc()调用用-XX:DisableExplicitGC测试一下是否立刻缓解。看堆占用趋势用jmap -heap看Old区增长曲线判断是否真的空间不足。如果Old区增长快抓jmap -histo:live | head -50看有没有明显异常的常驻大对象。检查Metaspace是否超过了-XX:MaxMetaspaceSizeJDK17里反射和动态代理用得多Metaspace膨胀也会触发Full GC。最后再考虑是不是堆太小、晋升太快导致的连锁反应。这套流程里最容易翻车的是第四步——jmap -histo:live本身会触发一次Full GC。这个问题在紧张时刻容易造成二次停顿所以最好在低峰期执行或者直接改用jmap -histo不加live拿非存活对象快照对比也能看趋势。5.3 几个容易被忽略的坑第一个坑是Humongous Allocation。当G1遇到超过半个Region大小的对象时它不进入普通Region流程而是作为“巨型对象”直接分配在连续多个Region中。巨型对象的分配和回收都没有什么并发优化频繁产生大数组、大缓存的服务会遇到明显的间歇性停顿。排查方法是在GC日志里搜Humongous关键字如果有大量出现优先从业务代码层面消灭大对象比如把大数组切块、把缓存拆细。第二个坑是-XX:UseG1GC其实不用写。JDK17默认就是G1写了反而容易让人觉得你还带着老JVM的思维调优。不过显式写也有一个好处防止未来换了默认GC之后行为变化。说到底是个团队习惯问题在很多公司配置偏保守显式写也无妨。第三个坑是堆大小最好保证Xms等于Xmx。如果你不设XmsJVM会从小堆开始慢慢扩扩展过程中会伴随堆容量调整带来的额外GC这在早期流量爬升阶段特别明显。我见过一个服务平时GC正常每天固定时间点出现一次耗时很长的Full GC找半天发现是堆容量在扩容原因就是只设了Xmx没设Xms。提前明确堆大小这一整类问题都消失。5.4 问题速查表现象最可能原因建议动作频繁Young GC堆太小或对象分配过快适当加大堆检查对象创建频率偶发长时间Full GC显式System.gc()调用加-XX:DisableExplicitGC并排查SDK代码Old区快速上涨对象晋升异常或大对象过多检查晋升阈值、大对象生命周期G1暂停超出预期GC线程数过多或Region大小不合适手动指定ParallelGCThreads评估Region大小Metaspace撑爆反射/动态代理类加载过多设置合理的MaxMetaspaceSize排查类加载器泄漏切了ZGC后监控看不到GC暂停太短监控采不到直接看zgc.log不要过度依赖监控曲线再补充一个排查技巧JDK17里jstat -gcutil依然好用可以快速看各内存区域的使用率。但jmap -heap这类命令会暂停进程在线上慎用尤其面对高流量时先评估影响找个流量低谷再执行。我自己在调优过程里最后悔的一件事是一开始花了太多时间在参数组合上寻找“神奇配方”后来才发现90%的问题出在对象分配和显式GC调用上。GC参数能做的其实是“最后一公里”的优化前提是业务代码层已经把不合理的对象生命周期清理干净了。所以现在的习惯是先查代码、再开日志、最后才动参数——顺序反了方向就容易跑偏。如果你也正在JDK17上做GC调优建议从一份完整的GC日志开始量化出问题是被迫的还是要优化的目标。不要在刚接触日志数据时急着改堆而是先观察两三天让数据告诉你答案。参数是死的业务特征才是调优的真正指向。