专栏文章
专栏文章
GC 垃圾回收系列
1. GC 系列 #01:GC 总览与历史:自动内存管理的起源与演化 2. GC 系列 #02:GC 基础算法:标记-清除、标记-整理、复制收集、引用计数 3. GC 系列 #03:分代假说与分代收集:朝生夕死的工程哲学 4. GC 系列 #04:CMS 收集器:Java 第一个并发收集器的设计与缺陷 5. GC 系列 #05:G1 收集器:Region 化堆与可预测停顿 6. GC 系列 #06:ZGC 与 Shenandoah:亚毫秒级停顿的并发移动收集器 7. GC 系列 #07:Go 的 GC:并发三色标记与混合写屏障 8. GC 系列 #08:Python 的内存管理:引用计数与分代循环 GC 9. GC 系列 #09:GC 调优实践:JVM 日志解读与 G1/ZGC 参数策略

GC 系列 #05:G1 收集器:Region 化堆与可预测停顿

发布于 2026-05-25 14:16 👁 40 次阅读
#GC#JVM#理论#G1

G1(Garbage-First)是 JDK 9 起的默认收集器,它通过将堆分割为等大的 Region,用优先队列选择收益最高的 Region 回收,在可预测停顿时间和高吞吐量之间取得了良好平衡。


目录

章节 说明
Region 化堆设计 打破物理分代的束缚
记忆集(RSet) 跨 Region 引用追踪的核心数据结构
G1 的收集模式 Young GC、Mixed GC、Full GC
并发标记周期 五个阶段详解
SATB 与写屏障 并发正确性保证
停顿预测模型 如何实现可预测停顿
G1 调优要点 关键参数与场景

Region 化堆设计

打破传统分代的物理边界

传统分代 GC 的堆是固定物理分区的:Young 区在堆的低地址,Old 区在高地址,大小比例固定。这带来的问题是:某代空间不够时,只能触发 Full GC,无法灵活调配。

G1 将堆划分为大量等大的 Region(默认 1MB~32MB,总数约 2048 个):

gc g1 regions

关键优势:

Humongous Region

大于单个 Region 50% 的对象直接分配到老年代的连续 Humongous Region 中:

-XX:G1HeapRegionSize=4m   # 大于 2MB 的对象进入 Humongous

Humongous 对象的 GC 成本高(连续 Region 难以整理),应尽量避免频繁创建大对象。


记忆集(RSet)

为什么需要 RSet

G1 每次 GC 只回收部分 Region,但被回收 Region 里的对象可能被其他 Region 引用。如果不知道这些外部引用在哪,就得扫描整个堆——这会让"部分回收"失去意义。

RSet(Remembered Set)就是解决这个问题的数据结构:每个 Region 维护一个 RSet,记录哪些其他 Region 引用了自己内部的对象。

gc g1 rset

RSet 的维护方式

每次应用线程修改跨 Region 的引用时,写屏障(Write Barrier) 触发,将来源 Region 记录到目标 Region 的 RSet 中:

// 写屏障伪代码:引用赋值时触发
void writeBarrier(Object obj, Object newRef) {
    if (regionOf(newRef) != regionOf(obj)) {
        // 跨 Region 引用:把 obj 所在 Region 记入 newRef 所在 Region 的 RSet
        newRef.region.rset.add(regionOf(obj));
    }
    obj.field = newRef;
}

RSet 的代价

代价 说明
内存开销 每个 Region 都有 RSet,通常占堆总大小的 1%~20%,对象引用关系越复杂开销越大
写屏障开销 每次跨 Region 引用修改都要更新 RSet,增加写操作的 CPU 开销
维护成本 并发标记的清理阶段需要更新 RSet,这是清理阶段需要 STW 的原因之一

RSet vs CMS 的卡表

CMS 使用卡表(Card Table),粒度是 512 字节的"卡",只追踪老年代 → 年轻代的引用。G1 的 RSet 粒度是 Region,追踪所有跨 Region 引用,更精确但开销也更大。

CMS 卡表 G1 RSet
追踪方向 老年代 → 年轻代 任意跨 Region
粒度 512 字节 Region 级别
用途 Minor GC 时扫描老年代引用 任意 Region 回收时定位外部引用

G1 的收集模式

Young GC(纯年轻代收集)

当 Eden Region 数量达到上限时触发,完全 STW(原因:复制收集需要移动对象并更新所有引用,移动过程中对象地址在变化,必须暂停应用线程防止读到旧地址):

  1. 确定 CSet:全部 Eden Region + 全部 Survivor Region
  2. 并行复制:CSet 中的存活对象复制到新的 Survivor Region(或 age 超阈值则晋升到 Old Region)
  3. 释放:CSet 中的 Region 整体清空,归还 Free Region 池

Young GC 是 G1 最常见的收集,停顿通常在 10~200ms。

gc g1 young gc

G1 Young GC 与 Region 的结合

传统分代(CMS)的 Eden/Survivor 是固定大小的连续内存,G1 没有固定的 Eden 区,而是动态地把若干 Region 打上 Eden 标签

传统分代(CMS) G1
Eden 空间 固定大小的连续内存 若干 Eden Region 的集合,数量动态变化
Survivor 空间 固定两块 S0/S1 若干 Survivor Region,不限于两块
大小调整 手动调参 停顿预测模型自动调整 Eden Region 数量
停顿可控性 不可控(Eden 固定多大收多少) 可控(通过 Region 数量控制每次工作量)

G1 控制停顿时间的本质:Eden Region 数量就是旋钮——上次 GC 停顿偏长则下次减少几个 Eden Region,停顿偏短则增加,持续逼近 MaxGCPauseMillis 目标。

Mixed GC(混合收集)

G1 的核心特色:在收集年轻代的同时,选择性地收集部分老年代 Region。

Full GC(降级,应尽量避免)

Mixed GC 来不及回收,空间不足时触发单线程 Full GC,停顿时间长。


并发标记周期

Mixed GC 前必须先完成并发标记,确定哪些 Old Region 值得回收。

阶段 是否 STW 说明
① 初始标记(搭载在 Young GC 上) ⏸️ STW 标记 GC Roots 直接可达对象,通常几毫秒。STW 原因:需要一致的 GC Roots 快照,应用线程运行时栈帧/静态变量持续变化
② 根区域扫描 🔄 并发 扫描 Survivor Region(从根扫描),不能发生 Young GC(会等待)
③ 并发标记 🔄 并发 遍历整个堆,标记所有存活对象;使用 SATB 算法保证正确性;耗时最长(与应用线程并发)
④ 重新标记 ⏸️ STW 处理 SATB 缓冲区,修正标记,通常 < 1ms(JDK 10+ 并发重新标记)。STW 原因:处理 SATB 缓冲区时需要稳定的对象图
⑤ 清理(前半) ⏸️ STW 计算各 Region 的存活率。STW 原因:统计活跃度和维护 Remembered Set 需要堆状态一致
⑤ 清理(后半) 🔄 并发 回收空 Region,更新 Remembered Set

SATB 与写屏障

三色标记模型

G1 并发标记使用三色标记

颜色 含义
白色 未被标记,GC 结束时仍为白色 = 垃圾
灰色 已被标记,但其引用的对象还未全部标记
黑色 已被标记,且所有引用的对象也已标记(存活)

标记过程:所有对象初始为白色 → 从 GC Roots 出发,依次变灰变黑 → 结束时仍为白色的对象即为垃圾。

并发修改问题

并发标记期间,应用线程可能打破标记一致性:

场景(导致对象被错误回收):

  1. 黑色对象 A 新增了对白色对象 C 的引用
  2. 灰色对象 B 删除了对白色对象 C 的引用
  3. 结果:C 只被已扫描完的黑色对象 A 引用,GC 不会再扫描 A,C 被误判为垃圾

gc g1 satb problem

SATB(Snapshot-At-The-Beginning)

G1 使用 SATB 解决这个问题:并发标记开始时的对象图快照是权威的,删除引用时记录被删除的引用。SATB 同时也处理并发期间 GC Roots 的变化:

gc g1 satb new alloc

// SATB 写屏障(伪代码):引用被删除时触发
void satbWriteBarrier(Object field, Object newValue) {
    Object oldValue = field;
    if (isMarking && oldValue != null) {
        // 把旧引用放入 SATB 队列(保存"快照")
        satbQueue.enqueue(oldValue);
    }
    field = newValue;
}

重新标记阶段处理 SATB 队列,确保快照中的对象都被标记。


停顿预测模型

G1 的核心设计目标:让用户可以指定停顿时间目标

-XX:MaxGCPauseMillis=200   # 目标最大停顿 200ms(默认值)

G1 用衰减平均值(Decaying Average) 统计历史 GC 数据:

基于这些统计,G1 在每次 GC 前预测:回收多少个 Region 能在目标停顿时间内完成,然后选取垃圾最多的 Region 来回收。

示例(目标停顿 200ms,当前已用 150ms,剩余 50ms):

Region 垃圾占比 预计耗时 是否选中
A 80% 5ms ✅ 选中(5ms < 50ms 剩余)
B 60% 8ms ✅ 选中(8ms < 45ms 剩余)
C 30% 12ms ❌ 跳过(12ms > 37ms 剩余)

G1 调优要点

关键参数

# 堆大小(建议设置相同值,避免动态调整开销)
-Xms4g -Xmx4g

# 停顿时间目标(核心参数,不要设太小)
-XX:MaxGCPauseMillis=200

# 触发并发标记的堆占用阈值
-XX:InitiatingHeapOccupancyPercent=45

# Region 大小(建议让 JVM 自动计算)
-XX:G1HeapRegionSize=4m

# 并发标记线程数
-XX:ConcGCThreads=4

常见问题与对策

现象 原因 对策
Full GC 频繁 混合回收速度跟不上晋升速度 降低 InitiatingHeapOccupancyPercent,增加堆大小
Young GC 停顿长 Eden Region 太多,复制开销大 设置更严格的 MaxGCPauseMillis
大对象导致 GC Humongous 分配频繁 增大 G1HeapRegionSize,或减少大对象创建
To-Space 耗尽 Survivor 空间不足 增加堆大小,检查是否有对象晋升过快

参考资料

  • 《深入理解 Java 虚拟机》第 3 章 — 周志明
  • Oracle G1 GC 调优指南
  • Monica Beckwith: "G1 GC: The Garbage First Garbage Collector"
  • CMS 收集器(前任)
  • ZGC 与 Shenandoah(后继)
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论