Skip to content

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,tags

2. 模拟 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 包)
                if (bean instanceof com.sun.management.GcInfo) {
                    System.out.println("  最近一次 GC 原因: " + bean.getLastGcInfo().getMemoryUsageBeforeGc());
                }
            }
        }
    }
}

G1 的工作流程

G1 的 GC 周期分为四个阶段:

  1. Young GC(STW)——Eden Region 填满时触发,存活对象复制到 Survivor Region 或晋升到 Old Region。G1 的 Young GC 是 STW 的,但因为它只处理新生代 Region,停顿时间可控。

  2. 并发标记周期(Concurrent Marking)——堆占用超过 -XX:InitiatingHeapOccupancyPercent(默认 45%)时启动。使用 SATB(Snapshot-At-The-Beginning)算法:在并发标记开始时拍一张对象图的快照,后续只处理变化部分。和 CMS 的增量更新不同,SATB 保证了并发标记的正确性,但会引入更多浮动垃圾。

  3. Mixed GC(混合回收,STW)——并发标记完成后,G1 从所有 Old Region 中选出垃圾比例最高的 Region 组成 CSet(Collection Set),每次只回收一部分。通过 -XX:G1MixedGCCountTarget(默认 8 次)把一次大停顿分散成多次小停顿。

  4. 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 关键差异

维度CMSG1
算法标记-清除标记-整理(Region 级别)
分代物理分代逻辑分代(Region 标记)
并发标记增量更新SATB
碎片严重可整理(Region 内复制)
可预测性好(停顿预测模型)
大堆表现差(并发模式失败)好(分区回收)
小堆表现差(RSet 开销)

面试追问——G1 实战踩坑

1. 我遇到的一次 Full GC 事故

背景:16GB 堆的推荐系统服务,JDK 11,G1。每天下午 2 点例行 Full GC,停顿长达 8 秒,接口超时告警。

排查过程

  1. jstat -gcutil <pid> 1s 观察到老年代持续增长,Mixed GC 回收速度跟不上分配速度
  2. jmap -dump:live,format=b,file=heap.hprof 后分析,发现一个 200MB 的 HashMap<Long, ArrayList<FeatureVector>> 缓存,每条记录约 1.5MB,占用了大量 Humongous Region
  3. -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 调试命令速查

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 的自适应阈值逻辑,说明你踩过坑,不只是背参数。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。