主题
G1 收集器原理与 Region 设计
问题
G1(Garbage-First)收集器相比 CMS 解决了哪些核心问题?Region 设计是如何实现的?
分析
CMS 是 JDK 8 时代最流行的"低延迟" GC,但它的三个致命缺陷——并发模式失败(Concurrent Mode Failure)、浮动垃圾(Floating Garbage)、内存碎片(Fragmentation)——让它在生产环境中始终是个"定时炸弹"。G1 从 JDK 7u4 开始进入实验阶段,JDK 9 正式成为默认 GC,核心假设是:与其在老年代整堆做标记-清除,不如把堆切成小块,每次只回收垃圾最多的那些块。
G1 放弃物理分代,改用逻辑分代。整个堆被划分为 2048 个大小相等的 Region(1MB–32MB),每个 Region 在运行时可被标记为 Eden、Survivor 或 Old。这种设计带来的好处是:回收粒度从"整堆"降到"Region",每次 GC 只选收益最高的 Region 集合,停顿时间可预测。
代码示例
1. G1 参数调优模板
以下是一组生产可用的 G1 参数配置(JDK 17,16GB 堆,Web 服务):
bash
-Xms16g -Xmx16g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=2
-XX:InitiatingHeapOccupancyPercent=45
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60
-XX:G1HeapRegionSize=8m
-XX:+UnlockExperimentalVMOptions
-XX:G1MixedGCCountTarget=8
-XX:G1HeapWastePercent=5
-XX:G1MixedGCLiveThresholdPercent=85
-XX:+UseStringDeduplication
-XX:+ParallelRefProcEnabled
-XX:+AlwaysPreTouch
-Xlog:gc*:file=gc.log:time,uptime,level,tags2. 模拟 G1 的垃圾回收行为
下面用一段代码模拟 G1 选择"垃圾最多 Region"的回收策略:
java
import java.util.*;
public class G1Simulation {
static class Region {
int id;
int liveBytes; // 存活字节数
int totalBytes; // 总字节数
Region(int id, int totalBytes, int liveBytes) {
this.id = id;
this.totalBytes = totalBytes;
this.liveBytes = liveBytes;
}
double garbageRatio() {
return 1.0 - (double) liveBytes / totalBytes;
}
int reclaimableBytes() {
return totalBytes - liveBytes;
}
}
public static void main(String[] args) {
// 模拟 2048 个 Region,每个 8MB,随机填充存活数据
Random rand = new Random(42);
List<Region> heap = new ArrayList<>();
for (int i = 0; i < 2048; i++) {
int live = rand.nextInt(8 * 1024 * 1024); // 0~8MB 存活
heap.add(new Region(i, 8 * 1024 * 1024, live));
}
// G1 选择回收集:按垃圾比例降序排列,取前 N 个
// 模拟 G1MixedGCCountTarget=8 的效果
int targetPauseMs = 200;
int regionsToCollect = 256; // 每次 Mixed GC 回收约 256 个 Region
heap.sort((a, b) -> Double.compare(b.garbageRatio(), a.garbageRatio()));
System.out.println("=== G1 模拟:选 Region 回收 ===");
System.out.println("总 Region 数: " + heap.size());
System.out.println("本次回收 Region 数: " + regionsToCollect);
long totalReclaimable = 0;
for (int i = 0; i < regionsToCollect; i++) {
Region r = heap.get(i);
totalReclaimable += r.reclaimableBytes();
if (i < 5 || i == regionsToCollect - 1) {
System.out.printf(" Region %4d | 垃圾占比: %.1f%% | 可回收: %d KB%n",
r.id, r.garbageRatio() * 100, r.reclaimableBytes() / 1024);
}
}
System.out.printf("本次回收总空间: %.1f MB (目标停顿: %dms)%n",
totalReclaimable / (1024.0 * 1024), targetPauseMs);
}
}输出示例:
=== G1 模拟:选 Region 回收 ===
总 Region 数: 2048
本次回收 Region 数: 256
Region 1523 | 垃圾占比: 99.9% | 可回收: 8188 KB
Region 731 | 垃圾占比: 99.9% | 可回收: 8187 KB
Region 445 | 垃圾占比: 99.9% | 可回收: 8186 KB
Region 1902 | 垃圾占比: 99.9% | 可回收: 8186 KB
Region 1123 | 垃圾占比: 99.9% | 可回收: 8186 KB
...
Region 838 | 垃圾占比: 50.1% | 可回收: 4104 KB
本次回收总空间: 1898.4 MB (目标停顿: 200ms)3. 通过 JMX 观察 G1 运行状态
java
import java.lang.management.*;
import javax.management.*;
import com.sun.management.GarbageCollectorMXBean;
public class G1Monitor {
public static void main(String[] args) throws Exception {
// 需要 -XX:+UseG1GC 运行
for (GarbageCollectorMXBean bean : ManagementFactory.getGarbageCollectorMXBeans()) {
String name = bean.getName();
if (name.contains("G1")) {
System.out.println("GC 名称: " + name);
System.out.println(" Young GC 次数: " + bean.getCollectionCount());
System.out.println(" Young GC 总耗时: " + bean.getCollectionTime() + "ms");
// 获取 G1 特有统计
com.sun.management.GcInfo gcInfo = bean.getLastGcInfo();
if (gcInfo != null) {
System.out.println(" 最近一次 GC 原因: " + gcInfo.getMemoryUsageBeforeGc());
}
}
}
}
}G1 的工作流程
G1 的 GC 周期分为四个阶段:
Young GC(STW)——Eden Region 填满时触发,存活对象复制到 Survivor Region 或晋升到 Old Region。G1 的 Young GC 是 STW 的,但因为它只处理新生代 Region,停顿时间可控。
并发标记周期(Concurrent Marking)——堆占用超过
-XX:InitiatingHeapOccupancyPercent(默认 45%)时启动。使用 SATB(Snapshot-At-The-Beginning)算法:在并发标记开始时拍一张对象图的快照,后续只处理变化部分。和 CMS 的增量更新不同,SATB 保证了并发标记的正确性,但会引入更多浮动垃圾。Mixed GC(混合回收,STW)——并发标记完成后,G1 从所有 Old Region 中选出垃圾比例最高的 Region 组成 CSet(Collection Set),每次只回收一部分。通过
-XX:G1MixedGCCountTarget(默认 8 次)把一次大停顿分散成多次小停顿。Full GC(降级,STW)——如果 Mixed GC 来不及回收,老年代空间耗尽,G1 退化为单线程 Serial Old 做完整标记-整理。这是 G1 的软肋,主要触发原因包括:Humongous 对象分配失败、IHOP 设置不当、RSet 膨胀过大。
深入理解
1. 停顿预测模型(Pause Prediction Model)
G1 的停顿预测模型是它区别于 CMS 的核心竞争力。-XX:MaxGCPauseMillis=200 设定目标停顿时间,G1 通过历史数据(每个 Region 的平均存活对象数、复制耗时)预测每次 GC 要回收多少 Region 才能保证在目标时间内完成。
关键参数:
-XX:G1HeapWastePercent(默认 5%)——堆中可回收空间占比低于此值时,G1 认为没必要启动 Mixed GC-XX:G1MixedGCLiveThresholdPercent(默认 85%)——只有存活对象占比低于此值的 Region 才进入 CSet,存活率太高的 Region 回收意义不大-XX:G1MixedGCCountTarget(默认 8)——把一次 Mixed GC 回收总量分散到 N 次中,每次回收一部分
2. Remembered Set(RSet)——空间换时间
RSet 是 G1 最重要的数据结构。每个 Region 维护一个 HashTable,记录其他 Region 指向本 Region 的引用,精确到 Card(512 字节)。这样在 Young GC 时,G1 只需要扫描 RSet 就能找到老年代到新生代的跨代引用,无需扫描整个老年代。
代价是内存开销:RSet 约占用堆的 5–20%。对于 64GB 堆,就是 3–12GB 的额外内存。这也是为什么 G1 不适合小堆(<4GB),在小堆上 RSet 的性价比太低。
3. Humongous 对象
对象大小超过 Region 的一半(例如 8MB Region 中 >4MB 的对象)被视为 Humongous 对象,分配到连续的 Humongous Region 中。
Humongous 对象的特殊之处在于:
- RSet 不跟踪 Humongous Region 的引用,所以无法通过 RSet 快速判断是否存活
- 分配和回收效率低,大对象分配会触发 Full GC 的常见原因之一
-XX:G1HeapRegionSize可以调大 Region 以减少 Humongous 对象,但 Region 越大,RSet 粒度越粗
4. G1 vs CMS 关键差异
| 维度 | CMS | G1 |
|---|---|---|
| 算法 | 标记-清除 | 标记-整理(Region 级别) |
| 分代 | 物理分代 | 逻辑分代(Region 标记) |
| 并发标记 | 增量更新 | SATB |
| 碎片 | 严重 | 可整理(Region 内复制) |
| 可预测性 | 差 | 好(停顿预测模型) |
| 大堆表现 | 差(并发模式失败) | 好(分区回收) |
| 小堆表现 | 好 | 差(RSet 开销) |
实战踩坑
1. 一次 Full GC 事故
背景:16GB 堆的推荐系统服务,JDK 11,G1。每天下午 2 点例行 Full GC,停顿长达 8 秒,接口超时告警。
排查过程:
jstat -gcutil <pid> 1s观察到老年代持续增长,Mixed GC 回收速度跟不上分配速度jmap -dump:live,format=b,file=heap.hprof后分析,发现一个 200MB 的HashMap<Long, ArrayList<FeatureVector>>缓存,每条记录约 1.5MB,占用了大量 Humongous Region-XX:+PrintAdaptiveSizePolicy日志显示 IHOP(Initiating Heap Occupancy Percent)阈值 45% 触发并发标记太晚,Mixed GC 阶段老年代已经被撑满
修复:
- 把缓存改为
Guava Cache+ 软引用(-XX:SoftRefLRUPolicyMSPerMB=0),让 GC 在内存紧张时主动回收 - IHOP 从 45% 降到 35%,提前触发并发标记
-XX:G1MixedGCCountTarget从 8 降到 4,减少 Mixed GC 次数,但每次回收更多 Region
结果:Full GC 从每天 1 次降到 0 次,Mixed GC 占比从 12% 降到 3%,P99 延迟从 800ms 降到 120ms。
2. 常见 G1 问题辨析
Young GC 为什么需要 STW?
因为 Young GC 需要更新 RSet 和复制存活对象,这两个操作都涉及内存屏障(Memory Barrier)和 CAS 操作,必须在 STW 状态下保证一致性。"G1 是低延迟 GC"指的是它把大停顿分散成小停顿,而不是完全消除 STW。
G1 的并发标记为什么比 CMS 稳定?
两个原因。第一,CMS 使用增量更新(Incremental Update),并发标记末尾需要一次 STW 的 Remark 来处理漏标对象,漏标率取决于并发阶段对象图的变化速率。G1 用 SATB,在并发标记开始时拍快照,并发阶段即使引用变了,也按快照处理,Remark 阶段只需要处理 SATB Buffer 里的记录,量级可控。第二,CMS 的 Remark 阶段需要扫描整个老年代,G1 只扫描被标记的 Region。
G1 在什么情况下应该换成 ZGC?
三个条件:堆 > 64GB、目标停顿 < 10ms、CPU 核心数 >= 16。ZGC 的染色指针(Colored Pointers)和读屏障(Load Barrier)在超大堆上吞吐量优于 G1,但 CPU 开销更高(约 15% 额外)。如果你的服务 CPU 水位已经 80%+,换 ZGC 前先评估机器资源是否够。
G1HeapRegionSize 设多大合适?
公式:RegionSize = max(1MB, min(32MB, heapSize / 2048))。G1 默认自动计算,目标是 2048 个 Region。如果你明确知道堆里有大量 2-4MB 的缓存对象,手动调大到 8MB 或 16MB 可以减少 Humongous 分配。但不要超过 32MB,否则 RSet 的 Card 粒度(512 字节)在超大 Region 上会变得很粗,跨代引用扫描的精度下降。
3. G1 调试命令速查
bash
# 实时查看 GC 情况
jstat -gcutil <pid> 1s
# 查看 G1 的 Region 分布
jhsdb jmap --heap --pid <pid>
# 开启 GC 详细日志(JDK 17+ 统一日志格式)
-XX:+UnlockExperimentalVMOptions -Xlog:gc+region=trace
# 触发 GC 前手动 dump(不 STW,但可能不完整)
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# 查看 RSet 内存占用
jcmd <pid> GC.stats
# 验证 Humongous 对象
jcmd <pid> GC.class_histogram | head -20总结
G1 的核心设计可以概括为:把大问题拆成小问题,每次只解决收益最高的那部分。Region 模型让 G1 可以精确控制每次 GC 的工作量,结合停顿预测模型实现可预测的延迟。RSet 用空间换时间,保证每代间的引用查找效率。但 G1 不是银弹——小堆上 RSet 开销不划算,大堆上 Full GC 依然是痛点。JDK 17 之后,极限低延迟场景应该考虑 ZGC,而常规 4–64GB 堆的 Web 服务,G1 仍然是默认首选。
Full GC 的触发条件、停顿预测模型的历史数据衰减策略、以及 -XX:G1AdaptiveIHOP 的自适应阈值逻辑,这些就是实际踩坑积累下来的经验。