G1 的 Region 不是平均分:一次 Mixed GC 把老年代回收拖垮的复盘

引子:从 CMS 迁到 G1 不是终点

JDK 9 之后 G1 成了默认 GC,很多团队"无脑升级"后发现停顿反而变长。我们的交易核心服务迁移到 G1(JDK 11)后,本来指望把 Full GC 干掉,结果出现了一种新的长停顿:Mixed GC 阶段单次回收耗时从 200ms 飙到 1.2s,偶尔还触发了"Allocation Failure"的 Full GC 兜底。调了一周参数,才把停顿重新压回 200ms 量级。

G1 的卖点是"可预测停顿",但这个"可预测"是有前提的——它依赖你给它正确的 Region 假设和停顿目标。

问题:G1 的"可预测"建立在什么假设上

G1 把堆切成很多个大小相等的Region(默认约 2048 个,每个 1MB~32MB),它不再像 CMS 那样分固定的年轻代/老年代,而是把 Region 动态标记为 Eden / Survivor / Old。它的核心算法是增量并行回收:每次只挑"回收收益最高"(垃圾最多)的一部分 Region 来清,从而把停顿控制在MaxGCPauseMillis(默认 200ms)附近。

但这里有个坑:停顿目标是个软目标。如果你的老年代里存活对象太多、Region 之间引用关系太复杂,G1 即便想只收一部分,也可能被迫扫描大量存活对象,实际停顿远超目标。我们的事故就是老年代里塞了大量长生命周期的缓存对象,Mixed GC 每次都要处理它们。

源码/原理:G1 怎么决定这次收哪些 Region

G1 的回收分两种:Young GC(只收 Eden+Survivor)和Mixed GC(在 Young GC 基础上,额外收一部分 Old Region)。触发 Mixed GC 前有个关键概念叫Collection Set (CSet)Remembered Set (RSet)

// 伪代码:G1 选择 CSet 的收益排序逻辑(简化自 G1CollectorPolicy) double score = 0.0; for (Region r : oldRegions) { double reclaimable = r.garbageRatio(); // 1. 该 Region 的垃圾占比 double cost = r.liveBytes() / reclaimBytes(); // 2. 回收成本 = 存活/可回收 score = reclaimable / cost; // 3. 收益 = 垃圾 / 成本 if (r.garbageRatio() < G1HeapWastePercent) // 4. 垃圾太少直接跳过 continue; candidateList.add(r, score); } // 5. 按 score 从高到低取,直到凑够暂停预算

逐行拆解 G1 的取舍逻辑:

  • 第 1 行garbageRatio()是 Region 里垃圾的比例。G1 优先收垃圾多的 Region,这符合直觉——清一个 90% 是垃圾的 Region 比清一个 10% 是垃圾的高效得多。
  • 第 2 行cost是回收成本,存活对象越多、需要复制搬运的越多,成本越高。
  • 第 3 行score = 收益/成本,G1 按这个分数排序,贪心地挑高分 Region 进 CSet。
  • 第 4 行G1HeapWastePercent(默认 5%):如果一个 Region 垃圾占比低于这个阈值,G1 认为"不值得收",跳过。我们当时老年代大量 Region 垃圾占比都很低(因为存活对象是长生命周期缓存),导致 G1 要么收不动、要么为了凑够空间不得不收很多低分 Region,停顿拉长。

实战:把停顿从 1.2s 压回 200ms 的三处改动

第一处,给 G1 一个现实可达的停顿目标,并限制单次 Mixed GC 的 Region 数量:

# JVM 启动参数(JDK 11) -XX:+UseG1GC -XX:MaxGCPauseMillis=200 # 1. 停顿目标,别设太低也别太高 -XX:G1HeapRegionSize=8m # 2. Region 调大,减少 Region 数量,降低 RSet 维护成本 -XX:G1MixedGCCountTarget=8 # 3. 把混合回收拆成 8 次,单次更短 -XX:G1OldCSetRegionThresholdPercent=10 # 4. 单次 Mixed GC 最多收 10% 的 Old Region
  • 第 1 行MaxGCPauseMillis=200是目标不是保证;我们最初设成 50,G1 为了达标疯狂缩小每次回收范围,反而让垃圾堆积、最终触发 Full GC,适得其反。
  • 第 3 行G1MixedGCCountTarget=8把原本一次性的大量 Old Region 回收拆成 8 次增量,单次停顿更平滑——这是把 1.2s 降下来的关键一刀。
  • 第 4 行限制单次最多收 10% 的 Old Region,防止一次 Mixed GC 扫描过多存活对象。

第二处,源头减少老年代压力——把长生命周期缓存从堆内搬走:

// 调整前:几 GB 的缓存直接放堆里,全是老年代常驻对象 // Map<String, Product> cache = new ConcurrentHashMap<>(); // 1. 拖垮 Mixed GC // 调整后:大缓存下沉到堆外 / Redis,堆里只留热点小对象 @Autowired private RedisTemplate<String, Product> redis; // 2. 冷数据下沉 Product get(String id) { Product p = localCaffeine.get(id, k -> redis.opsForValue().get(k)); // 3. 本地小缓存 + 远程 return p; }
  • 第 3 行用 Caffeine(本地小缓存,容量受限、自动驱逐)替代无限增长的ConcurrentHashMap,把缓存总量从几 GB 压到几百 MB,老年代存活对象大幅减少,Mixed GC 要搬运的存活对象随之下降。这是治本的一招。

第三处,监控与验证:

// 用 GC 日志 + 监控确认效果(命令行抽取关键指标) // -Xlog:gc*:gc.log:time,level,tags 开启统一 GC 日志 // 关注指标: // - G1 Evacuation Pause 平均值是否回到 200ms 内 // - Mixed GC 频率与单次耗时 // - 是否还有 Full GC (Allocation Failure)
  • 我们盯着gc.logPause Young (Mixed)Pause Time字段,确认从 1.2s 量级降到 200ms 左右,且Full GC条目消失,才算达标。

对比:G1 之外还能选什么

GC特点适用
G1可预测停顿、平衡型大多数服务端(JDK 9+ 默认)
CMS(已废弃)低延迟但易碎片化老系统遗留
ZGC停顿 < 10ms、堆大时优势明显超大堆、低延迟敏感
Shenandoah并发压缩、低停顿类似 ZGC 的备选

我的取舍:JDK 11/17 的默认 G1 对绝大多数服务够用,先把参数和对象生命周期调好,往往比急着换 ZGC 更划算。只有当堆超过几十 GB、且对停顿极度敏感(比如要求 < 10ms)时,我才建议上 ZGC。

总结与我的取舍

G1 的"可预测停顿"不是免费午餐:它靠 Region 收益排序 + 停顿预算来实现,前提是你别往老年代塞太多长生命周期的存活对象,也别把MaxGCPauseMillis设成不切实际的数字。我们那次事故,根因是"堆内大缓存 + 过低停顿目标"双重叠加,调参只能治标,把缓存下沉才是治本。

我的建议:迁移到 G1 后,先做一件事——用jmap/heap dump 看老年代里到底住了什么。如果是缓存、池化大对象,先挪走再调参,事半功倍。不要一上来就猛加 GC 参数。

复盘数据:那次调优的具体数字

把调优前后的关键指标摊开,比空谈参数更有用。这台交易服务是 8 核 16G 的容器,堆设了 8GB,按默认约 2048 个 Region、每个 4MB。迁移到 G1(JDK 11.0.2)之初,Mixed GC 的Pause Time长期在 800ms~1.2s,每天约 3~5 次因"Allocation Failure"触发的 Full GC,每次 Full GC 停顿 1.5s 以上,对交易链路的毛刺非常明显。

做完前面三处改动后,指标变化如下:Mixed GC 单次停顿降到 150ms~220ms,全天 Full GC 次数归零,G1HeapRegionSize调到 8m 后 Region 数量减半、RSet 维护开销下降约 30%。堆内缓存下沉到 Caffeine(本地上限 200MB)+ Redis 后,老年代常驻对象从 6GB 降到 1.8GB,年轻代到老年代的晋升压力随之大幅下降。我们最终落地参数是 G1 + Region 8m + MixedGCCountTarget 8 + OldCSet 10% + IHOP 45,配合缓存下沉,稳定运行至今。监控上我们盯三个看板:Mixed GC 平均停顿、Full GC 次数(必须为零)、老年代常驻大小趋势。

中间我们还踩过一个坑:一度把-XX:InitiatingHeapOccupancyPercent(IHOP,默认 45)调到 70,想推迟 Mixed GC 启动、减少频率,结果老年代在还没开始混合回收时就涨太快,反而更频繁触发 Full GC。后来理解到 IHOP 是 Mixed GC 的"启动闸门",调太高会让 G1 来不及在并发周期里回收老年代;最终回到默认 45 附近才平稳。这个参数和MaxGCPauseMillis是联动的,单独拧其中一个往往适得其反——G1 的几个旋钮必须一起看,它本质是 Heap 占用、停顿目标、回收频率三者之间的博弈。

补充一个选型上的真实对比:我们另一套堆 40GB、对停顿要求 < 10ms 的推荐服务,G1 怎么调都压不到 50ms 以下,最后切到ZGC(JDK 15 后生产可用,我们用的是 JDK 17 的 ZGC)才把停顿稳定在 5ms 内。所以我的经验是——堆小于 16~32GB、停顿要求百毫秒级,G1 调到合适参数就够了;一旦堆进入几十 GB 且要求亚十毫秒,别硬刚 G1,直接上 ZGC 或 Shenandoah,收益远大于调参成本。

另外提醒一句:G1 下-Xlog:gc*的日志量不小,建议按大小滚动(如gc.log::filecount=5,filesize=20M),别让它把磁盘写满——我们曾因没设滚动把容器 /tmp 撑爆过一次,反而引发了比 GC 更离谱的故障。调优之前,先把可观测性这层地基打好。

思考题

如果把MaxGCPauseMillis设得过小,G1 为什么反而可能更容易触发 Full GC?RSet(记忆集)在 G1 里是拿来干什么的,它为什么会增加回收成本?欢迎在评论区交流你的调优经验。