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 个):
关键优势:
- 分代在逻辑上保留,但物理上灵活分配
- 可以随时将任意 Region 标记为 Young/Old
- 每次 GC 只回收部分 Region,停顿可控
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 引用了自己内部的对象。
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(原因:复制收集需要移动对象并更新所有引用,移动过程中对象地址在变化,必须暂停应用线程防止读到旧地址):
- 确定 CSet:全部 Eden Region + 全部 Survivor Region
- 并行复制:CSet 中的存活对象复制到新的 Survivor Region(或 age 超阈值则晋升到 Old Region)
- 释放:CSet 中的 Region 整体清空,归还 Free Region 池
Young GC 是 G1 最常见的收集,停顿通常在 10~200ms。
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。
- 触发条件:老年代占整个堆的比例超过阈值(
-XX:InitiatingHeapOccupancyPercent=45,默认 45%) - 选择策略:优先选择垃圾占比最高的 Region(Garbage-First 名字的由来)
- 结果:年轻代全部清空;老年代中垃圾最多的若干 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 出发,依次变灰变黑 → 结束时仍为白色的对象即为垃圾。
并发修改问题
并发标记期间,应用线程可能打破标记一致性:
场景(导致对象被错误回收):
- 黑色对象 A 新增了对白色对象 C 的引用
- 灰色对象 B 删除了对白色对象 C 的引用
- 结果:C 只被已扫描完的黑色对象 A 引用,GC 不会再扫描 A,C 被误判为垃圾
SATB(Snapshot-At-The-Beginning)
G1 使用 SATB 解决这个问题:并发标记开始时的对象图快照是权威的,删除引用时记录被删除的引用。SATB 同时也处理并发期间 GC Roots 的变化:
// 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 数据:
- 每个 Region 回收需要多少时间
- 每次 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)
发表评论