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 服务):
-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"的回收策略:
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 运行状态
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 包)
if (bean instanceof com.sun.management.GcInfo) {
System.out.println(" 最近一次 GC 原因: " + bean.getLastGcInfo().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 膨胀过大。
P7/P8 深度
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 开销) |
面试追问——G1 实战踩坑
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 问题
Q: G1 的 Young GC 为什么要 STW? A: 因为 Young GC 需要更新 RSet 和复制存活对象,这两个操作都涉及内存屏障(Memory Barrier)和 CAS 操作,必须在 STW 状态下保证一致性。别被"G1 是低延迟 GC"误导——它只是把大停顿分散成小停顿,但不能完全消除 STW。
Q: G1 的并发标记为什么比 CMS 稳定? A: 两个原因。(1) CMS 使用增量更新(Incremental Update),并发标记末尾需要一次 STW 的 Remark 来处理漏标对象,漏标率取决于并发阶段对象图的变化速率。G1 用 SATB,在并发标记开始时拍快照,并发阶段即使引用变了,也按快照处理,Remark 阶段只需要处理 SATB Buffer 里的记录,量级可控。(2) CMS 的 Remark 阶段需要扫描整个老年代,G1 只扫描被标记的 Region。
Q: G1 在什么情况下应该换成 ZGC? A: 三个条件:堆 > 64GB、目标停顿 < 10ms、CPU 核心数 >= 16。ZGC 的染色指针(Colored Pointers)和读屏障(Load Barrier)在超大堆上吞吐量优于 G1,但 CPU 开销更高(约 15% 额外)。如果你的服务 CPU 水位已经 80%+,换 ZGC 前先想想能不能加机器。
Q: G1HeapRegionSize 设多大合适? A: 公式:RegionSize = max(1MB, min(32MB, heapSize / 2048))。G1 默认自动计算,目标是 2048 个 Region。如果你明确知道堆里有大量 2-4MB 的缓存对象,手动调大到 8MB 或 16MB 可以减少 Humongous 分配。但不要超过 32MB,否则 RSet 的 Card 粒度(512 字节)在超大 Region 上会变得很粗,跨代引用扫描的精度下降。
3. G1 调试命令速查
# 实时查看 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 的自适应阈值逻辑,说明你踩过坑,不只是背参数。