28-G1 收集器深度解析
CMS 用并发回收把停顿降到了百毫秒级,但留下了两个硬伤:内存碎片和Full GC 不可控。当堆增长到 8GB、16GB 甚至更大,CMS 的 Full GC(退化为 Serial Old)会变成几秒甚至十几秒的灾难。G1(Garbage First)收集器用分区化(Region-based)设计彻底解决了这两个问题:它把堆切成数百个大小相等的 Region,按"回收价值"优先回收垃圾最多的区域——这就是 “Garbage First” 名字的由来。G1 是JDK 9+ 的默认收集器,本篇深入剖析其内存布局、CSet/RSet/SATB 机制、GC 流程与调优实战。
G1 的内存布局:Region 分区
G1 打破了传统的"连续新生代 + 连续老年代"布局,把整个堆划分为大小相等的 Region(默认 1-32MB,2 的幂)。每个 Region 在运行时动态扮演不同角色:
堆(16GB,假设 Region=8MB,共约 2048 个 Region): ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ │E │E │S │O │O │H │H │O │E │ │O │S │ ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ │O │E │O │O │H │ │E │O │O │S │O │O │ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ E=Eden S=Survivor O=Old H=Humongous 空=Free四种 Region 类型:
- Eden:新生代,刚分配的对象。
- Survivor:GC 后存活的新生代对象。
- Old:老年代,多次 GC 后仍存活的对象。
- Humongous:大对象专用,超过 Region 一半大小的对象直接分配在这里。
为什么分区化是革命性的
传统收集器的代际是连续内存块,回收要么全收、要么不收。G1 的 Region 是逻辑上的代——同一时刻 Eden Region 可以散布在堆的任意位置。这让 G1 能选择性回收:只回收垃圾最多的几个 Region,而不必每次扫全堆。
传统 CMS:Full GC 时扫整个老年代 → 堆越大越慢 G1 Mixed GC:只回收垃圾最多的 10 个 Old Region → 停顿可控这就是 G1 能在大堆上保持可控停顿的根本原因:回收范围与堆大小解耦。
Humongous Region
大于 Region 一半的对象(如 5MB 数组在 8MB Region 下)直接进 Humongous Region,可能跨越多个连续 Region。大对象单独管理的好处是:
- 不占用 Eden/Survivor 空间,避免频繁晋升触发 GC。
- 回收时整体处理,简单高效。
// Region = 8MB 时,下面这些都会进 Humongousbyte[]big1=newbyte[5*1024*1024];// 5MB > 4MB(一半)byte[]big2=newbyte[16*1024*1024];// 16MB,占 2 个连续 RegionCSet 与 RSet
G1 的核心数据结构是CSet(Collection Set)和RSet(Remembered Set)。
CSet:本次回收的 Region 集合
CSet 是"本次 GC 要回收的 Region 列表"。G1 根据停顿目标和回收价值(Region 里垃圾占比)动态选择:
停顿目标 200ms,Region 回收耗时约 10ms/个 → CSet 约 20 个 Region 选择策略:按垃圾占比降序选,Garbage First! Region A: 90% 垃圾 → 优先选 Region B: 80% 垃圾 → 选 Region C: 30% 垃圾 → 不选(性价比低)Young GC 时 CSet = 所有 Eden + Survivor Region;Mixed GC 时 CSet = 所有 Young + 部分 Old Region(选垃圾最多的 Old)。
RSet:记录"谁引用了我"
传统分代回收通过Card Table记录跨代引用。G1 的 Region 是动态的,跨 Region 引用更复杂,需要更精细的结构——RSet。
RSet 的思路是:每个 Region 维护一个"指向我的引用来源"列表。
Region X 被引用情况(RSet): Region A.obj5 → Region X.obj1 Region C.obj2 → Region X.obj3 ...回收 Region X 时,只要扫它的 RSet 就知道"哪些外部对象指向我",不必扫全堆找 roots——这是 G1 能局部回收的关键。
没有 RSet:回收 Region X 要扫描整个堆找"谁引用 X" → 全堆扫描,停顿爆炸 有 RSet:回收 Region X 只扫 X 的 RSet → 停顿可控RSet 的代价是内存开销(通常占堆 1-20%)和写屏障开销——每次引用写入都要更新 RSet。G1 用写屏障(Write Barrier)拦截写操作:
// 伪代码:G1 写屏障voidfieldWrite(Objectsrc,Fieldf,Objectdst){RegionsrcR=regionOf(src);RegiondstR=regionOf(dst);if(srcR!=dstR){dstR.rememberedSet.add(srcR,offset(src,f));// 记录跨 Region 引用}// 实际写入src.f=dst;}SATB:并发标记的快照
G1 的并发标记和 CMS 一样面临"引用变化"问题,但解决方案不同——SATB(Snapshot-At-The-Beginning)。
思路:并发标记开始时拍一张"存活对象快照",标记期间即使引用断开,原快照里的对象仍按存活处理(本轮不回收)。
并发标记开始时刻 T: 对象图状态 → 快照 S 标记期间(应用在跑): 断开引用:A.field = null(原本 A→B) SATB 行为:B 仍按"在 T 时刻被引用"处理 → 本轮不回收 B → B 成为"浮动垃圾",下次再回收SATB 与增量更新的区别
| 机制 | 解决的问题 | 处理时机 | 数据结构 |
|---|---|---|---|
| 增量更新(CMS) | 黑色对象新引用白色对象 | 重新标记时重扫 | Card Table |
| SATB(G1) | 灰色对象断开到白色对象的引用 | 并发期间实时记录 | SATB 队列 |
SATB 在写屏障中记录"被覆盖的旧引用":
// 伪代码:SATB 写屏障voidfieldWrite(Objectsrc,Fieldf,ObjectnewValue){ObjectoldValue=src.f;// 先读旧值if(oldValue!=null){satbQueue.add(oldValue);// 加入 SATB 队列,标记结束统一处理}src.f=newValue;}快照保证:旧引用的对象在快照时刻是存活的,本轮按存活处理,绝不漏标。代价是浮动垃圾(本可回收但要等下轮),但正确性优先。
GC 流程
G1 有三种 GC 模式,按触发条件切换。
Young GC
当 Eden 区填满时触发。CSet = 所有 Eden + Survivor Region,复制存活对象到新的 Survivor Region。
Young GC 前: [Eden1][Eden2][Eden3][S0][O1][O2][O3] Young GC 后(存活对象进 Survivor): [ ][ ][ ][ ][O1][O2][O3][S1][S1] ← Eden 清空 → ← 原 Eden 存活对象 →Young GC 是 STW 的,但因为只回收 Young Region(数量受控),停顿可通过MaxGCPauseMillis控制。这是 G1 的常规操作,频率最高。
Mixed GC
当老年代占用率达到阈值(-XX:InitiatingHeapOccupancyPercent,默认 45%)时,G1 触发并发标记,标记完成后进入 Mixed GC 阶段。Mixed GC 的 CSet = 所有 Young +部分 Old Region(选垃圾最多的)。
Mixed GC 流程: 1. 并发标记(不停应用)── 遍历 Old Region 找垃圾 2. 重新标记(STW)────── 补标 SATB 队列 3. 独占清理(STW)────── 统计垃圾占比,选 CSet 4. 多次 Mixed GC ─────── 每次回收部分 Old RegionMixed GC 是 G1 回收老年代的核心方式——分批回收 Old Region,避免一次性全老年代 Full GC。通常一次 Mixed GC 回收 10-30% 的 Old Region,多次才能清完。
Full GC(退化)
当 Mixed GC 跟不上分配速度(老年代耗尽)时,G1 退化为Single-threaded Full GC(Serial Old 算法),STW 整理全堆。这是 G1 的失败兜底,应该极力避免。
正常:Young GC → Mixed GC → Young GC → Mixed GC ...(停顿可控) 异常:分配太快 → Mixed GC 来不及 → Full GC(灾难,秒级停顿)JDK 10 后 G1 的 Full GC 改为多线程并行,缓解了停顿,但仍不理想。正确调优应保证 Full GC 不发生。
-XX:MaxGCPauseMillis 调优
G1 最核心的调优参数是停顿目标:
java-XX:MaxGCPauseMillis=200-XX:+UseG1GC-cpMyApp com.example.Main默认 200ms。G1 根据这个目标动态调整 CSet 大小:停顿目标小,就少选几个 Region;目标大,就多选几个。
MaxGCPauseMillis=50 → CSet 小 → 回收快但不彻底 → GC 频繁 MaxGCPauseMillis=500 → CSet 大 → 回收彻底 → 单次停顿长调优的平衡点
# 生产推荐(4GB 堆)java-Xms4g-Xmx4g-XX:+UseG1GC\-XX:MaxGCPauseMillis=200\-XX:InitiatingHeapOccupancyPercent=45\-XX:G1HeapRegionSize=8m\-XX:G1NewSizePercent=20\-XX:G1MaxNewSizePercent=60\-XX:G1MixedGCCountTarget=8\-Xlog:gc*=info:file=gc.log-cpMyApp com.example.Main参数说明:
MaxGCPauseMillis=200:200ms 是多数 Web 服务可接受的上限。InitiatingHeapOccupancyPercent=45:堆占用 45% 就开始并发标记,默认值合理。G1HeapRegionSize=8m:Region 大小,G1 会根据堆自动选 1/2/4/8/16/32MB。G1NewSizePercent=20:新生代最小占比,防止被压得太小。G1MaxNewSizePercent=60:新生代最大占比,留空间给 Mixed GC。G1MixedGCCountTarget=8:Mixed GC 分 8 次完成,每次回收少点 Old Region,停顿更可控。
停顿目标不是越低越好
# 错误:盲目调低-XX:MaxGCPauseMillis=1010ms 目标下 G1 会把 CSet 压到极小,导致回收不彻底、GC 频繁飙升,反而降低吞吐量。根据 SLA 设定合理值,Web 服务通常 100-300ms。
代码示例:观察 G1 行为
/** * 演示 G1 Young GC 与 Mixed GC * 适用 JDK 11/17 * * 运行: * java -Xms2g -Xmx2g -XX:+UseG1GC * -XX:MaxGCPauseMillis=200 * -XX:G1HeapRegionSize=4m * -Xlog:gc*=info,gc+heap=debug -cp MyApp G1GcDemo */publicclassG1GcDemo{staticfinalint_1MB=1024*1024;staticfinaljava.util.List<byte[]>CACHE=newjava.util.ArrayList<>();publicstaticvoidmain(String[]args)throwsException{// Phase 1:快速分配,触发 Young GCfor(inti=0;i<100;i++){byte[]block=newbyte[256*1024];Thread.sleep(5);}// Phase 2:保留引用,填充老年代,触发 Mixed GCfor(inti=0;i<200;i++){CACHE.add(newbyte[512*1024]);if(i%20==0)CACHE.subList(0,CACHE.size()/3).clear();Thread.sleep(2);}}}日志关键字段:
# Young GC [0.234s][info][gc,start] GC(0) Pause Young (Normal) (G1 Evacuation Pause) [0.234s][info][gc,heap] GC(0) Eden regions: 40->0(40) [0.235s][info][gc] GC(0) Pause Young (Normal) 500M->200M(2048M) 5.678ms # 并发标记开始(Mixed GC 前奏) [1.234s][info][gc] GC(5) Concurrent Cycle [1.234s][info][gc,marking] GC(5) Concurrent Clear Claimed Marks [1.235s][info][gc,marking] GC(5) Concurrent Scan Root Regions [1.300s][info][gc,marking] GC(5) Concurrent Mark (1.234s, 1.300s) # Mixed GC [1.567s][info][gc,start] GC(8) Pause Young (Mixed) (G1 Evacuation Pause) [1.567s][info][gc,heap] GC(8) Old regions: 30->25 [1.568s][info][gc] GC(8) Pause Young (Mixed) 1200M->800M(2048M) 12.345ms注意日志中(Normal)表示 Young GC,(Mixed)表示 Mixed GC。Old regions: 30->25说明这次 Mixed GC 回收了 5 个 Old Region。
G1 vs CMS 对比
| 维度 | CMS | G1 |
|---|---|---|
| 内存布局 | 连续分代 | Region 分区 |
| 老年代算法 | Mark-Sweep | Mark-Compact(复制) |
| 碎片问题 | 严重 | 无(Region 复制整理) |
| Full GC | Serial Old 降级 | 多线程并行(JDK 10+) |
| 停顿可控性 | 较差(Concurrent Mode Failure) | 好(CSet 动态调整) |
| 标记算法 | 增量更新 | SATB |
| 适用堆大小 | < 4GB | 4-32GB |
| JDK 支持 | JDK 8(14 移除) | JDK 9+ 默认 |
| 调优复杂度 | 高(参数多) | 中(自适应为主) |
G1 的核心优势是用 Region 复制代替全堆整理,让大堆也能保持可控停顿。CMS 在 4GB 以下堆仍有竞争力,但 G1 是面向未来的选择。
JDK 9+ 默认收集器
从 JDK 9 开始,不显式指定-XX:+UseG1GC时默认就是 G1。这意味着:
- 大多数应用开箱即用,无需调参。
MaxGCPauseMillis=200的默认值对多数场景合理。- 只有特殊场景(超低延迟、超大堆、批处理)才需要换收集器。
JDK 11 进一步优化了 G1:并发标记的停顿从多次 STW 优化为单次,减少了 STW 次数。JDK 12 引入了可中断 Mixed GC(JEP 344),超时能中止,避免停顿超标。JDK 17 的 G1 在 NUMA 感知上也有改进。
实践要点
1. 不要盲目调小停顿目标
# 错误:10ms 目标导致 GC 风暴-XX:MaxGCPauseMillis=10G1 会为达成目标疯狂缩小 CSet,导致回收不彻底、频率飙升。根据 SLA 设 100-300ms。
2. IHOP 阈值调整
默认InitiatingHeapOccupancyPercent=45,即堆用到 45% 就开始并发标记。如果 Mixed GC 来不及,可调低到 35-40,提早启动回收。
3. Region 大小的选择
G1 自动选择 Region 大小,但某些场景需手动干预:
# 大对象多时,调大 Region 减少 Humongous 开销-XX:G1HeapRegionSize=16m# 堆小(< 2GB)时保持默认(1-2MB)即可4. 监控 Full GC
G1 出现 Full GC 说明调优失败。日志里Pause Full (G1 Compaction Pause)就是红线。常见原因:老年代太小、Mixed GC 速度跟不上分配、大对象洪流。
5. Mixed GC 的目标次数
G1MixedGCCountTarget默认 8,即 Mixed GC 分 8 次清完老年代垃圾。调大(如 16)让每次回收更少、停顿更短;调小(如 4)清得更快但单次停顿长。
6. 不要关闭自适应
# 错误:手动固定新生代-XX:G1NewSizePercent=50-XX:G1MaxNewSizePercent=50除非有充分理由,不要强制固定新生代比例——G1 的自适应能根据负载动态优化,手动干预往往帮倒忙。
小结
- G1用Region 分区打破连续分代布局,把堆切成数百个 1-32MB 的 Region,动态扮演 Eden/Survivor/Old/Humongous 角色。
- CSet是本次回收的 Region 集合,RSet记录"谁引用了我"让局部回收成为可能,两者共同支撑了 G1 的"按价值优先回收"策略。
- SATB在并发标记开始时拍快照,通过写屏障记录旧引用,保证并发标记正确性,代价是浮动垃圾。
- GC 流程:Young GC(常规)→Mixed GC(回收部分 Old)→Full GC(失败兜底,应极力避免)。
- 核心调优:
MaxGCPauseMillis设 100-300ms,IHOP=45,大对象多时调大G1HeapRegionSize,保持自适应开启。 - JDK 9+ 默认 G1,对 4-32GB 堆的低延迟场景是当前最佳选择;CMS 在 JDK 14 后已移除。
下一篇我们将探索更前沿的低延迟收集器——ZGC 与 Shenandoah,它们把停顿目标从"百毫秒"推向"亚毫秒",是 GC 技术的当代前沿。
更多内容:JVM调优实战