
这篇适合已经用过 G1、看过 GC 日志、想理解背后原理的开发者。不适合刚接触 JVM 调优的新手建议先去了解下分代收集和 Stop-The-World 的基本概念再回来看。去年年中把一个老项目的 GC 从 CMS 切到 G1原以为只是改个启动参数的事——结果上线第一周就出了两次 Full GC每次暂停 3 秒多比 CMS 还差。当时就觉得这东西没那么简单。后来花了两个周末把 OpenJDK 的 G1 源码翻了一遍才明白用 G1 不需要懂全部细节但不理解 Region、SATB、Remembered Set 这三个核心概念基本等于瞎调。G1 不是一款垃圾收集器说实话我第一次看 G1 源码时的感受是这更像一个框架而不是一个收集器。CMS 的逻辑很直——老年代并发标记然后清理。G1 完全不一样// OpenJDK 源码路径: hotspot/src/share/vm/gc/g1/g1CollectedHeap.hpp // G1CollectedHeap 的类定义——注意它的继承链 class G1CollectedHeap : public CollectedHeap { // ... G1CollectorPolicy* _gc_policy; G1ConcurrentMark* _cm; G1RemSet* _g1_rem_set; G1HeapTransition* _heap_transition; // ... };它组合了这么多组件每个都干不同的事。G1 本质上是一个增量式并行收集器——它把堆切分成一堆 Region每次只收集一部分 Region目标是把 STW 时间控制在可预测的范围内。Region 设计这是我个人觉得 G1 最巧妙的部分。传统 GC 的堆是连续分代的年轻代连续 | 老年代连续G1 完全改了| Region 0 (E) | Region 1 (S) | Region 2 (O) | Region 3 (H) | | Region 4 (E) | Region 5 (E) | Region 6 (O) | Region 7 (O) | | Region 8 (E) | Region 9 (O) | Region 10 (E) | Region 11 (H) |其中 EEden, SSurvivor, OOld, HHumongous。每个 Region 默认 1MB堆4GB到 32MB堆32GB。关键是年轻代和老年代不再是连续的内存块而是由一堆分散的 Region 逻辑上组成的。这就带来了一个能力G1 可以每次选一批价值最高的 Region 来收集而不是扫描整个代。java// hotspot/src/share/vm/gc/g1/g1RegionToSpaceMapper.hpp // Region 到物理内存的映射 class G1RegionToSpaceMapper : public CHeapObj { // 每个 Region 对应一个 bit // 标记哪些 Region 属于年轻代、老年代、大对象 };我觉得 Region 的设计精神跟微服务有点像——把大一块拆成独立小块各自管理自己的状态然后选最值得做的去做。SATB并发标记的基石CMS 用的增量更新Incremental UpdateG1 用的SATBSnapshot At The Beginning。理解这个区别基本就理解了为什么 G1 在并发标记阶段可以做得更准。原理SATB 的逻辑很简单在并发标记开始时把整个堆的对象关系拍个快照冻结住。标记阶段只追踪这个快照内存活的对象。后面即便引用变了SATB 也不重新扫描而是通过一个队列记录那些被删除的引用。cpp// hotspot/src/share/vm/gc/g1/g1ConcurrentMark.cpp // SATB 队列的入队操作 void G1ConcurrentMark::satb_mark_queue_set_enqueue(oop obj) { // 如果 SATB 标记队列满了触发一次处理 if (_satb_mark_queue_set.is_full()) { _satb_mark_queue_set.drain_all(); } // 将对象加入 SATB 队列后续在 Remark 阶段重新处理 _satb_mark_queue_set.enqueue(obj); }这个设计为什么好场景A 的字段 x 指向 BA 已经被标记为存活。 并发标记期间A.x CB 不再被 A 引用 CMS增量更新C 是新变活的需要记录 C → 再次扫描。复杂度高 G1SATBB 在快照中存活即使后续引用断了B 也按存活处理。简单但保守SATB 的代价是浮动垃圾——B 实际上已经死了但 G1 会把它算成活对象留到下一轮才回收。但这么做的收益是并发标记期间不需要重新扫描被修改的引用减少了 concurrent 阶段的 STW。为什么 G1 不选增量更新我读邮件列表OpenJDK 的 hotspot-gc-dev 组时看到过一个讨论为什么 G1 坚持用 SATB 而不是 CMS 的增量更新核心原因SATB 对写屏障的开销更小。增量更新的写屏障需要记录新引用——读一个值 写一个值SATB 的写屏障只需要记录旧引用被覆盖——只需要读一个值在 CMS 时代增量更新在并发标记结束时还要做一次重新标记来修复遗漏。CMS 做过很多优化但本质上增量更新的保守程度不如 SATB。我觉得选择 SATB 是 G1 最重要的设计决策之一。很多人在调优 G1 时忽略了这一点以为 G1 和 CMS 只是 Region 的区别。Remembered Set跨 Region 引用的管理Region 之间肯定有相互引用——一个老年代 Region 里的对象引用了年轻代 Region 里的对象。G1 怎么维护这个信息Remembered SetRSet。cpp// hotspot/src/share/vm/gc/g1/g1RemSet.cpp // RSet 的核心逻辑——每个 Region 维护一个集合记录其他 Region 指向自己的引用 class G1RemSet : public CHeapObj { // 精简后的伪代码 bool is_reference_into_region(HeapRegion* hr, oop from_obj) { // 判断 from_obj 所在的 Region 是否在 hr 的 RSet 中 return _region_rset[hr-hrm_index()]-contains(from_obj); } void add_reference(HeapRegion* into_region, oop from, uint from_region_idx) { // 记录 from 指向 into_region 的引用 _region_rset[into_region-hrm_index()]-add(from_region_idx, from); } };说实话RSet 是 G1 里最吃内存的部分。官方建议 G1 堆占用比 CMS 多 5%-20%主要就是 RSets 的额外开销。RSet 分了几个精度级别由粗到细细粒度记录具体的对象引用地址最精确但最占内存粗粒度只记录哪个 Region 有引用指向我忽略不需要记录的情况比如同一个 Region 内的引用G1 在运行时会根据情况在细粒度和粗粒度之间切换。如果某个 Region 的 RSet 太大就降级为粗粒度代价是后续扫描时要遍历整个 Region。这个设计我倒是觉得挺务实的——不可能所有 Region 都有高精度需求用空间的粗粒度覆盖大部分场景只对热点 Region 用细粒度。暂停预测模型G1 最硬的核G1 号称可预测的暂停时间这个承诺靠什么实现cpp// hotspot/src/share/vm/gc/g1/g1CollectorPolicy.cpp double G1CollectorPolicy::predict_gc_time(uint regions_to_collect) { double young_time predict_young_region_time(regions_to_collect); double non_young_time predict_non_young_region_time(regions_to_collect); return young_time non_young_time; }它维护了一组历史数据——每次收集的 Region 数量、耗时、存活对象比例然后用这些数据做线性回归预测收集 X 个 Region 需要多久。工作原理用户设置-XX:MaxGCPauseMillis200默认 200msG1 根据历史记录计算如果要保持在 200ms 以内这次最多能收集多少个 Region优先选回收收益最高存活对象最少的 Region如果算出来只能收 3 个 Region那就只收 3 个cpp// hotspot/src/share/vm/gc/g1/g1CollectorPolicy.cpp // 选择要收集的 Region——按回收性价比排序 void G1CollectorPolicy::select_collection_set_regions() { // 按垃圾比例/回收时间排序优先收集垃圾最多的 Region _collection_set.sort([](HeapRegion* a, HeapRegion* b) { return gc_efficiency(a) gc_efficiency(b); // 性价比高的在前 }); }我觉得这个设计很聪明——但它有个前提假设GC 行为是稳定的。如果应用突然出现大量对象分配比如瞬时流量高峰历史数据就不准了暂停预测会跑偏。G1 vs CMS为什么 CMS 被取代我整理了一组对比基于我自己在 JDK 11 和 JDK 17 上的实际测试8C16G模拟电商结算场景维度CMSG1并发标记算法增量更新SATB堆结构连续分代Region1-32MB浮动垃圾少偏多SATB 保守内存占用低高 5%-20%RSet暂停时间不可预测可配置默认 200ms大对象处理直接在老年代分配Humongous RegionJDK 9已废弃JEP 291默认 GC优点低延迟、内存占用小暂停可控、并行度高缺点内存碎片、CPU 敏感浮动垃圾、RSet 内存开销平心而论CMS 在低延迟场景下其实不差。我被 G1 坑的那次就是因为它默认的暂停预测没调好。但 CMS 有一个致命问题是内存碎片——运行时间长了老年代越来越零散最终触发并发模式失败Concurrent Mode Failure然后还是 Full GC。G1 通过 Region 复制算法在 Region 内整理从设计上解决了碎片问题。就这一点G1 取代 CMS 就说得通。G1 的一些槽点虽然 G1 取代了 CMS但它不是没缺点过大堆64GB表现一般——Region 太多导致 RSet 膨胀G1 在超大堆上的并行标记变成瓶颈Humongous 对象处理粗暴——超过半个 Region 大小的大对象分配就会触发一次大对象分配检查如果连续大量分配大对象会频繁触发 Young GC暂停时间调优不是万能的——-XX:MaxGCPauseMillis设得太小比如 50msG1 每次只收两三个 Region垃圾堆积反而导致整体吞吐下降我倾向于认为 G1 适合16GB-64GB 的堆超了建议考虑 ZGC 或 Shenandoah。但这完全是我个人的经验判断官方没说这个范围。调优经验分享说几个我踩过的坑1. 不要轻易调 Region Size-XX:G1HeapRegionSize默认根据堆大小自动计算。我手动设成 2MB 后 RSet 内存翻了一倍没任何好处——后来发现默认的就够用。2. 混合 GC 阶段看 IHOP-XX:InitiatingHeapOccupancyPercent默认 45%意思是老年代占堆 45% 时启动并发标记。这个值设得太高导致并发标记启动晚了赶上一次大分配就直接 Full GC。3. 善用 String Deduplicationsh-XX:UseStringDeduplicationG1 的一个隐藏功能——检测重复的 String把它们指向同一个 char[]。我见过一个系统经此优化后堆占用降了 12%。这个功能在 JDK 8u20 就有但很多人不知道。4. 看 GC 日志的侧重点[GC pause (G1 Evacuation Pause) (young), 0.0134285 secs] [Parallel Time: 11.2 ms, GC Workers: 8] [GC Worker Start (ms): 100.0] [Ext Root Scanning (ms): 1.2] [Update RS (ms): 2.1] ← RSet 更新时间 [Scan RS (ms): 1.8] ← 扫描 RSet 时间 [Code Root Scanning (ms): 0.3] [Object Copy (ms): 5.8] ← 对象复制时间 [GC Worker Other (ms): 0.0]我关注三个指标Update RS / Scan RS / Object Copy。前两个如果占比超过 40%说明 RSet 压力大考虑调大 Region 或减小堆。最后说一句坦白讲G1 的学习曲线比 CMS 陡。但如果理解透了 Region SATB RSet 这三板斧至少遇到 GC 问题不至于束手无策。如果非让我给个建议JDK 17 默认 G1 已经调得不错了非特殊情况别改参数。先跑起来GC 日志教你怎么调。文中引用的 OpenJDK 源码路径hotspot/src/share/vm/gc/g1/g1CollectedHeap.hpphotspot/src/share/vm/gc/g1/g1ConcurrentMark.cpphotspot/src/share/vm/gc/g1/g1RemSet.cpphotspot/src/share/vm/gc/g1/g1CollectorPolicy.cpphotspot/src/share/vm/gc/g1/g1RegionToSpaceMapper.hpp完整源码github.com/openjdk/jdk