【JVM原理详解】28-G1收集器深度解析

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 个连续 Region

CSet 与 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 Region

Mixed 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=10

10ms 目标下 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 对比

维度CMSG1
内存布局连续分代Region 分区
老年代算法Mark-SweepMark-Compact(复制)
碎片问题严重无(Region 复制整理)
Full GCSerial Old 降级多线程并行(JDK 10+)
停顿可控性较差(Concurrent Mode Failure)好(CSet 动态调整)
标记算法增量更新SATB
适用堆大小< 4GB4-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=10

G1 会为达成目标疯狂缩小 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 的自适应能根据负载动态优化,手动干预往往帮倒忙。

小结

  • G1Region 分区打破连续分代布局,把堆切成数百个 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调优实战