G1 收集器原理

举报
yd_287908631 发表于 2026/10/30 09:10:22 2026/10/30
【摘要】 拆解 G1 的 Region 布局、回收周期与可预测停顿模型

G1(Garbage First)从 JDK 7 引入,JDK 9 成为默认收集器,JDK 17 上仍是服务端的主流选择。它的核心承诺是「可预测的停顿」:通过 -XX:MaxGCPauseMillis 指定目标,G1 自己决定每次回收哪些 Region。本文解释 G1 如何用 Region 布局、SATB 和停顿预测模型兑现这个承诺,并给出 JDK 8 与 JDK 17 的差异。

为什么需要 G1

CMS 的问题在于碎片和并发模式失败。标记-清除不整理内存,运行久了老年代充满不连续的空闲块,大对象分配失败就退化为串行的 Full GC,停顿可能达到数秒。

G1 的设计目标是:把堆划分为大量小区域,每次只回收「垃圾最多」的那部分,从而在有限时间内获得最大收益。名字里的 Garbage First 就是这个意思——优先回收收益高的区域。

Region 布局

G1 把堆划分为大小相等的 Region,默认约 2048 个,每个 Region 大小为 1 MB 到 32 MB 之间的 2 的幂,由 -XX:G1HeapRegionSize 指定,未指定时按堆大小自动推导:

| 堆大小 | Region 大小 | Region 数量 |

| --- | --- | --- |

| 2 GB | 1 MB | 2048 |

| 8 GB | 4 MB | 2048 |

| 16 GB | 8 MB | 2048 |

| 32 GB | 16 MB | 2048 |

每个 Region 在逻辑上扮演一种角色:Eden、Survivor、Old、Humongous。角色是动态的,一个 Region 这次是 Eden,下次回收后可能变成 Old。这就是 G1 的「逻辑分代、物理不分代」——不再要求新生代和老年代在地址空间上连续。

Humongous Region 用于存放超过 Region 大小一半的大对象。如果一个对象超过整个 Region,会占用连续的多个 Humongous Region。这类对象直接进入老年代,且在 JDK 8 中只能在并发标记周期的清理阶段回收,容易造成浪费;JDK 17 对此有优化,允许在 Young GC 中回收部分 Humongous 区域。

跨 Region 引用

G1 的每个 Region 都有一份记忆集(RSet),记录「谁引用了我」。RSet 的底层实现是哈希表加卡表,每个 Region 对应一个稀疏或细粒度的位图。Young GC 时,只要扫描 Eden 和 Survivor 的 RSet 就能找到外部引用,不必扫描整个老年代。

RSet 的内存开销通常占堆的 1% 到 20%,跨 Region 引用越多开销越大。这是 G1 在「引用高度交叉」的堆上表现不佳的原因之一。

回收周期

G1 的回收分为两个层次。

Young GC

只回收 Eden 和 Survivor。当 Eden 被填满时触发,全程 STW,多线程并行复制存活对象。Young GC 的停顿时间取决于新生代 Region 数量和存活对象大小,通常几十毫秒。

G1 会根据历史数据动态调整新生代 Region 数量,使其接近 MaxGCPauseMillis 目标。

并发标记周期

当老年代占用超过 -XX:InitiatingHeapOccupancyPercent(默认 45%)时触发。整个周期包含六个阶段:

  1. 初始标记(Initial Mark):STW,标记 GC Roots 直接可达的对象,通常搭在 Young GC 上完成。
  2. 根区域扫描(Root Region Scan):并发扫描 Survivor 中指向老年代的引用,此阶段不能发生 Young GC。
  3. 并发标记(Concurrent Mark):并发遍历整个堆,使用 SATB 记录引用变化。
  4. 重新标记(Remark):STW,处理 SATB 缓冲区中的引用,完成最终标记。
  5. 清理(Cleanup):STW 一小段,统计各 Region 存活比例并排序,回收完全空闲的 Region。
  6. 并发清理:重置 RSet,归还空闲 Region。

初始标记和重新标记是两次短 STW,并发标记可能持续数百毫秒甚至更久,但不暂停应用。

Mixed GC

并发标记完成后,G1 进入混合回收阶段。每次 Mixed GC 不仅回收新生代,还按「垃圾占比」从高到低挑选若干老年代 Region 一并回收。挑选数量受 -XX:G1MixedGCCountTarget(默认 8)和 -XX:G1HeapWastePercent(默认 5%)约束。

Mixed GC 会持续若干轮,直到老年代垃圾占比低于阈值,然后回到纯 Young GC 状态。

注意:G1 没有严格意义上的 Full GC。当 Mixed GC 赶不上分配速度、或者晋升失败时,会退化为单线程的 Serial Full GC,这是需要极力避免的场景。

可预测停顿模型

G1 的停顿预测基于「衰减平均值」:为每种回收类型维护一个历史停顿样本集,用最近若干次的加权平均值预测本次回收耗时。选择要回收的 Region 时,G1 按垃圾占比降序排序,依次累加,直到预测总时间接近 MaxGCPauseMillis 就停止。

这就是 G1 的贪心策略:在时间预算内,优先回收性价比最高的 Region。

但模型有前提:它假设回收耗时与 Region 的存活对象数量线性相关。如果某个 Region 里存活对象特别多,预测会失准。因此 MaxGCPauseMillis 是一个软目标,不是硬保证。

三个关键参数的相互作用

java -Xms8g -Xmx8g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:G1NewSizePercent=5 \
     -XX:G1MaxNewSizePercent=60 \
     -XX:InitiatingHeapOccupancyPercent=45 \
     -XX:G1HeapRegionSize=4m \
     -jar app.jar
  • MaxGCPauseMillis 调小,G1 会减少每次回收的 Region 数量,回收频率上升,总吞吐下降。
  • G1NewSizePercent 与 G1MaxNewSizePercent 限定新生代占比范围,默认 5% 到 60%。
  • InitiatingHeapOccupancyPercent 调小会提前触发并发标记,减少 Full GC 风险,但增加并发标记的 CPU 开销。

三者互相牵制,改一个必须观察另外两个的表现。

日志分析

JDK 9 起统一用 -Xlog:gc*,JDK 8 用 -XX:+PrintGCDetails。以 JDK 17 为例:

java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar

典型输出:

[2026-10-20T10:12:33.881+0800][12.345s][info][gc,start    ] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
[2026-10-20T10:12:33.892+0800][12.356s][info][gc,phases   ] GC(42)   Pre Evacuate Collection Set: 0.21ms
[2026-10-20T10:12:33.892+0800][12.356s][info][gc,phases   ] GC(42)   Evacuate Collection Set: 8.94ms
[2026-10-20T10:12:33.892+0800][12.356s][info][gc,phases   ] GC(42)   Post Evacuate Collection Set: 1.13ms
[2026-10-20T10:12:33.893+0800][12.357s][info][gc,heap       ] GC(42) Eden regions: 180->0(180)
[2026-10-20T10:12:33.893+0800][12.357s][info][gc,heap       ] GC(42) Survivor regions: 12->12(24)
[2026-10-20T10:12:33.893+0800][12.357s][info][gc,heap       ] GC(42) Old regions: 96->96
[2026-10-20T10:12:33.893+0800][12.357s][info][gc,heap       ] GC(42) Humongous regions: 3->3
[2026-10-20T10:12:33.893+0800][12.357s][info][gc,heap       ] GC(42) Metaspace: 42180K(43520K)->42180K(43520K)
[2026-10-20T10:12:33.893+0800][12.357s][info][gc          ] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 291M->111M(8192M) 12.102ms

字段含义:

  • GC(42):第 42 次 GC,编号连续递增。
  • Pause Young (Normal):普通 Young GC;若出现 (Concurrent Start) 表示搭车做了初始标记。
  • Evacuate Collection Set:复制存活对象的耗时,是停顿的主要构成。
  • Eden regions: 180->0(180):回收前 180 个、回收后 0 个,括号内是当前可用的总数。
  • Old regions: 96->96:老年代没有变化,说明这次只回收了新生代。
  • 291M->111M(8192M):回收前占用、回收后占用、堆总容量。

如果看到 Pause Full (G1 Compaction Pause),说明已经退化为 Full GC,需要立即排查。

JDK 8 与 JDK 17 的差异

| 项目 | JDK 8 | JDK 17 |

| --- | --- | --- |

| 默认收集器 | Parallel Scavenge + Parallel Old | G1 |

| 日志参数 | -XX:+PrintGCDetails | -Xlog:gc* |

| 字符串去重 | 需显式 -XX:+UseStringDeduplication | 同样需显式开启 |

| Humongous 回收 | 仅在并发标记清理阶段 | Young GC 可回收部分 |

| 废弃参数 | 可用 | -XX:+UseG1GC 仍可用但已非必需 |

| 偏向锁 | 默认开启 | 默认关闭(JDK 15 起) |

JDK 8 上使用 G1 需要显式 -XX:+UseG1GC,且早期版本(8u40 之前)的 G1 有较多稳定性问题,生产环境建议至少 8u191 以上。

常见坑 / 最佳实践

  1. 同时设置 -Xmn 和 MaxGCPauseMillis。-Xmn 会固定新生代大小,使 G1 无法动态调整,停顿预测模型直接失效。G1 下应使用 G1NewSizePercent / G1MaxNewSizePercent。
  2. MaxGCPauseMillis 设成 20 ms 以下。G1 会退化为每次只回收极少 Region,Young GC 频率飙升,吞吐断崖式下降。一般不低于 50 ms,延迟敏感场景再考虑 ZGC。
  3. 堆过大但 Region 太小。堆 64 GB 配默认 Region 时,Region 数量远超 2048,RSet 内存和扫描开销都会上升。堆超过 32 GB 时建议显式设置 G1HeapRegionSize。
  4. 大量 Humongous 对象。超过 Region 一半的对象会占用整个 Region,序列化大对象、大数组、大字符串是常见来源。应拆分或改用流式处理。
  5. 只看 GC 日志不看 safepoint。加 -Xlog:safepoint 可以发现非 GC 引起的长时间停顿,比如偏向锁撤销、Thread.sleep 之外的 safepoint 轮询。

小结

  • G1 用等大 Region 替代物理连续分代,逻辑上仍分 Eden、Survivor、Old、Humongous。
  • RSet 记录跨 Region 引用,使 Young GC 无需扫描整个老年代,代价是额外内存。
  • 回收周期由 Young GC、并发标记、Mixed GC 组成,没有严格意义的 Full GC,退化即事故。
  • 停顿预测基于历史样本和垃圾占比排序,MaxGCPauseMillis 是软目标。
  • JDK 9 起 G1 成为默认收集器,JDK 17 上无需再显式指定 -XX:+UseG1GC。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。