行业资讯
G1 的 Region 不是平均分:一次 Mixed GC 把老年代回收拖垮的复盘
引子从 CMS 迁到 G1 不是终点JDK 9 之后 G1 成了默认 GC很多团队无脑升级后发现停顿反而变长。我们的交易核心服务迁移到 G1JDK 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 怎么决定这次收哪些 RegionG1 的回收分两种Young GC只收 EdenSurvivor和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:MaxGCPauseMillis200 # 1. 停顿目标别设太低也别太高 -XX:G1HeapRegionSize8m # 2. Region 调大减少 Region 数量降低 RSet 维护成本 -XX:G1MixedGCCountTarget8 # 3. 把混合回收拆成 8 次单次更短 -XX:G1OldCSetRegionThresholdPercent10 # 4. 单次 Mixed GC 最多收 10% 的 Old Region第 1 行MaxGCPauseMillis200是目标不是保证我们最初设成 50G1 为了达标疯狂缩小每次回收范围反而让垃圾堆积、最终触发 Full GC适得其反。第 3 行G1MixedGCCountTarget8把原本一次性的大量 Old Region 回收拆成 8 次增量单次停顿更平滑——这是把 1.2s 降下来的关键一刀。第 4 行限制单次最多收 10% 的 Old Region防止一次 Mixed GC 扫描过多存活对象。第二处源头减少老年代压力——把长生命周期缓存从堆内搬走// 调整前几 GB 的缓存直接放堆里全是老年代常驻对象 // MapString, Product cache new ConcurrentHashMap(); // 1. 拖垮 Mixed GC // 调整后大缓存下沉到堆外 / Redis堆里只留热点小对象 Autowired private RedisTemplateString, 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.log里Pause 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。迁移到 G1JDK 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:InitiatingHeapOccupancyPercentIHOP默认 45调到 70想推迟 Mixed GC 启动、减少频率结果老年代在还没开始混合回收时就涨太快反而更频繁触发 Full GC。后来理解到 IHOP 是 Mixed GC 的启动闸门调太高会让 G1 来不及在并发周期里回收老年代最终回到默认 45 附近才平稳。这个参数和MaxGCPauseMillis是联动的单独拧其中一个往往适得其反——G1 的几个旋钮必须一起看它本质是 Heap 占用、停顿目标、回收频率三者之间的博弈。补充一个选型上的真实对比我们另一套堆 40GB、对停顿要求 10ms 的推荐服务G1 怎么调都压不到 50ms 以下最后切到ZGCJDK 15 后生产可用我们用的是 JDK 17 的 ZGC才把停顿稳定在 5ms 内。所以我的经验是——堆小于 16~32GB、停顿要求百毫秒级G1 调到合适参数就够了一旦堆进入几十 GB 且要求亚十毫秒别硬刚 G1直接上 ZGC 或 Shenandoah收益远大于调参成本。另外提醒一句G1 下-Xlog:gc*的日志量不小建议按大小滚动如gc.log::filecount5,filesize20M别让它把磁盘写满——我们曾因没设滚动把容器 /tmp 撑爆过一次反而引发了比 GC 更离谱的故障。调优之前先把可观测性这层地基打好。思考题如果把MaxGCPauseMillis设得过小G1 为什么反而可能更容易触发 Full GCRSet记忆集在 G1 里是拿来干什么的它为什么会增加回收成本欢迎在评论区交流你的调优经验。
郑州网站建设
网页设计
企业官网