G1 收集器原理
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%)时触发。整个周期包含六个阶段:
- 初始标记(Initial Mark):STW,标记 GC Roots 直接可达的对象,通常搭在 Young GC 上完成。
- 根区域扫描(Root Region Scan):并发扫描 Survivor 中指向老年代的引用,此阶段不能发生 Young GC。
- 并发标记(Concurrent Mark):并发遍历整个堆,使用 SATB 记录引用变化。
- 重新标记(Remark):STW,处理 SATB 缓冲区中的引用,完成最终标记。
- 清理(Cleanup):STW 一小段,统计各 Region 存活比例并排序,回收完全空闲的 Region。
- 并发清理:重置 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 以上。
常见坑 / 最佳实践
- 同时设置
-Xmn和MaxGCPauseMillis。-Xmn会固定新生代大小,使 G1 无法动态调整,停顿预测模型直接失效。G1 下应使用G1NewSizePercent/G1MaxNewSizePercent。 MaxGCPauseMillis设成 20 ms 以下。G1 会退化为每次只回收极少 Region,Young GC 频率飙升,吞吐断崖式下降。一般不低于 50 ms,延迟敏感场景再考虑 ZGC。- 堆过大但 Region 太小。堆 64 GB 配默认 Region 时,Region 数量远超 2048,RSet 内存和扫描开销都会上升。堆超过 32 GB 时建议显式设置
G1HeapRegionSize。 - 大量 Humongous 对象。超过 Region 一半的对象会占用整个 Region,序列化大对象、大数组、大字符串是常见来源。应拆分或改用流式处理。
- 只看 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。
- 点赞
- 收藏
- 关注作者
评论(0)