
JDK17刚发布那阵子有个老同事翻出一份Java 8时代的调优参数文档往启动脚本里一粘程序直接起不来。这事儿当时让我印象很深JDK17的GC调优其实已经不是大多数人习惯的那套玩法了。默认回收器换成了G1CMS被彻底移走ZGC从实验室走向生产日志参数全面迁移到统一的Xlog体系——如果不重新理解和梳理这些变化沿用JDK8那套经验很容易踩坑。这篇文章想把这几年在JDK17上做GC调优的实践拆开来讲重点不是背参数而是帮你建立一条从现象观察到方案落地的清晰路径。如果你正被老年代回收频繁、Full GC随机打断服务、或者RT突然拉高这些问题困扰又不想盲目百度一堆互相矛盾的参数这篇文章应该正好对你有用。1. 为什么JDK17的GC调优是一道新题1.1 从CMS消失开始的生态变化很多人对垃圾回收的认知还停留在Serial、Parallel、CMS、G1这四个经典选型上。JDK8时代稍微讲究一点的团队都会用CMS理由也很简单并发标记清理低停顿对在线服务友好。但从JDK9开始默认回收器换成G1JDK14彻底移除CMS到了JDK17你如果还想在启动脚本里写-XX:UseConcMarkSweepGCJVM会直接报Unrecognized VM option连启动都过不去。这意味着网上大量基于CMS调优的文章在JDK17环境里基本失去了参考意义。JDK17里可选的回收器其实还有几个Serial、Parallel、G1、ZGC、Shenandoah另外还有一个专门做基准测试和极端低内存场景的Epsilon不回收垃圾。CMS对应的那套老参数比如CMSInitiatingOccupancyFraction、UseCMSCompactAtFullCollection在JDK17里全部失效。更值得关注的是ZGC。ZGC在JDK11还是实验特性到JDK15转正到JDK17已经是可以放心上生产的版本。它能把GC停顿压到极低这在旧时代几乎不敢想象。所以JDK17的GC调优与其说是在旧参数框架里修修补补不如说是一个全新的决策空间你首先要想清楚用哪个回收器然后才谈得上怎么调。1.2 主流回收器在JDK17下的定位我在JDK17下做技术选型时习惯先用一张表把几个回收器的特点框住回收器目标典型停顿适合场景JDK17状态Serial单线程、简单较长小型应用、桌面工具保留Parallel吞吐量优先较长批量计算、离线任务保留G1吞吐与停顿平衡10~200ms区间大多数在线服务默认ZGC尽量压低停顿通常10ms大堆、低延迟服务转正可用Shenandoah并发压缩、低停顿通常几十ms以内中大型堆、延迟敏感转正可用选择逻辑并不复杂如果你的服务对延迟不敏感比如跑批任务、数据清洗Parallel往往比G1吞吐更高如果是一个常规Web接口服务追求吞吐和延迟的平衡直接用默认G1如果堆内存动辄二三十GB以上又希望接口的尾延迟不被GC打断ZGC基本是首选。我曾经帮某团队看过一个案例他们的服务堆内存设到了64G业务线对P99延迟要求又特别高一开始沿用JDK8的G1参数结果老年代回收时停顿经常冲到300ms以上。后来切换到ZGCP99从150ms降到80ms左右CPU只多消耗了大约8%。这类收益在G1上是很难靠调参拿到的。1.3 调优思路必须跟着GC模型变很多人对GC调优的理解是搜几个参数抄到启动命令里但JDK17的环境下这么做风险更高。原因是G1和ZGC的回收模型比CMS复杂得多比如G1引入了Region、记忆集Remembered Set、并发标记周期、混合回收Mixed GC等机制光靠一两个参数根本控制不住整体行为。所以正确思路应该是先做观测、再定目标、然后做最小干预。JDK17提供了很完整的可观测性工具GC日志的粒度可以精细到每个阶段耗时JFRJDK Flight Recorder甚至能记录每一次垃圾回收的分配速率、晋升速率和停顿明细。用好这些数据往往比盲目改参数更能解决问题。2. 调优前必做目标、观测与工具链2.1 先把调优目标量化我遇到很多人一上来就问G1的MaxGCPauseMillis应该设多少IHOP调到多少合适这种问法其实反了。没有明确的可量化目标调参就是碰运气。GC调优本质是在回答三个问题分配速率高不高、对象存活率怎么样、回收成本能不能接受。你至少需要定义三个指标GC停顿目标比如一次GC不超过100ms、P99不超过50ms、GC频率上限比如每分钟Young GC次数不超过10次、Full GC完全不出现、吞吐底线比如应用于GC之外的有效运行时间占比不能低于95%。举个例子一个在线订单服务业务方明确要求接口P99在100ms以内那GC停顿目标就可以定到50ms以下因为GC停顿只是RT的一部分还要留余量给网络和业务计算。反过来一个离线报表任务T1跑完就算成功你完全可以把GC目标定义成吞吐最高选Parallel更合适。2.2 启动阶段就把观测能力打开生产环境最怕的是出了问题才发现日志没开。JDK17里建议把GC日志、堆转储、JFR这些能力默认打开而且最好做成标准配置模板。我个人的经验是直接用这段基础参数兜底-Xlog:gc*:file/data/logs/gc/gc-%t.log:time,uptime,level,tags:filecount10,filesize50m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/gc/heapdump.hprof -XX:ExitOnOutOfMemoryError这段参数的含义是GC详细日志写入文件按时间和大小滚动保留一旦发生OOM就输出堆快照并且JVM直接退出方便上层调度系统及时拉起新实例。ExitOnOutOfMemoryError这个参数很多人不熟它比单纯的HeapDump更进一步防止进程在堆耗尽后继续半死不活地硬撑。JFR在JDK17里也可以直接生产开启预留的运行时开销非常低。启动时加-XX:StartFlightRecordingduration60m,filename/data/logs/gc/monitor.jfr然后用jfr print命名事件能看到每次GC的所有阶段耗时。这套组合下来排查问题时基本不用靠猜。2.3 日常观测四板斧说到运行时观测我最常用的工具依次是jstat看GC趋势最稳。jstat -gcutil pid 1000 30可以连续观察30秒内Eden、Survivor、老年代的占用比例以及GC次数和时间。jhsdb jmap看堆里对象分布和内存占用。jhsdb jmap --heap --pid pid能得到当前堆的分区情况jmap在JDK17的部分场景下不如jhsdb稳定这是个坑。JFR事件看单次GC的阶段性耗时例如jdk.G1GarbageCollection事件里会列出young collection和root region scan等细分阶段。外部指标如果服务接入了监控体系重点盯GC Pause时间、GC次数、堆使用率三个大盘指标。有一个非常容易忽略的点分配速率比堆占用更重要。老年代缓慢增长可能是正常业务如果分配速率突然飙升即使堆还有空闲也会很快触发GC。用JFR的jdk.ObjectAllocationSample事件能看到哪些栈分配了大量对象九成情况下都能定位到某个热点的集合操作或大量临时字符串拼接。3. G1调优参数地图与每个参数背后的原因3.1 G1的Region模型决定你不能乱调参数G1把整个Java堆划分成一个个相等大小的Region每个Region在逻辑上可能是Eden、Survivor或老年代。回收时G1优先挑垃圾最多的Region垃圾优先Garbage First通过并发标记找出回收收益最高的区域再通过混合回收来一步步搬家。这个设计带来的调优哲学和CMS完全不一样CMS调的是老年代几时触发并发标记、预留多少空间G1调的是停顿目标、回收步幅和并发标记节奏。如果你还拿固定-Xmn去写死新生代大小G1的自适应能力会被大大削弱等于让一个自动档汽车手动挂死档位。3.2 G1的关键参数表与调整思路下面这组参数是我在JDK17下做G1调优最常用的每个都写明了调整思路参数默认值作用什么情况下需要动-Xms/-XmxJVM自行扩展堆初始/最大生产必须设为相同值避免运行时扩容/缩容带来的停顿-XX:MaxGCPauseMillis200G1的软性停顿目标目标过低如50ms以下会让G1频繁计算并大幅压缩新生代反而增加GC次数-XX:G1HeapRegionSize自动Region大小有明显的大对象且尺寸贴边时可以考虑调整-XX:InitiatingHeapOccupancyPercent45触发并发标记的堆占用阈值偏低会导致并发周期频繁偏高会导致回收赶不上分配-XX:G1NewSizePercent5新生代下限限制新生代不被压缩得过小-XX:G1MaxNewSizePercent60新生代上限限制业务临时对象过少时新生代膨胀-XX:G1ReservePercent10预留空间经常出现转移失败Evacuation Failure时调大-XX:G1MixedGCCountTarget8混合回收分几轮完成混合回收单次停顿太长时调大让回收更分散-XX:ConcGCThreads自动并发标记线程数CPU核数少或受竞争严重时适当压缩先说-XX:MaxGCPauseMillis它是最容易被误解的参数。它不是硬性上限而是G1预测模型的一个目标。G1会通过历史概率模型估算下一个周期停顿多久如果发现很容易突破目标就会主动变小新生代、减少回收区数量把停顿压到目标以内。目标设得太严新生代会频繁被压缩导致大量短命对象直接晋升老年代老年代压力反而变大。我在实践中看到很多人设成50ms甚至10ms结果Full GC频次上升恰恰是调反了。再说-XX:InitiatingHeapOccupancyPercent简称IHOP。不准确的网上资料会说它是老年代占比阈值更准确的理解是当整个堆的占用比例达到这个阈值G1就开始启动并发标记周期。默认45%。如果你的服务堆使用率长期只在30%~40%但老年代回收仍然跟不上说明IHOP偏大如果并发标记周期频繁启动但标记过程中垃圾占比其实很低说明IHOP偏小。我的经验是先从45%出发观测一到两周再根据GC日志里并发周期的时间间隔和停顿数据微调。3.3 三个特别容易踩的G1参数坑第一个坑是设置-XX:MaxGCPauseMillis1或者类似极小的值。G1为了满足这种不可能的软目标会不断把新生代压小最后结果可能是Young GC每秒一次整体吞吐崩盘。如果你是延迟敏感型不如直接上ZGC而不是在G1里硬怼停顿目标。第二个坑是手动固定-Xmn。G1自适应的新生代调节能力很强硬设新生代会打断它对停顿目标的计算路径。如果你真的觉得新生代偏大或偏小正确姿势是用G1NewSizePercent和G1MaxNewSizePercent设上下限给G1一个自由发挥的区间。第三个坑是-XX:G1HeapRegionSize调得过大。Region越大并发标记和记忆集维护的粗略程度越高而且Humongous对象的判定标准和后续回收策略都受影响。只有在GC日志中频繁看到类似Humongous allocation of large object的分配时才考虑让Region变大去“吸收”这些大对象否则保持默认就好。4. ZGC与Shenandoah低延迟GC的另一条路线4.1 ZGC为什么能把停顿压到极低如果你被G1的停顿波动折磨得够呛JDK17里的ZGC值得认真考虑。ZGC的核心思路是把回收的绝大多数工作都并发化让业务线程和回收线程一起跑只保留极少数需要在安全点进行的操作。它用染色指针Colored Pointers在指针里存储标记、重映射等信息遇到引用访问时通过读屏障来处理不需要像传统回收器那样反复移动大块对象。在JDK17里ZGC虽然还没有分代分代ZGC是后续版本才逐步完善的但基本能力已经很扎实。大多数实践场景下ZGC的单次停顿能控制在10ms以内好一点的负载可以压到1~2ms。对超大堆几十GB起步的服务来说这个收益非常明显因为G1的Region再多回收遍历成本也会随堆变大而ZGC靠并发处理受堆大小的影响小得多。启动ZGC很简单-XX:UseZGC -Xms16g -Xmx16g注意ZGC不太吃MaxGCPauseMillis这套老参数它内部是围绕“尽量减小停顿”设计的所以不需要像G1那样给它设停顿目标。ZGC的主要代价是CPU。读屏障会在每次引用访问时多做一些事导致吞吐量比G1低一些不同场景下可能低5%~15%。如果你们CPU资源本来就紧张选ZGC前要先想清楚。4.2 Shenandoah和ZGC怎么选Shenandoah和ZGC一样也是并发回收路线但它采用的是转发指针和读屏障机制在中大型堆上同样能把停顿控制得很低。JDK17里Shenandoah已经转正启动方式也是简单的-XX:UseShenandoahGC。那到底选哪个我的个人判断标准是优先看技术栈和运维熟悉度其次看负载特征。ZGC的染色指针机制在以后的JDK版本中演进明显思路更前卫Shenandoah在某些场景下对分配速率不敏感但在读多写多的引用访问负载下读屏障开销要单独测。我没有一个一劳永逸的答案遇到具体业务都是先做对比压测同一个服务分别用G1、ZGC、Shenandoah开三套环境压一版测试流量录GC停顿和RT采样谁的数据好选谁。4.3 低延迟GC的适用边界低延迟GC不是银弹。如果你的堆内存本来就很小小于4G或者业务是典型的批处理ZGC带来的收益很微弱反而多花CPU。我见过一个团队把全部服务都换上ZGC结果一些低QPS服务出现CPU上涨和吞吐下降后来又换回了G1。所以说选回收器应该像一个工程师选工具而不是追潮流。判断基准始终是停顿时长是不是当前服务的核心痛点。如果不是G1默认就够用如果是优先评估ZGC同时做好CPU预算的评审。5. 一次基于G1的调优过程复盘5.1 案例背景与现象某订单处理服务以下用代称某订单服务JDK17 G1堆初始和最大均为16G平时RT的P99在50ms左右。上线发布后某天监控报警P99涨到180ms同时GC监控面板里老年代回收次数明显增加甚至出现Full GC。团队第一反应是加机器但加了两台后P99并没有明显回落。我介入后先做了三件事拿GC日志、开JFR采样30分钟、用jstat连续观测。很快从日志里发现两个关键现象Young GC平均耗时30ms频率每分钟约15次Mixed GC每轮平均耗时200ms一天出现多次偶发Full GC日志里能看到大量Humongous allocation和Full GC (Allocation Failure)字样。5.2 根因分析与参数调整过程看到Humongous allocation时我第一反应是查大对象。用JFR的对象分配采样能看到某业务接口每次请求都会生成一个约2.2MB的临时数组。默认Region大小是1MB任何大于Region一半512KB的对象都会被当成Humongous对象直接放入老年代并且不参与G1的复制回收只能等待老年代回收——这正是老年代GC频次飙升的直接原因。这个案例里我做了两个层面的处理第一步是程序侧优化把那个2.2MB的临时数组改成池化复用或者拆成小块结构。这属于治本方案功能上线后Humongous分配明显减少。第二步为了处理剩余散在大对象把-XX:G1HeapRegionSize从默认的1MB调到4MB这样原本超过1MB一半但小于2MB的对象不再被视为Humongous能被G1以普通Region的方式回收。注意这里不是让G1HeapRegionSize无限变大只要能让大部分常规大对象落回普通Region区间即可。同时调整了IHOP从默认45%升到60%。原因是观测到一个现象堆整体占用率并不高大约只有50%但G1很早就启动了并发标记周期周期结束后回收到的对象却不多。这说明并发周期有些空转白白消耗CPU还增加Mixed GC频率。升到60%之后并发标记周期间隔拉长Mixed GC次数降下去CPU负担也减轻了。5.3 迭代验证与最终效果参数并不是一次调完的。第一轮我只改了IHOP和-XX:MaxGCPauseMillis从原来的300ms改成100ms。观察了两天Full GC确实不再出现但Young GC频率稍微升高Mixed GC偶发停顿还在。第二轮又把-XX:G1HeapRegionSize从默认调到4M同时把-XX:G1ReservePercent升到15给转移失败留出更多逃生空间。之后P99从180ms回到60ms左右GC停顿的波动幅度也从原来的300ms区间收敛到100ms以内。这个案例最想说明的是调优要分轮次、抓主线。如果一开始就同时改八个参数出了问题根本不知道是谁造成的。我强烈建议每次只改一个维度观察至少一个业务低峰高峰周期再决定下一步。上面这个复盘是基于模拟数据还原的典型调优路径重点在于流程可复现具体数值到了你的现场还是要以自己的监控为准。6. 常见问题与排查技巧实录6.1 老年代GC频发第一件事做什么很多人看到老年代使用率到90%就急着调IHOP。但正确的排查顺序是先看GC日志里老年代回收原因是廉价的Concurrent Mode Failure并发模式失败还是Allocation Failure分配失败再看这段时间的对象分配速率有没有上涨。我总结过一张速查表现象常见原因优先处理方向老年代回收频繁但回收效果大分配速率高、存活对象多检查短命大对象、池化连接是否泄漏Full GC频繁元空间不足、Humongous分配、晋升失败查看Full GC日志和Metaspace上限Concurrent Mode Failure并发标记跟不上分配速度调高CPU配额、降低IHOP、增加堆Evacuation Failure回收集内对象太多、预留空间不足调大G1ReservePercent、降低回收步幅Young GC频繁但Eden用量不大Eden被压缩过小恢复MaxGCPauseMillis合理值避免硬压新生代6.2 关于元空间和直接内存的两个隐藏陷阱元空间是JDK里特别容易拖后腿的一环。JDK17的类元数据有自己的Native内存如果应用频繁动态生成类或者用了反射、代理、热部署框架Metaspace会被撑起来。当接近-XX:MaxMetaspaceSize时JVM会触发Full GC来回收无用的类元数据。这类Full GC常常被误判为堆问题。务实做法是把-XX:MaxMetaspaceSize设一个观察值并且在监控里加一条Metaspace使用率曲线。另一个陷阱是直接内存Direct Memory。很多NIO框架消费的是堆外内存它不走GC但它会让进程的RSS不断上涨看起来像内存泄漏。调GC参数对这种问题完全无效你需要的是排查ByteBuffer分配和释放路径。6.3 我常用的几个兜底习惯先亮明我的态度GC参数能少改就少改能用默认就用默认。我最常做的其实只有几件小事堆初始和最大设成相同关掉运行时堆伸缩GC日志、堆转储、ExitOnOutOfMemoryError三件套常开用JFR定期抽样而不是等到事故发生了才去补数据每次调整只改一个参数保留完整的变更记录上线前在压测环境跑一轮不要让新参数直接上生产。另外有个细节JDK11之后GC日志语法从-Xlog:gc衍生出来的-verbose:gc已经被统一到-Xlog体系很多老资料里写的-XX:PrintGCDetails在JDK17中已经不推荐用了。如果你在启动参数里看到用了大量过时参数建议直接看JDK17官方工具说明里的java命令文档别再用老旧模板。说句实在话单纯靠堆参数解决GC问题很多时候只是把一个瓶颈挪到了另一个环节。GC调优真正的价值在于让你搞清楚系统当前的内存分配模式、对象存活特征和回收成本然后用最小代价让它们匹配起来。比如上面那个案例真正解决Humongous问题的核心是程序侧改对象大小参数只是辅助。我现在做GC配置越来越克制十个服务里有八个用的是默认G1加基础观测参数顶多根据实际监控微调IHOP和Region大小。这比十年前人人抄一套惊天调优参数的时代健康多了。JDK17给了我们更好的回收器、更好的日志体系、更好的可观测性工具最该做的并不是疯狂调参而是把这些观测能力用起来让数据告诉你该动哪里。