如果你是从 JDK8 直接跳到 JDK17 维护线上服务第一件要接受的事是过去那套 GC 调优经验至少有一半已经作废了。JDK17 的默认垃圾回收器是 G1不是 ParallelGC也不是早就退役的 CMS原本习惯用的 PrintGCDetails 日志参数被统一成了 -Xlog 体系连堆内存的 Region 划分逻辑也和你在 JDK8 里“改改 NewRatio、调调 SurvivorRatio”的直觉完全不同。所以这篇我打算从参数解析讲到实战优化把 JDK17 下 G1 的常用参数、日志读法、调优流程和收集器选型边界一次讲透最后用一个线上 Full GC 的排查案例展示完整链路。内容偏实战适合正在做 JDK17 迁移、或者已经在用 JDK17 但 GC 停顿不达标的同学。先说清楚我给出的是调优思路和判断方法不是让你直接抄一组配置到处用因为 GC 参数必须跟业务负载、堆大小、对象分配速率绑定在一起才有意义。1. 先回答一个问题JDK17 里 GC 调优还灵不灵1.1 默认收集器换了旧经验大面积作废JDK9 之前JVM 默认的垃圾回收器是 ParallelScavenge ParallelOld也就是常说的 ParallelGC。JDK9 开始默认收集器切换为 G1JDK17 作为长期支持版本延续了这一设定。如果你是从 JDK8 升级上来的很多文档里“年轻代默认大小是堆的 1/3”“老年代和年轻代按比例划分”这类结论在 G1 下根本不适用。G1 的年轻代大小是动态的主要受 -XX:MaxGCPauseMillis 这个停顿目标影响它会根据历史回收耗时预测并调整年轻代的大小而不是死板地按 NewRatio 分配。更直接的变化是CMS 在 JDK9 被标记废弃JDK14 正式移除。JDK17 里如果继续使用 -XX:UseConcMarkSweepGCJVM 会直接启动失败报 Unrecognized VM option。所以当你从旧系统迁移到 JDK17第一步不是背新参数而是先把脑子里那套“ParallelGC CMS 调优手册”清空否则调着调着就会拿一个根本不存在的参数去加 JVM 启动项。JDK17 的另一个变化是低延迟收集器终于可用了ZGC 在 JDK15 转正Shenandoah 也在 JDK15 转正二者虽然都不是默认项但让“低停顿 GC”从实验性选项变成了可以上生产的方案。1.2 日志参数从 PrintGCDetails 换成了统一的 XlogJDK8 时代查看 GC 日志最常用的组合是 -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:gc.log。到了 JDK17这几个参数虽然还能用但官方已经标记为废弃统一推荐使用 JDK9 引入的 -Xlog 日志框架。这不仅是语法变化更重要的是能力变化-Xlog 可以按 tag 过滤、按 level 输出粒度比旧的 PrintGCDetails 细得多。例如 -Xlog:gc* 输出所有 GC 相关标签-Xlog:gcheapdebug 可以看到堆前后占用变化-Xlog:gcstatsdebug 能输出更细的统计信息。实际生产中我建议直接配一行标准参数-Xlog:gc*:filegc-%t.log:time,uptimemillis,level,tags:filecount10,filesize20m。含义是所有 gc 标签输出到文件文件按时间戳命名记录时间点、JVM 启动毫秒数、日志级别和标签最多保留 10 个文件每个 20MB。第一次接触这套语法的人容易在冒号层级上出错格式可以拆成三段第一段是标签第二段是输出目标和格式第三段是轮转策略三段之间用冒号分隔别写成逗号。1.3 G1 在 JDK17 里进化到了什么程度JDK9 到 JDK17 之间G1 做了不少实质改进不是换个默认名那么简单。JDK12 的 JEP 346 让 G1 在空闲时能把未使用的堆内存归还给操作系统这解决了 G1 长期占着内存不放的问题对容器环境尤其友好。JDK16 的 JEP 376 为 G1 启用了并发类卸载类卸载从 Full GC 阶段挪到了并发阶段进一步减少了 Full GC 的频率。JDK17 里这些改进默认就是开启的也就是说同样一个服务从 JDK8 升级到 JDK17即使参数完全不动GC 行为也会明显变化。这也回答了标题里的问题JDK17 里 GC 调优当然还灵但“调”的对象变了。JDK8 时代调优重点经常是“怎么把 CMS 的参数配好”JDK17 时代你需要理解的是 G1 的自适应性它会根据你设定的停顿目标动态调整年轻代根据堆占用启动并发标记根据可回收量决定混合回收的轮数。与其说你在“调” GC不如说你在给 G1 提供正确的约束然后让它自己干活。下面几个章节我就按这个思路把参数、计算、日志和案例串起来。2. G1 参数全景把关键旋钮的作用讲透2.1 堆尺寸与 Region调优的底层结构G1 把堆划分成大小相同的 Region每个 Region 独立管理。Region 是这个回收器一切行为的基础晋升、复制、回收都发生在 Region 级别所以 Region 大小直接影响回收粒度和记忆集RSet开销。默认情况下Region 大小由堆大小决定JVM 的目标是让 Region 数量大致接近 2048 个。推算方式很简单Region 大小取 2 的幂范围是 1MB 到 32MB用堆大小除以 2048 向上取整到最近的 2 的幂。举个例子4GB 堆默认 Region 约 2MB对应约 2048 个 Region8GB 堆 Region 约 4MB16GB 堆 Region 约 8MB。如果想手动指定用 -XX:G1HeapRegionSize4m 之类的方式设置。但要注意Region 太小会让 RSet 的内存开销和并发标记要处理的映射量变大Region 太大则回收粒度变粗混合回收时一次可能要把大量对象整体搬走停顿时间容易超标。手动指定前先算一下当前堆大小下的 Region 数量和你要的业务场景是否匹配。这里顺带说一个重要结论G1 是为较大堆设计的收集器堆特别小的时候比如 512MB 以下默认 Region 数量远少于 2048G1 的优势发挥不出来。这种情况不如直接用 ParallelGC省掉 G1 的并发和记忆集开销反而更稳。2.2 停顿目标和年轻代G1 最核心的联动G1 最核心的一组参数联动是 -XX:MaxGCPauseMillis、-XX:G1NewSizePercent 和 -XX:G1MaxNewSizePercent。MaxGCPauseMillis 默认 200ms是 G1 努力达成的停顿软目标。G1 会根据历史 GC 的停顿数据通过预测模型调整年轻代的大小如果预测停顿可能超过目标就压缩年轻代如果停顿时间远低于目标就会放开年轻代减少 GC 频率。G1NewSizePercent 默认 5表示年轻代最小占整个堆的百分比G1MaxNewSizePercent 默认 60表示年轻代最大占堆的百分比。正常情况下这两个参数不需要动G1 自己会在 5% 到 60% 之间寻找平衡。但有一种例外如果你的堆很大而且 MaxGCPauseMillis 设得比较低G1 可能会因为过分追求低停顿把年轻代压到接近 5%导致 Young GC 极其频繁。这时候可以适当调高 G1NewSizePercent给年轻代一个保底值避免 GC 频率失控。反过来如果业务对象分配速率很高、Young GC 频繁而你又不希望年轻代无限膨胀可以用 G1MaxNewSizePercent 限制上限把压力传导到老年代再通过 Mixed GC 消化。这套联动机制意味着你在 JDK8 里习惯的 -XX:NewRatio、-XX:SurvivorRatio 在 G1 下基本派不上用场G1 会自动管理这些比例你强行设置反而会干扰它的自适应逻辑。2.3 并发标记与 GC 线程IHOP 和线程数怎么影响节奏G1 的老年代回收走的是“并发标记 混合回收”路线。触发并发标记的阈值由 -XX:InitiatingHeapOccupancyPercent 控制默认 45含义是整个堆的使用率达到 45% 时G1 启动一次并发标记周期。注意这个值计算的是整个堆的占用比例不是老年代占用比例。标记结束后G1 会在后续的 Young GC 中穿插 Mixed GC逐步回收老年代中可回收量较大的 Region。InitiatingHeapOccupancyPercent 调高并发标记会推迟发生标记频率降低但风险是老年代可能来不及被标记回收堆空间耗尽后退化成 Full GC调低则更早启动标记但标记本身要消耗 CPU如果老年代里可回收对象很少就属于白白忙活。这个参数没有标准答案要根据老年代存活数据量来判断后面案例部分我会讲具体方法。GC 线程参数里-XX:ParallelGCThreads 控制并行阶段使用的线程数默认值公式大致是CPU 核数小于等于 8 时等于核数大于 8 时按 8 (核数 - 8) * 5 / 8 计算。-XX:ConcGCThreads 控制并发标记线程数默认约为 ParallelGCThreads 的四分之一。在容器环境里要注意 CPU 配额问题JDK17 默认能识别容器限制但如果配额没配好JVM 可能认为你有 32 核而实际只给了 4 核并发线程数严重超配抢占业务线程。遇到这种情况可以显式设置 ParallelGCThreads或者用 -XX:ActiveProcessorCount 指定可见核数。2.4 兜底参数Reserve 和混合回收阈值除了上面这些核心参数还有一批容易被人忽视、但关键时刻能救命的兜底参数。-XX:G1ReservePercent 默认 10含义是为晋升预留堆空间的比例。G1 在年轻代回收时需要把存活对象复制到空 Region如果预留空间不够就会出现 to-space exhausted轻则回收效率下降重则退化成 Full GC。分配速率高、晋升量大的场景把 G1ReservePercent 从 10 提高到 15 或 20 是有实际意义的因为提升的是 Full GC 的安全性空间。混合回收阶段还有一个重要参数 -XX:G1MixedGCLiveThresholdPercent默认 85。它表示 Region 存活对象占比低于这个值才值得被回收存活比例太高的 Region 回收代价大、收益低。另一个相关参数是 -XX:G1HeapWastePercent默认 10表示如果堆中的垃圾比例低于 10%G1 会提前结束混合回收周期避免空转。还有 -XX:G1MixedGCCountTarget默认 8表示一次完整的混合回收周期分 8 轮完成这个值太小会让单次暂停时间飙升太大会拖长回收周期。此外如果应用里依赖软引用、弱引用较多可以考虑打开 -XX:ParallelRefProcEnabled让引用处理并行执行如果你的代码和框架没有显式调用 System.gc可以加 -XX:DisableExplicitGC 防止人为触发 Full GC但用了 RMI 这类依赖周期性 System.gc 清理分布式对象的框架时要谨慎。参数默认值作用调整倾向-XX:MaxGCPauseMillis200msG1 的停顿软目标不要盲目调小按业务 RT 需求来-XX:G1HeapRegionSize由堆决定Region 大小影响回收粒度通常不用动小堆/大对象场景需关注-XX:G1NewSizePercent5年轻代最小占比停顿目标过低导致 GC 频繁时可调高-XX:G1MaxNewSizePercent60年轻代最大占比需要限制年轻代膨胀时调低-XX:InitiatingHeapOccupancyPercent45触发并发标记的堆占用阈值存活数据低时调高降低标记频率-XX:G1ReservePercent10晋升预留空间比例高分配速率场景调高到 15~20-XX:ParallelGCThreads按核数计算并行回收线程数容器 CPU 配额异常时显式指定-XX:ConcGCThreads约并行线程的 1/4并发标记线程数一般不动CPU 抢占严重时下调-XX:G1MixedGCLiveThresholdPercent85Mixed GC 的回收筛选阈值可回收 Region 太少时可配合 IHOP 调整-XX:G1MixedGCCountTarget8混合回收轮数目标混合回收停顿超标时调大-XX:G1HeapWastePercent10混合回收提前终止的垃圾比例可回收比例太低时可适当降低3. 调参前先算三笔账3.1 堆该给多大看存活数据量而不是想当然很多人的调优是从拍脑袋定堆大小开始的这是最不推荐的做法。合理的方式是先拿到应用的真实存活数据量再反推堆大小。你可以通过 jmap -histo:live 或者 GC 日志里 Full GC 之后的 Live data 来看存活对象总量。老年代建议给到存活数据的 2 到 3 倍整个堆建议在存活数据总量的 3 到 8 倍之间同时还要给年轻代分配和晋升留下余量。举个例子某个服务 jstat 观察后Full GC 结束老年代存活对象约 1.2GB。那么老年代至少给 2.5GB 才安全再算上年轻代的缓冲堆给 4GB 是一个比较稳的起点。如果物理内存只有 8GB还要给 Metaspace、线程栈、堆外内存留空间就不能无脑把 -Xmx 设成 6GB。堆不是越大越好堆越大Full GC 时扫描和压缩的范围越大停顿时间也越长尤其在 G1 下还存在 Region 数量、RSet 内存等额外开销。容器场景下还有一层JDK17 默认启用容器感知会读取 cgroup 的内存限制但堆外内存不在 -Xmx 管控范围内。给容器设内存 limit 时建议保留 1GB 到 2GB 的余量给 Metaspace、线程栈、DirectByteBuffer 和 JVM 自身元数据否则 OOM 是系统层面的 kill而不是 JVM 抛出的 OutOfMemoryError排查起来更痛苦。3.2 Region 数量怎么估算别让回收粒度失去控制Region 数量是 G1 调优里一个隐性但重要的指标。默认逻辑前面提过堆大小除以 2048 向上取整到 2 的幂。4GB 堆对应 Region 2MB、约 2048 个 Region8GB 堆对应 Region 4MB16GB 堆对应 8MB。这在大部分场景下是合理的但你要会估算否则遇到两种极端情况会摸不着头脑。一种是小堆场景。512MB 堆默认 Region 最小只能 1MBRegion 数量只有 512 个。Region 太少意味着 G1 的 Region 级回收粒度变得很粗每次回收可能涉及大量对象停顿表现不佳。这种情况我一般直接建议换 ParallelGCG1 在小堆上不占优势。另一种是大对象场景。如果应用频繁分配超过半个 Region 大小的巨型对象Humongous ObjectG1 会直接把它们放进老年代连续占用多个 Region。这种对象频繁产生时老年代碎片化会加速即便没有完全占满也会因为连续空间不足而提前触发 Full GC。此时适当调大 Region 大小比如从 2MB 调到 4MB 或 8MB能减少巨型对象占用的 Region 数量但要权衡回收粒度变粗的影响。判断方法也简单在 GC 日志中开启 -Xlog:gcheapdebug或者用 jcmd GC.heap_info 查看当前 Region 大小和数量再用 jmap -histo:live 看看 byte[]、char[] 这类大数组的占比。如果大对象占比高Region 大小这件事就值得认真算一算。3.3 对象分配速率先统计再决定改堆还是改代码GC 调优最容易被忽略的指标是对象分配速率。它决定了年轻代满的速度和 Young GC 的频率比堆大小更能反映真实负载。获取方法很简单启动后用 jstat -gcutil 1000 连续观察看 Eden 区的增长速率。比如 Eden 从 500MB 涨到 1500MB 用了 120 秒那分配速率大约是 8MB/s。如果分配速率在几十 MB/s 级别年轻代再大也会很快填满GC 频率自然降不下来。分配速率高要分两种情况区分。一种是业务确实需要这么大的临时内存比如批量加载、缓存构建、大列表处理这种可以通过调大年轻代占比、增加堆空间来缓冲。另一种是代码写了大量不必要的临时对象比如循环里反复创建 List、DTO 转换时整层复制、用字符串拼接代替 StringBuilder。后一种光调参数是治标不治本更合适的做法是用 JFR 的 Allocation Profiling 定位分配热点。JDK17 自带 JFRjcmd JFR.start 录制后用 JMC 或 jfr 命令分析直接能看到哪个方法、哪一行代码分配的内存最多。这个工具比 jmap 直方图精确得多因为它是按调用栈聚合的能直接告诉你去改哪段代码。计算完分配速率后你才能回答一个关键问题当前 GC 频繁是因为堆太小、年轻代太小还是因为代码分配太多。顺序应该是先排除代码问题再考虑调参数。4. 停顿时间目标为什么 MaxGCPauseMillis 不能乱设4.1 为什么目标设得越小GC 不一定变得越好MaxGCPauseMillis 是 G1 最显眼的参数但也是最容易被误解的参数。它不是硬性承诺而是一个软目标G1 通过预测模型尽量靠近它。问题在于目标设得越小G1 为了满足目标会不断压缩年轻代的大小。年轻代变小后每次 Young GC 回收的对象变少GC 频率就会上升。GC 频率上升意味着总 GC 耗时可能不降反升应用的吞吐量反而下降。我见过有人把 MaxGCPauseMillis 从默认的 200ms 直接改成 10ms结果 Young GC 频率从每分钟几次飙升到每秒十几次CPU 被 GC 线程吃掉了大半接口 RT 反而恶化。这就是典型的“把目标当成了命令”的后果。正确的心态是先观察当前停顿在实际业务中是否真的构成瓶颈如果 P99 200ms 完全能满足业务要求就没必要为了数字好看去压低目标。停顿目标要和业务 RT 目标联动而不是和你的完美主义联动。4.2 用什么数据判断当前停顿目标是否合理判断目标合理性的唯一依据是停顿时间的分布而不是平均值。看 GC 日志时把所有 Pause Young (Normal) (G1 Evacuation Pause) 的耗时记录下来画出 P95、P99 百分位。如果 P95 在目标以内说明大部分时候 G1 是达标的如果 P99 频繁超出目标说明回收能力跟不上。这时候要问的问题是回收能力为什么跟不上可能是并行线程数太少、堆太小、分配速率太高也可能是每次回收的 Region 集合太大。单纯调低 MaxGCPauseMillis 解决不了这些问题反而会让年轻代被压缩到极限制造更多 GC。还有一个容易被忽视的点Mixed GC 的停顿同样受 MaxGCPauseMillis 约束。当老年代可回收 Region 很多时一次 Mixed GC 可能回收的 Region 数量也很多单次停顿容易超标。如果日志里 Pause Young (Mixed) 的耗时超过普通 Young GC 很多可以调大 G1MixedGCCountTarget把一次混合回收拆成更多轮次也可以调低 G1OldCSetRegionThresholdPercent默认 5控制每一轮加入回收集合的老年代 Region 数量。4.3 调整目标的先后顺序和常见误区我建议的调整顺序是先用默认参数跑出基线看日志里的停顿分布确认停顿超标的根因后再决定是调整目标还是调整其他参数。如果根因是年轻代过大导致单次回收耗时太长比如停顿已经到 300ms而你希望控制在 150ms那么可以逐步把 MaxGCPauseMillis 从 200 调到 150观察 G1 是否把年轻代压缩到合理水平。如果根因是分配速率过高那么调整目标只会让情况更糟正确做法是回到第 3 章说的分配热点排查。常见误区有三个一是把 MaxGCPauseMillis 设成 1ms纯属自欺欺人二是发现停顿超标后同时改四五个参数结果无法判断是哪个起了作用三是只看平均停顿不看 P99 和 GC 频率。记住 G1 是一个自适应系统你给它的目标越合理它表现越好你给它的目标越激进它反而越容易走极端。5. JDK17 的 GC 日志实操从开启到看懂一份报告5.1 一行参数拿全信息JDK17 下我推荐的生产日志配置是-Xlog:gc*:filegc-%t.log:time,uptimemillis,level,tags:filecount10,filesize20m这行配置输出所有 GC 相关标签记录时间和 JVM 启动毫秒数保留 10 个 20MB 的轮转文件。如果你想看得更细可以在 gc* 后面追加具体标签-Xlog:gcheapdebug:filegc-heap.log:time,uptimemillis,level,tags -Xlog:gcstatsdebug:filegc-stats.log:time,uptimemillis,level,tags第一个输出每次 GC 前后的堆占用明细第二个输出区域统计信息。排查具体问题时才建议开 debug平时用 info 级别就够debug 日志量很大长时间运行会占用不少磁盘。另一个实用技巧是配合 -Xlog:gcrefdebug 查看引用处理耗时如果应用里有大量软引用或弱引用这个指标能解释一部分停顿来源。5.2 日志怎么读Young GC、Mixed GC、Full GC 长什么样用一段模拟日志来说明。GC 日志中一次典型的 Young GC 记录大致是[120.500s][info][gc] GC(15) Pause Young (Normal) (G1 Evacuation Pause) 1500M-1100M(4096M) 42.021ms翻译一下JVM 启动后 120.5 秒发生了第 15 次 GC类型是 G1 停顿式年轻代回收堆从 1500MB 降到 1100MB总堆大小 4096MB耗时 42ms。这种日志是正常现象的年轻代在回收后降到 1100MB 是因为部分对象晋升到了老年代。Mixed GC 的日志行会把类型标成 Pause Young (Mixed)后面可能带 G1 Evacuation Pause含义是这次年轻代回收还顺带回收了部分老年代 Region。看到 Mixed 日志不代表有问题它是 G1 消化老年代垃圾的正常途径。但如果 Mixed GC 出现的频率特别高说明老年代增长速度很快就要回到分配速率和存活数据上找原因。最需要警惕的是 Full GC。日志中会出现 Pause Full (G1 Compaction Pause) 或 Pause Full (System.gc())前者代表堆真的出问题了后者代表有人显式调用了 System.gc。Full GC 是最重的暂停事件单次可能达到几百毫秒到几秒。Full GC 的原因通常就那么几类to-space exhausted晋升预留空间不足、并发标记失败老年代在标记期间被占满、元空间不足、巨型对象分配失败。排查时先搜日志里的 to-space exhausted 关键字再结合堆大小和存活数据判断是空间问题还是分配速率问题。5.3 从日志到工具让数据替你说话自己读日志是最基础的能力但排查复杂问题时我建议用工具辅助。GCeasy 是目前最常用的在线 GC 日志分析工具上传日志后能自动给出吞吐量、平均停顿、P95/P99 百分位、GC 频率趋势还会给一段通用建议。注意它的建议是模板化的可以参考不能盲从最终判断还是要回到你自己的监控数据。GCMV 是另一个可视化 GC 日志的选项能画出堆使用趋势线和 GC 活动时间线适合观察堆水位的变化规律。实时监控方面jstat 是 JDK 自带的轻量工具jcmd GC.heap_info 可以查看 Region 分布JFR 则能提供分配热点、GC 阶段耗时等更深入的信息。工具的定位是帮你把日志里的时间序列转化成直观趋势从而更快定位问题而不是替代你对 G1 原理的理解。6. 实战案例一次线上 Full GC 的完整调优链路6.1 现象与第一轮数据控制变量是调优的基本原则背景一个 4 核 8GB 内存容器里的 BFF 中间层服务跑在 JDK17 上堆设置 -Xmx4gG1 默认参数。高峰期接口 RT P99 从正常的 60ms 慢慢涨到 300msgc.log 里每小时出现 1 到 2 次 Full GC。第一轮收集数据我做了三件事。第一用 jstat -gcutil 1000 连续观察内存分区变化发现 Eden 区从接近 1.2GB 降到 100MB 大约只需要 20 秒推算分配速率在 55MB/s 左右这个数值对中间层服务来说偏高。第二打开 gc.log 统计Young GC 平均停顿 38msP99 约 120ms这个停顿对默认 200ms 目标来说是达标的所以问题不在年轻代回收。第三用 jmap -histo:live 看存活对象byte[] 和 java.util.HashMap$Node 占了大部分结合 JFR 录制结果热点集中在订单列表 DTO 转换上每一层处理都在复制 List 并创建大量临时对象。这里要强调一个原则不要一次性改一堆参数。第一轮只做数据收集和现象确认记录基线让后续每一步调整都能对应到具体变化。6.2 根因定位参数背锅还是代码背锅把日志完整看一遍后真相浮出水面。Full GC 发生前日志里都有 to-space exhausted 的标记这说明晋升预留空间不足。为什么预留空间不足因为分配速率太高年轻代每次回收都有大量对象需要晋升到老年代老年代增长速度快G1 默认 10% 的保留空间很快被消耗掉。同时堆整体使用率达到 45% 就触发并发标记而并发标记后真正被回收的 Region 并不多——老年代里大量对象是业务中间态、存活时间未必很短标记白忙一场Mixed GC 做了不少无用功。所以这里既有参数问题也有代码问题。参数层面的直接诱因是 G1ReservePercent 默认值太低顶不住高分配速率下的晋升压力IHOP 默认 45 对当前存活数据占比来说也偏保守导致并发标记周期频繁空转。代码层面则是分配速率过高让整个堆的周转速度超过了 G1 的正常消化能力。如果只改参数不优化代码Full GC 可能暂时消失但堆压力没有实质缓解后续负载一涨又会复发。6.3 参数调整与代码优化每一步都要有依据我分两步处理。第一步参数兜底。把 -Xms 和 -Xmx 都固定为 4g避免运行时动态扩容把 -XX:G1ReservePercent 从默认的 10 调到 20给晋升留出更多缓冲把 -XX:InitiatingHeapOccupancyPercent 从 45 调到 60让并发标记晚一点触发减少空转式标记带来的 CPU 浪费把 -XX:MaxGCPauseMillis 设为 100给未来负载留下更低的目标边界。调整后观察几天Full GC 消失P99 降到 140ms 左右但 Young GC 频率还是偏高因为分配速率没有变化。第二步代码优化。根据 JFR 的分配热点定位在 DTO 转换方法里复用了循环外的容器对象避免每处理一条数据都新建 List同时把一次性加载全量订单列表的逻辑改成流式分页处理减少大数组和临时对象在堆里的堆积。代码上线后再看 jstat分配速率从 55MB/s 降到 15MB/s 左右Young GC 间隔从 20 秒拉长到接近 75 秒单次停顿从 38ms 降到 15msP99 回到 70ms 附近堆水位稳定不再出现 to-space exhausted。6.4 复盘什么变量真正起了作用事后复盘真正解决问题的实际上是两步的组合拳G1ReservePercent 和 IHOP 的参数修正解决了 Full GC 的直接触发条件代码分配热点的优化则解决了堆压力的根源。MaxGCPauseMillis 调整为 100 更多的是给未来负载做预期管理在当前负载下并不是最关键的变化。这个案例给了一个很实用的启示GC 调优的产出不只是“把参数改对了”更重要的是把参数背后的数据链路摸清楚。如果你不知道分配速率、存活数据量、to-space exhausted 意味着什么就算有人给了你一组参数你也无法判断它适不适合自己。每次只改一个或一组强关联参数保留基线日志调完观察多天这才是可持续的调优方式。7. 该换 ZGC/Shenandoah 时别硬撑JDK17 收集器边界7.1 ZGC 适合什么样的服务G1 再能调在大堆低延迟场景下也会到极限。堆越大G1 的并发标记和 Region 回收范围越大停顿时间很难压到 10ms 以下。ZGC 在 JDK15 正式转正JDK17 已经足够稳定它的核心设计是让 GC 停顿时间和堆大小基本解耦目标停顿在亚毫秒到几毫秒级别。ZGC 最适合的场景是超大堆 低延迟双重要求比如堆给到 16GB、32GB 甚至更大同时接口 P99 要求在 10ms 以内。这时候 G1 几乎不可能满足ZGC 是更合理的选择。但 ZGC 不是免费午餐它的并发回收依赖读写屏障会占用额外的 CPU同规格服务的 CPU 开销通常比 G1 高开启 ZGC 后 JVM 整体内存占用也偏高。所以小堆、低并发场景强行上 ZGC可能得不偿失。JDK17 下 ZGC 的生产首选平台还是 Linux x86_64 和 AArch64其他平台要先确认官方支持状态。启用 ZGC 很简单-XX:UseZGC 即可需要调的 GC 参数很少因为它的设计目标就是“默认参数覆盖大多数场景”。真正要注意的是 CPU 配额ZGC 并发阶段对 CPU 敏感容器 CPU 核数太少时回收速度可能跟不上分配速度。7.2 Shenandoah 与 ZGC 怎么选JDK17 里 Shenandoah 也是转正的低延迟收集器和 ZGC 经常被放在一起比较。二者目标相同实现思路不同ZGC 基于染色指针通过指针中的颜色位管理对象状态Shenandoah 基于 Brooks 转发指针用转发指针指向对象新位置GC 过程中读取对象都可能触发转发。这些底层差异导致它们在内存屏障开销、堆内存占用、停顿表现上各有侧重JDK17 里选哪个最靠谱的方式是拿你的真实业务流量做 A/B 压测而不是看网上结论。从生态和维护角度看ZGC 由 Oracle 主导和 G1 在同一套开发节奏下演进Shenandoah 主要由 Red Hat 主导社区活跃度略低但在 HBase、Lucene 等场景有较多应用案例。如果你维护的是一个常规 Web 服务没有强绑定这两者的特殊原因我会建议先用 ZGC 做验证因为它和 G1 的切换成本差不多参数面更干净。如果压测结果里 Shenandoah 明确更好再用也不迟。7.3 我的选型建议和最后几句心得给一个相对实用的选型边界大部分 4GB 到 8GB 堆的在线服务G1 默认参数加少量调整就够用不要因为追逐“低延迟”而盲目换收集器堆超过 16GB、延迟要求高、CPU 有多余预算时ZGC 值得认真压测离线批处理、纯计算任务ParallelGC 的吞吐优势更明显Shenandoah 作为 ZGC 的对比选项适合你在两个低延迟方案之间摇摆时做验证。调了几年 GC我最大的体会是GC 参数更多时候是在给代码质量背锅。JDK17 下 G1 的默认配置已经能覆盖大部分在线业务真正需要动手改参数的场景并没有想象中多。遇到 GC 停顿问题先开日志、先算分配速率、先定位分配热点再考虑改参数参数要一次改一个改完要回到日志验证效果。最后提醒一句JDK17 升级后确认容器对 JVM 暴露的 CPU 和内存配额是准确的否则前面所有调优结果都会失真。