G1(Garbage-First)是 Java HotSpot 虚拟机自 JDK 7u4 起引入并在 JDK 9 中成为默认的服务器端垃圾回收器。它的核心设计目标是替代老旧的 CMS(Concurrent Mark Sweep),在大堆内存(>4GB ~ 几十GB甚至上百GB)的生产环境下,兼顾高吞吐量与软实时、可预测的低延迟(Soft Real-Time Low Latency)。
核心设计哲学与特性
传统的垃圾回收器(如 Serial、Parallel、CMS)在物理上将堆内存严格划分为固定且连续的新生代(Eden、Survivor)与老年代(Old)。而 G1 颠覆了这种传统的物理连续布局:
核心特性
- 分区管理(Region-Based Layout): 将连续的整个 Java 堆划分为约 2048 个大小相同的独立分区(Region),每个 Region 大小为 $1\text{MB} \sim 32\text{MB}$(必须是 2 的幂)。Region 在逻辑上依然扮演 Eden、Survivor、Old 的角色,但在物理上不再要求连续。
- 巨型对象区(Humongous Region): 如果一个对象大小超过单 Region 空间的一半($50%$),则被判定为巨型对象(Humongous Object),直接分配在连续的巨型 Region 中(巨型区在逻辑上归属老年代),避免频繁在大对象分配时造成 Eden 和 Survivor 的颠簸。
- 垃圾优先(Garbage-First): G1 会实时跟踪维护每个 Region 中垃圾对象的回收价值(回收所释放的空间与预期消耗的时间之比)。在回收时,根据用户设定的目标停顿时间,优先回收价值最大的 Region,这也是 G1 名称的由来。
- 可预测的停顿模型(Soft Real-time Pause Prediction): 允许用户配置期望的最大停顿时间(
-XX:MaxGCPauseMillis),G1 借助衰减均值(Decay Average)模型推算回收耗时,自适应决定本次回收多少个 Region。 - 基于复制算法无内存碎片: G1 在 Region 之间进行对象移动(Copying 算法),相当于在局部进行压缩整理,从根本上避免了 CMS 标记-清除(Mark-Sweep)带来的离散碎片问题。
核心数据结构与底层机制
Remembered Set(RSet 记忆集)
在跨 Region 分区收集时,若 Region-A 的对象引用了 Region-B 的对象,如何避免为了回收 Region-B 而对整个堆进行全量扫描?G1 引入了 RSet(Remembered Set)。
- 本质与存储: “谁引用了我”(Points-into)。每个 Region 都维护了一个私有的 RSet,记录了有哪些外部 Region 的哪些内存 Card(512 字节的 Card Table)指向了当前 Region。
- 写屏障(Post-Write Barrier): 当 JVM 执行引用赋值(如
objA.field = objB)时,JIT 编译器会插入写屏障指令,检查是否发生了跨 Region 引用;如果是,将引用所在的 Card 标记为 Dirty,并放入更新日志缓冲区(DCQ, Dirty Card Queue)。 - 并发细化线程(Concurrent Refinement Threads): 后台线程异步消费 DCQ 队列,将 Dirty Card 解析并合并写入目标 Region 的 RSet 中,避免了写屏障在业务主线程上的高额开销。
- 内存开销权衡: RSet 避免了全堆扫描,但其自身会占用大约堆内存 $5% \sim 15%$(甚至更多)的空间,这是典型的“空间换时间”工程权衡。
三色标记法与 SATB(Snapshot-At-The-Beginning)
在并发标记(Concurrent Marking)阶段,GC 线程与用户业务线程并发执行。为了保证在对象图动态变化时不发生漏标与误标,G1 结合了三色标记法与 SATB(原始快照):
- 对象三色状态:
- 白色(White): 未被 GC 访问过的对象。在标记周期结束时仍为白色的对象即为不可达垃圾。
- 灰色(Gray): 自身已被访问,但其直属引用的下游对象尚未全部扫描完毕(正在处理队列中)。
- 黑色(Black): 自身已被访问,且引用的所有下游对象全部扫描标记完毕(确定存活)。黑色对象绝不会直接指向白色对象,除非由于并发修改产生引用断裂。
- 漏标的核心成因(两个充分必要条件):
- 业务线程切断了灰色对象到白色对象的引用;
- 业务线程同时建立了一条黑色对象到该白色对象的新引用。
- SATB 解决漏标方案:
- CMS 采用增量更新(Incremental Update),破坏条件 2(记录黑色到白色的新引用,将黑色重新变灰)。
- G1 采用 SATB(原始快照),破坏条件 1:通过写前屏障(Pre-Write Barrier),在任何引用被覆盖或切断之前(如
x.f = null),把旧引用推入 SATB 缓冲区(SATB Buffer)。GC 认为并发标记开始时存活的对象在本次标记中都视为存活(逻辑快照),虽然可能产生少量浮动垃圾(Floating Garbage),但无需对黑色对象重新回溯扫描,极大缩短了 Remark 最终标记的 STW 时间。
G1 垃圾回收全生命周期
G1 的垃圾回收并不是单调重复的简单过程,而是由 Young GC、并发标记周期(Concurrent Marking Cycle)、Mixed GC 以及作为后备兜底的 Full GC 组成的自适应闭环:
Young GC(年轻代收集)
- 触发条件: 业务线程在 Eden 区分配对象失败,且年轻代 Region 总数达到动态上限时触发。
- 收集范围: 仅回收所有的 Eden 和 Survivor Region。
- 执行过程(全程 STW,但多线程并行):
- 根扫描(Root Scanning): 扫描栈帧本地变量、JNI 指针、静态变量等外部 GC Roots;
- 更新与扫描 RSet: 处理 DCQ 缓冲区未完成的卡表更新,扫描年轻代 Region 的 RSet,找出跨老年代引用;
- 对象复制(Evacuation): 将年轻代中依然存活的对象复制到空的 Survivor Region,满足年龄晋升条件的复制到 Old Region;
- 释放与重置: 清空原 Eden 和原 Survivor Region,将其重置为空闲 Region 回收到可用链表。
并发标记周期(Concurrent Marking Cycle)
当老年代空间占用达到堆总容量的 -XX:InitiatingHeapOccupancyPercent(默认 $45%$)时启动,包含五个阶段:
- 初始标记(Initial Mark, STW): 标记 GC Roots 能直接关联到的对象。通常借道(Piggybacked)在一次普通的 Young GC 中同步完成,借用 Young GC 的 STW 停顿,基本不增加额外延迟。
- 根区域扫描(Root Region Scanning, 并发): 扫描由初始标记中 Survivor 区对象引用的老年代对象。必须在下一次 Young GC 触发前完成。
- 并发标记(Concurrent Mark, 并发): 遍历整个堆的对象图,进行全局三色标记,同时处理 SATB 写前屏障记录的引用快照。
- 最终标记 / 重新标记(Remark, STW): 短暂 STW,清空并发阶段剩余的 SATB 缓冲区,完成最后漏标对象的修正。
- 清理阶段(Cleanup, 部分 STW):
- 统计每个 Region 的存活对象比例与回收价值;
- 完全空闲(没有存活对象)的老年代 Region 会在此时直接释放并重置,无需经过后续的复制拷贝;
- 准备可回收集合(CSet),为后续 Mixed GC 铺路。
Mixed GC(混合收集)
- 收集范围: 回收所有的年轻代 Region + 部分收益最高的老年代 Region。
- 分批执行机制: G1 不会一次性强行回收所有老年代 Region,而是拆分成多次 Mixed GC(由
-XX:G1MixedGCCountTarget控制,默认 8 次)。每轮回收中,优先选择垃圾比例最高(可回收比例超过-XX:G1MixedGCLiveThresholdPercent=85)的 Region,确保每次停顿控制在目标范围之内。
Full GC(兜底衰退)
当对象晋升速度远超过并发标记与 Evacuation 释放速度时,老年代空间被快速填满,或者没有足够连续的 Region 容纳大对象,便会触发 Evacuation Failure(To-space Exhausted),导致 G1 退化为 Full GC。
- 在 JDK 10 之前,G1 的 Full GC 是单线程串行(Serial Old)全堆标记整理,耗时极长(秒级甚至几十秒);
- 从 JDK 10 开始(JEP 307),G1 对 Full GC 进行了多线程并行化改造,但依然会发生全局长时间 STW,生产中应尽可能通过调优避免。
停顿时间控制原理(MaxGCPauseMillis)
用户通过 -XX:MaxGCPauseMillis=200(默认 200ms)设置最大停顿目标。G1 是如何做到尽量满足该目标的?
- 可预测耗时衰减模型: G1 内部记录了每次 GC 中各个阶段的历史耗时(如扫描一张卡表平均耗时、复制一个对象字节的平均耗时、更新 RSet 耗时)。通过历史数据的指数衰减加权均值(Decay Average),建立起严格的“耗时预测数学模型”。
- CSet(Collection Set,回收集)动态剪裁:
在决定本次 GC 收集哪些 Region 时:
$$\text{预估停顿时间} = \text{年轻代全部 Region 预估复制时间} + \sum \text{选中的老年代 Region 预估复制时间} + \text{固定基础设施耗时}$$
如果预估时间快达到
MaxGCPauseMillis,G1 会动态减少加入 CSet 的老年代 Region 数量,甚至动态收缩年轻代大小,从而保证停顿时间严格不超标。 - 调优认知误区:
[!IMPORTANT]
MaxGCPauseMillis只是一个软目标(Soft Target),并非硬性 SLA 保证。如果盲目将该值设得过低(例如 30ms),G1 为了达标会拼命缩减年轻代 Region 数量,导致年轻代极小,从而引发高频 Young GC,甚至导致对象快速溢出进入老年代而频繁触发 Full GC。通常推荐设置为 $100\text{ms} \sim 300\text{ms}$。
生产常见问题:Full GC 频繁原因与完整排查
Full GC(整堆与元空间回收)频繁触发的本质,是对象未能被年轻代有效过滤而过快进入老年代(或老年代空间耗尽),以及元空间、堆外内存不足或显式触发。在 G1 中,Full GC 会导致不可接受的全局长停顿,是系统可用性的重大隐患。
常见根因分类与防御
大对象(Humongous Object)分配过多或无序
- 机制: 对象大小超过单个 Region 50% 的对象会被判定为巨型对象,直接分配在连续的巨型 Region(属于老年代)。
- 典型场景: 未分页的超大 SQL 查询(如一次性查询几十万记录)、大文件或报表(Excel/CSV)非流式导出、超大 JSON/ProtoBuf 序列化。
- 后果: 巨型对象难以找到物理连续的 Region 块,直接导致内存碎片化加剧,极易触发 Full GC。
- 解法: 调大 Region 尺寸(
-XX:G1HeapRegionSize=16m或32m);代码重构为流式处理(如 EasyExcel、MyBatis 流式游标)。
内存泄漏(Memory Leak)
- 典型场景: 静态集合(
static Map/List)无上限缓存数据;ThreadLocal线程池复用未在finally中显式remove();未关闭的连接与监听器。 - 后果: 存活对象随时间推移全部堆积在老年代,每次 Full GC 回收收益几乎为 0(老年代水位居高不下)。
- 解法: 导出堆转储(Heap Dump),使用 MAT (Memory Analyzer Tool) 查看 Leak Suspects 报表与 GC Roots 强引用链。
- 典型场景: 静态集合(
对象过早晋升(Premature Promotion / Promotion Failure)
- 机制诱因:
- 动态年龄判定(Dynamic Tenuring): Survivor 区中相同年龄所有对象大小总和超过 Survivor 的 50%(
-XX:TargetSurvivorRatio)时,年龄大于或等于该年龄的对象直接提前晋升老年代,无需达到-XX:MaxTenuringThreshold。 - 突发流量与慢请求: 突发高并发下产生大量生命周期略长的临时对象(如耗时 2
3 秒的复杂 RPC/DB 调用上下文),对象跨越了 12 次 Minor GC,Eden 迅速填满且 Survivor 承载不下,触发分配担保提前甩入老年代。
- 动态年龄判定(Dynamic Tenuring): Survivor 区中相同年龄所有对象大小总和超过 Survivor 的 50%(
- 应对策略(三层防御):
- 业务与代码层(根治): 缩短请求链路 RT(优化慢 SQL、加缓存、异步并发调用),让请求上下文对象在 Eden 区快速消亡;瘦身 DTO/Context,避免大宽表实体进内存;评估高频字节数组或大缓冲区池化复用。
- JVM 参数调优(缓解):
- 针对 G1: 调高年轻代最小占比
-XX:G1NewSizePercent=20~30(默认 5%),预留弹性缓冲;适当放宽停顿目标-XX:MaxGCPauseMillis=200(避免停顿设太小导致 G1 主动压缩年轻代 Region 逼出过早晋升);调大 Region 尺寸-XX:G1HeapRegionSize=16m/32m防 Humongous 判定;下调-XX:InitiatingHeapOccupancyPercent=40提前启动 Mixed GC。 - 针对传统分代(CMS/Parallel): 调大年轻代
-Xmn或减小-XX:NewRatio=1;减小-XX:SurvivorRatio=4或6扩大 Survivor 物理容量;提高动态判定阈值-XX:TargetSurvivorRatio=80;提高最大晋升年龄-XX:MaxTenuringThreshold=15。
- 针对 G1: 调高年轻代最小占比
- 架构与网关层(兜底): 网关(Sentinel/Kong)配置并发线程数与令牌桶限流削峰;非实时/重批处理任务通过 MQ(RocketMQ/Kafka)异步削峰填谷;K8s 容器层配置基于 CPU/QPS 指标的 HPA 弹性伸缩。
- 机制诱因:
并发模式失败(Concurrent Mode Failure / Evacuation Failure)
- 如果对象分配速率远快于 G1 的并发标记清理速度,或者 Mixed GC 来不及腾出足够的可用 Region,当发生晋升失败(To-space Exhausted)时,G1 会被迫退化为全堆 STW 的 Full GC。
- 参数关联:
-XX:InitiatingHeapOccupancyPercent(IHOP) 设置过高,导致并发标记启动过晚。建议下调为35% ~ 40%。
元空间(Metaspace)满导致 Full GC
- 大量使用 CGLIB、动态代理、反射、Groovy 脚本动态编译加载类,导致类加载器及方法区元数据不断膨胀。
- 当元空间使用量达到阈值(
-XX:MetaspaceSize)且需要扩张时,JVM 会强制触发一次 Full GC 尝试卸载无用的 ClassLoader。 - GC 日志特征:包含
Metadata GC Threshold。建议显式配置足够大的元空间阈值,如-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m。
代码显式调用 System.gc() 或堆外直接内存不足
- 业务代码或早期第三方库隐式调用
System.gc()(日志中含System.gc())。配置-XX:+DisableExplicitGC彻底禁用。 - Netty / NIO 频繁申请
DirectByteBuffer且未及时释放,堆外直接内存不足时触发Bits.reserveMemory()内部调用System.gc()回收堆外资源。
- 业务代码或早期第三方库隐式调用
排查与定位链路
1. GC 日志与监控定位
├── 查看触发原因:Allocation Failure / System.gc() / Metadata GC Threshold / To-space Exhausted
└── 观察老年代水位变化:回收后水位骤降(大对象/瞬时突发)还是居高不下(内存泄漏)
2. 线上实时排查命令
├── jstat -gcutil <pid> 1000 10 (查看 Eden/Survivor/Old/Metaspace 使用率及 YGC/FGC 次数与耗时)
├── jmap -histo:live <pid> | head -n 20 (查看存活对象排行榜,快速锁定 byte[]、String 或高危业务实体)
└── jcmd <pid> GC.class_histogram
3. 离线深度定位(定位代码行)
├── 导出堆转储:jmap -dump:format=b,file=heap.hprof <pid>(生产注意先切走流量)
└── 工具分析:使用 Eclipse MAT 或 JProfiler 查看 Leak Suspects、GC Roots 引用链与支配树 (Dominator Tree)
G1 vs CMS 核心对比与选型
| 比较维度 | CMS 垃圾回收器 | G1 垃圾回收器 |
|---|---|---|
| 内存物理模型 | 固定且连续的物理分代(Eden/Survivor/Tenured) | 离散的 Region 分区(每个 Region 逻辑扮演不同角色) |
| 回收算法 | 老年代使用标记-清除(Mark-Sweep),新生代使用复制 | 老年代使用标记-整理/复制(Copying),全局无离散碎片 |
| STW 停顿控制 | 无法设置目标,停顿时间不可预测 | 支持 -XX:MaxGCPauseMillis 软实时目标与预测模型 |
| 大对象处理 | 直接进入连续的老年代空间 | 专门的连续 Humongous Region,针对性收集 |
| 并发漏标处理 | 增量更新(Incremental Update),破坏黑色到白色引用 | 原始快照(SATB),破坏灰色到白色引用断开,Remark 更快 |
| 额外内存开销 | 仅依赖卡表(Card Table),内存开销小(约 $1%$ 堆大小) | 每个 Region 维护独立 RSet,内存额外开销大($5% \sim 15%$) |
| 适用堆内存范围 | 适合小堆与中等堆($< 4\text{GB} \sim 6\text{GB}$) | 适合大堆($\ge 6\text{GB} \sim$ 上百 GB) |
生产推荐配置与调优指南
在生产环境中,使用 G1 遵循 “尽量减少手动干预,信任 G1 自适应机制” 的原则,通常只需配置以下核心参数:
# 1. 基础堆与垃圾回收器指定
-XX:+UseG1GC
-Xms8g -Xmx8g
# 2. 停顿时间目标(最核心参数,切勿设置过于极端)
-XX:MaxGCPauseMillis=200
# 3. 避免 Humongous 碎片与提升单 Region 容量(8G+ 堆推荐 4MB~16MB)
-XX:G1HeapRegionSize=8m
# 4. 解决突发流量过早晋升:调大年轻代保底比例(默认 5% 容易被突发打爆)
-XX:G1NewSizePercent=20
-XX:G1MaxNewSizePercent=60
# 5. 提前启动老年代并发标记,预防 Evacuation Failure(默认 45%)
-XX:InitiatingHeapOccupancyPercent=40
# 6. 禁用显式 System.gc()
-XX:+DisableExplicitGC
# 7. GC 日志配置(JDK 8 语法)
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=100M
# 注:若在 JDK 9+ 请采用 Unified JVM Logging:-Xlog:gc*,gc+phases=debug:file=/var/log/app/gc.log:time,uptime,pid:filecount=5,filesize=100M