Java 基础体系 · 第 74/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
JVM G1 GC 深入:Region、Remembered Set、暂停目标和调优
G1(Garbage-First)是 HotSpot 中面向较大堆、可预测停顿的分区式垃圾收集器。它把 Java 堆划分为大量大小相同的 Region,再根据每个 Region 的垃圾收益和预计处理成本选择回收集合,因此不再严格按照“整个新生代”“整个老年代”回收。
这里的“可预测”不是指暂停时间有硬性上限,而是指 G1 会把暂停目标纳入集合选择和线程配置的启发式模型中。实际暂停仍可能受对象复制量、Remembered Set(记忆集)大小、并发阶段、分配速度、机器资源和对象存活率影响。
需要先区分规范与实现:
- JVM 规范规定了对象、堆、线程和执行语义,但不规定某一种 GC 算法。
- G1 的 Region、Remembered Set、暂停预测和大多数
-XX参数属于 HotSpot 实现。 - Java 25 LTS 使用的具体默认值和内部启发式可能随补丁版本、硬件和容器资源变化,应通过
PrintFlagsFinal、GC 日志和 JFR 验证,而不是把某个实现细节当成 Java 语言保证。
1. G1 要解决什么问题
传统分代收集器通常把堆划分为:
年轻代:Eden + Survivor
老年代
一次 Minor GC 主要处理年轻代;一次老年代回收则可能涉及更大范围的对象。这样的布局简单,但当堆很大时,老年代回收可能产生较长的 Stop-The-World(STW)暂停。
G1 的核心变化是:
整个堆
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ R0 │ R1 │ R2 │ R3 │ R4 │ R5 │ R6 │ R7 │ ...
└────┴────┴────┴────┴────┴────┴────┴────┘
每个 Region 在某个时刻可以扮演不同角色:
- Eden Region;
- Survivor Region;
- Old Region;
- Humongous Region;
- 空闲 Region。
Region 的物理位置不等于对象的逻辑代际。G1 可以在一次暂停中同时回收部分年轻 Region 和部分老 Region,这类回收称为 Mixed GC。
“Garbage-First”的含义是:在满足暂停预算的前提下,优先选择预计能释放更多空间的 Region,而不是简单按地址顺序扫描老年代。
2. Region:G1 的堆布局单位
2.1 Region 大小
G1 将堆划分为固定大小的 Region。当前 HotSpot 实现通常根据最大堆大小自动选择一个 2 的幂次大小,常见范围为 1 MiB 到 32 MiB,并尽量使整个堆包含大约 2048 个 Region。
例如,假设:
-Xmx8g
如果选择 4 MiB Region,则大约有:
8 GiB / 4 MiB = 2048 个 Region
也可以显式指定:
-XX:G1HeapRegionSize=4m
这个参数必须在 JVM 启动时设置,不能运行期间调整。
Region 太小和太大各有代价:
- Region 太小:
- Region 数量增多;
- Region 管理元数据和 Remembered Set 数量增加;
- 大对象更容易跨越多个 Region。
- Region 太大:
- 单个 Region 内可能包含更多存活对象;
- 一次回收处理一个 Region 的工作量更大;
- Humongous 对象的最小占用粒度变粗。
G1HeapRegionSize 不是“越小越容易控制暂停”。暂停时间还取决于扫描卡表、更新 Remembered Set、复制存活对象和引用处理等工作。
可以用以下命令查看最终生效值:
java -XX:+UseG1GC -XX:+PrintFlagsFinal -version \
| grep -E 'UseG1GC|G1HeapRegionSize|MaxGCPauseMillis'
其中:
:=通常表示命令行显式设置;=通常表示使用默认值或人体工学计算值;- 具体输出格式应以当前 JDK 为准。
2.2 Region 的生命周期
Region 并不是永久属于某一代。一个典型生命周期如下:
空闲 Region
│
├── 分配新对象
▼
Eden Region
│ Minor/Young GC
▼
Survivor Region
│ 多次存活或达到晋升条件
▼
Old Region
│ Mixed GC
▼
空闲 Region
这意味着“年轻代大小”不是一段固定的连续地址区间,而是当前被 G1 选作 Eden 的 Region 集合。
G1 通常会根据应用分配速率、回收速度和暂停目标动态调整年轻代 Region 数量。显式设置 -Xmn 或 -XX:NewRatio 会限制 G1 的调节空间,通常不应把传统分代收集器的固定年轻代思路直接套用到 G1。
3. G1 的对象分配与复制
3.1 TLAB 与 Eden Region
线程通常优先从 TLAB(Thread-Local Allocation Buffer)中分配对象。TLAB 位于 Eden Region 内,线程可以在不竞争全局分配锁的情况下快速完成小对象分配。
当 TLAB 用完时,线程会申请新的 TLAB;当当前 Eden Region 无法满足分配时,G1 会获取新的空闲 Region。若没有足够的空闲 Region,可能触发更急迫的 GC,甚至出现分配失败或 Full GC。
3.2 Young GC 的基本过程
假设堆中有:
Eden:E1、E2、E3
Survivor:S1
Old:O1、O2
一次 Young GC 大致经历:
- 停止 Java 线程;
- 扫描 GC Roots;
- 处理从 Old 指向 Eden 的引用;
- 找出 Eden 和当前 Survivor 中仍然存活的对象;
- 将存活对象复制到新的 Survivor 或 Old Region;
- 更新对象引用;
- 释放原来的 Eden Region;
- 恢复 Java 线程。
复制完成后,原 Region 不需要逐对象清除,因为其中的对象已经被转移,整个 Region 可以作为空闲空间重新使用。
3.3 为什么 G1 需要复制
G1 采用疏散(Evacuation)方式回收 Region。它不只标记垃圾,还会把存活对象移动到目标 Region,从而同时完成压缩,降低堆碎片。
但复制有一个直接代价:
暂停工作量 ≈ 需要扫描的引用 + 需要复制的存活对象 + 引用更新
因此,一个“垃圾很多”的 Region 不一定难以回收;真正昂贵的可能是一个垃圾很少、存活对象很多的 Region。
4. Remembered Set:为什么回收一个 Region 不必扫描整个堆
4.1 跨 Region 引用问题
假设:
Region A 中有对象 a
Region B 中有对象 b
a.field = b
现在 G1 想回收 Region B。为了判断 b 是否仍然存活,必须找到所有指向 B 中对象的引用,包括位于 Region A 中的 a.field。
最直接的方法是扫描整个堆,但这会使一次局部回收重新退化为全堆扫描。
因此,G1 为每个 Region 维护 Remembered Set,简称 RSet。对于目标 Region B,RSet(B) 记录“哪些其他 Region 可能包含指向 B 的引用”。
于是回收 B 时,处理范围可以缩小为:
GC Roots
+ RSet(B) 指出的外部引用位置
+ B 内部对象
RSet 是“按被指向方组织”的反向索引:
A 中的引用 ───────► B 中的对象
▲
│
RSet(B) 记录 A
4.2 RSet 的信息来源
HotSpot 通常以 Card Table(卡表)为基础记录引用写入。堆会被划分为固定大小的 Card,每张 Card 对应一小段内存。
当代码执行:
source.field = target;
如果这个写入可能形成跨 Region 引用,写屏障会把对应 Card 标记为脏(dirty)。后台的 RSet 更新线程随后处理这些脏 Card,找出其中可能指向其他 Region 的引用,并更新目标 Region 的 RSet。
逻辑数据流可以表示为:
flowchart LR
A[Java 线程写入引用] --> B[写屏障标记 Card]
B --> C[脏 Card 队列]
C --> D[RSet 更新线程]
D --> E[扫描 Card 中的引用]
E --> F[更新目标 Region 的 RSet]
F --> G[GC 暂停时扫描 RSet]
这里存在一个重要的并发取舍:
- Java 线程负责快速记录写入;
- RSet 更新可以部分并发进行;
- GC 暂停时仍需处理尚未完成的脏 Card,或者使用保守信息保证正确性。
RSet 不是一个简单的“每个对象都精确列出反向引用”的 Java 集合。为了控制空间和更新时间,HotSpot 会使用不同粒度的表示,例如稀疏、细粒度和粗粒度形式。粒度变粗后,GC 可能扫描比实际需要更多的 Card,但不能因此漏掉可能存在的引用。
所以 RSet 的典型特征是:
- 允许冗余;
- 允许过期或保守信息;
- 不能漏掉必要的跨 Region 引用;
- 可能产生额外扫描成本。
4.3 RSet 为什么会导致暂停变长
考虑两个目标 Region:
Region B:
- 实际只有 10 个外部引用指向它
- RSet 只需扫描少量 Card
Region C:
- 实际只有 10 个外部引用
- 但大量 Card 被标记,RSet 变粗
- GC 需要扫描很多 Card 才能确认引用
两者存活对象数量相同,Region C 的 RSet 处理成本却可能更高。
因此,G1 的暂停时间不只由“要回收多少 Region”决定,还取决于:
- 这些 Region 的 RSet 大小;
- RSet 的表示粒度;
- 需要扫描的 Card 数量;
- 从这些 Card 中发现的跨 Region 引用数量;
- 跨 Region 引用最终指向多少存活对象。
这也是为什么“减少 Region 数量”或“单纯增大堆”不能自动解决所有暂停问题。
5. 写屏障、SATB 与并发标记
G1 的并发标记通常基于 SATB(Snapshot-At-The-Beginning,初始快照)思想。
5.1 并发标记的目标
G1 需要知道老年代 Region 中哪些对象在并发标记周期开始时仍然可达,以便之后决定哪些老 Region 值得纳入 Mixed GC。
并发标记期间,Java 线程仍在修改对象图:
标记开始时:A ─► B
标记进行中:A.field 被改为指向 C
如果只依赖并发线程当前看到的对象图,可能漏掉标记开始时仍然存活、但后来引用关系发生变化的对象。
SATB 的思路是:记录被覆盖掉的旧引用,使并发标记近似处理“标记开始瞬间的对象图”。
5.2 两类不同的写屏障职责
G1 中常见的两类屏障职责可以概括为:
- SATB 前置屏障:在并发标记期间记录被覆盖的旧引用;
- 后置写屏障:记录可能形成跨 Region 引用的写入,以维护 Card 和 RSet 相关信息。
两者不要混淆:
| 机制 | 解决的问题 |
|---|---|
| SATB | 并发标记时避免遗漏标记开始时的可达对象 |
| Card/RSet 写屏障 | 回收某个 Region 时快速找到来自其他 Region 的引用 |
写屏障会增加每次引用写入的成本,但这是用运行时写入开销换取 GC 时不扫描整个堆的典型设计。
6. G1 的主要阶段与状态变化
G1 的回收过程不是每次都完整执行所有阶段。一个简化状态图如下:
stateDiagram-v2
[*] --> YoungOnly
YoungOnly --> ConcurrentMarkStart: 堆占用达到启动条件
ConcurrentMarkStart --> ConcurrentMark
ConcurrentMark --> Remark
Remark --> Cleanup
Cleanup --> Mixed
Mixed --> Mixed: 继续处理老 Region
Mixed --> YoungOnly: 可回收老 Region 不足或完成
YoungOnly --> FullGC: 疏散失败或空间压力过大
FullGC --> YoungOnly
6.1 Young-only 阶段
只回收年轻代 Region,主要目标是:
- 回收 Eden;
- 晋升或复制存活对象;
- 控制年轻代大小;
- 使分配继续进行。
6.2 并发标记周期
当老年代占用达到某个启动条件后,G1 启动并发标记。一个概念化流程是:
- Initial Mark:通常借助一次 STW 暂停开始标记;
- Root Region Scan:扫描与根相关的区域;
- Concurrent Mark:与应用线程并发标记;
- Remark:再次 STW,完成 SATB 处理和最终标记;
- Cleanup:整理标记结果,识别可回收 Region;
- 后续进行 Mixed GC。
不同 JDK 版本和实现细节可能调整具体阶段和日志名称,但“并发标记用于识别老年代回收候选,Mixed GC 负责实际疏散其中一部分”这一关系是理解 G1 的关键。
6.3 Mixed GC
Mixed GC 不是“只回收老年代”,而是通常同时包含:
部分 Eden Region
+ 部分 Survivor Region
+ 一部分已完成标记、值得回收的 Old Region
G1 会根据暂停预测决定本次纳入多少老 Region。老 Region 不会因为完成标记就全部一次性回收,否则很容易超出暂停目标。
7. 暂停目标:预算,不是承诺
7.1 MaxGCPauseMillis 的含义
可以设置:
-XX:MaxGCPauseMillis=200
它表示期望的 GC 暂停目标,单位为毫秒。它不是操作系统级别的硬上限,也不保证每次暂停都小于 200 ms。
G1 会把这个目标用于启发式决策,例如:
- 选择多少年轻代 Region;
- Mixed GC 中加入多少老 Region;
- 使用多少并行 GC 线程;
- 是否推迟或扩大某些回收工作。
目标越小,G1 越倾向于限制单次暂停工作量,但可能产生以下副作用:
- 单次回收释放的空间减少;
- 需要更多次 GC;
- 浮动垃圾和保留空间压力增大;
- 并发标记、RSet 更新或应用分配可能跟不上;
- 最终出现更频繁的回收,甚至 Full GC。
目标越大,单次暂停可以做更多工作,吞吐量可能更好,但尾延迟可能变差。
7.2 一个简化的暂停预测模型
G1 的真实内部模型包含多个阶段和动态修正,不能用一个公开固定公式完全描述。为了理解集合选择,可以使用下面的抽象模型:
设候选 Region 集合为 ,每个 Region 有:
- :预计需要处理的存活对象或复制工作量;
- :预计 RSet/Card 扫描工作量;
- :预计释放的空间;
- :预计处理时间。
可以抽象为:
其中:
- 表示复制或对象处理速度的倒数;
- 表示 RSet 扫描成本;
- 表示固定阶段开销。
一次暂停选择集合 时,希望满足:
同时尽量最大化收益:
这里:
- 是暂停目标附近的预算;
- 是根处理、固定阶段和其他开销;
- 是回收 Region 后可释放的空间。
这不是 HotSpot 的公开精确算法,而是解释“垃圾多且处理便宜的 Region 更优先”的形式化模型。
7.3 完整算例
假设一次 Mixed GC 的固定开销预计为 8 ms,目标暂停为 50 ms,因此可用于 Region 处理的预算约为:
有四个候选 Region:
| Region | 预计处理时间 | 预计释放空间 |
|---|---|---|
| A | 8 ms | 3.5 GiB |
| B | 12 ms | 3.0 GiB |
| C | 20 ms | 4.0 GiB |
| D | 25 ms | 4.5 GiB |
如果只按释放空间排序,会先选 D、C:
这组集合超过预算。
选择 A、B、C:
预计释放:
选择 A、B、D:
同样超过预算。
在这个简化模型中,A、B、C 是满足预算且收益较高的集合。D 虽然释放空间更多,但它的存活对象多或 RSet 扫描成本高,单位时间收益不一定更优。
真实 G1 还会考虑:
- 年轻代必须及时回收;
- 是否有足够的 To-Space;
- 老 Region 回收的进度;
- 分配速率;
- 标记结果是否已经完成;
- 历史暂停数据;
- 并发线程和 CPU 资源。
因此不能根据这个算例反推 HotSpot 的精确选区结果。
7.4 为什么会超过目标
下面几类情况会使实际暂停明显超过目标:
-
存活对象估计偏低
预测的复制量小于实际复制量。 -
RSet 更新滞后
暂停中需要处理积累的脏 Card。 -
对象复制失败
没有足够的 To-Space,进入更重的处理路径。 -
Humongous 对象占用空间
大对象造成可用连续 Region 不足。 -
CPU 被其他任务争抢
GC 线程的实际运行速度低于历史模型。 -
应用分配速度突增
在并发标记或回收尚未完成前,堆空间快速消耗。
所以暂停目标适合用作“控制方向”,不适合作为严格 SLO 的唯一保障。
8. Humongous 对象:Region 机制的特殊边界
G1 将大小达到一个 Region 一半及以上的对象视为 Humongous 对象。
如果 Region 大小为 4 MiB,则对象达到约 2 MiB 时就可能进入 Humongous 分配路径。
Humongous 对象通常从 Region 边界开始,并占用连续的一个或多个 Region。例如:
Region 大小:4 MiB
对象大小:3 MiB
占用:1 个 Region
对象大小:9 MiB
占用:3 个连续 Region
最后一个 Region 可能只有部分空间被对象使用,但通常不能像普通小对象那样灵活复用剩余部分。
Humongous 对象的问题包括:
- 占用连续 Region;
- 可能造成 Region 碎片化;
- 大对象分配可能直接触发 GC;
- 大对象存活时复制和回收成本高;
- 对象释放后产生的空间粒度较粗。
生产诊断时不能只看 Eden 和 Old 占用,也应关注 Humongous Region。GC 日志中的堆摘要通常能显示各类 Region 的使用情况。
如果应用频繁创建接近 Region 一半大小的数组、缓冲区或序列化临时对象,应重点检查:
- 对象实际大小分布;
- 是否可以复用缓冲区;
- 是否可以采用分块结构;
- Region 大小改变后是否真的改善了分配和回收;
- 是否只是把对象从普通分配推入了 Humongous 路径。
盲目增大 Region 可能降低 Region 数量,但也会提高 Humongous 判定阈值并改变大对象占用粒度;盲目减小 Region 则可能让更多对象进入 Humongous 路径。
9. 空间安全:To-Space、保留空间与 Evacuation Failure
9.1 To-Space
疏散回收需要同时存在:
From-Space:正在被扫描的旧 Region
To-Space:用于存放存活对象的新 Region
如果应用中存活对象很多,或者堆中没有足够的空闲 Region,G1 可能无法完成全部复制。
这类情况常被称为 Evacuation Failure。它不是普通的“这次 GC 效率低”,而是说明回收过程中可用复制空间不足,后续可能需要更保守的处理,甚至触发 Full GC。
9.2 G1ReservePercent
可以设置:
-XX:G1ReservePercent=15
该参数用于为 G1 保留一部分堆空间,帮助疏散阶段应对存活对象复制需求。它不是“额外增加堆大小”,而是限制可被普通分配消耗的空间比例。
增加保留比例的代价是:
- 应用可直接使用的堆空间减少;
- 更早触发 GC;
- 如果根因是堆本身太小,单独调高该值不能解决问题。
降低保留比例则可能提高短期可用容量,却增加疏散失败风险。应结合 GC 日志确认是否存在 To-Space 或分配失败迹象。
10. 什么时候开始并发标记
G1 需要在老年代压力过高之前完成并发标记和 Mixed GC,否则可能出现:
老年代持续增长
→ 可回收老 Region 尚未准备好
→ 空闲 Region 减少
→ Young GC 仍然需要分配空间
→ 疏散空间不足
→ Full GC 风险上升
10.1 InitiatingHeapOccupancyPercent
可以显式设置启动占用比例:
-XX:InitiatingHeapOccupancyPercent=45
它表示并发标记启动相关的堆占用阈值。G1 还可能使用自适应 IHOP(Initiating Heap Occupancy Percent)预测,根据历史并发标记时间和应用分配速率动态调整启动时机。
固定阈值和自适应策略各有边界:
- 阈值过高:
- 更晚启动标记;
- 留给并发标记和 Mixed GC 的时间变少;
- 高分配速率下可能来不及。
- 阈值过低:
- 标记更早、更频繁;
- 并发 CPU 和内存开销增加;
- 可能在并不需要时就启动周期。
如果问题表现为老年代快速增长、Mixed GC 来得太晚,应先确认:
- 并发标记是否按预期启动;
- 标记周期耗时多久;
- 应用在一个标记周期内分配了多少对象;
- Mixed GC 是否释放了足够空间。
单纯降低 IHOP 可能缓解时序问题,但如果根因是存活对象过多或泄漏,它不能消除根因。
11. GC 日志:从现象还原因果链
Java 25 使用统一日志系统记录 GC。一个适合诊断的启动示例:
java \
-Xms8g -Xmx8g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-Xlog:gc*,gc+heap=info,gc+phases=debug,gc+ergo=trace:file=/var/log/app-gc.log:time,uptime,level,tags:filecount=5,filesize=50M \
-jar app.jar
各部分作用:
-Xms8g -Xmx8g:示例中固定堆大小,减少运行期间堆扩缩容变量;-XX:+UseG1GC:显式指定 G1,便于实验可重复;-XX:MaxGCPauseMillis=100:设置暂停目标,不是硬上限;gc*:记录主要 GC 信息;gc+heap=info:观察各类堆区域变化;gc+phases=debug:观察暂停内部阶段;gc+ergo=trace:观察人体工学决策;filecount、filesize:限制日志文件滚动规模。
日志中的一条 Young GC 可能类似:
Pause Young (Normal) (G1 Evacuation Pause)
这说明发生了 STW 疏散暂停,但不代表只复制了少量对象。应继续查看:
- 暂停总耗时;
- Eden 回收前后;
- Survivor 和 Old 的变化;
- RSet 扫描时间;
- Object Copy 时间;
- Termination 时间;
- 是否出现
to-space exhausted或evacuation failure。
如果看到 Mixed GC,应关注:
Pause Young (Mixed) (G1 Evacuation Pause)
这表示一次年轻代疏散暂停中包含老 Region,而不是一个完全独立于年轻代的老年代暂停。
11.1 一个简化的日志解读例子
假设连续几次日志呈现:
Pause Young ... 70ms
Scan RS: 35ms
Object Copy: 8ms
Pause Young ... 85ms
Scan RS: 48ms
Object Copy: 9ms
这里暂停增长主要来自 Scan RS,而不是对象复制。此时直接减少存活对象或调整 Survivor 参数未必有效,应进一步检查:
- 跨 Region 引用是否过多;
- RSet 更新是否积压;
- 应用是否频繁修改跨 Region 数据结构;
- GC 线程是否获得足够 CPU;
- Region 大小调整是否降低了 RSet 管理成本。
另一个例子:
Pause Young ... 130ms
Scan RS: 10ms
Object Copy: 95ms
这更像是存活对象复制成本过高。可能原因包括:
- Eden 太大;
- 当前年轻代存活率过高;
- 大量对象快速晋升;
- 堆中有突发流量;
- 目标暂停设置过紧,导致工作被切分后仍然无法及时完成。
日志必须看阶段拆分,不能只看 GC 类型。
12. 使用 jcmd 和 JFR 进行运行中诊断
12.1 jcmd
首先查找 Java 进程:
jcmd
查看堆概况:
jcmd <pid> GC.heap_info
查看 JVM 参数:
jcmd <pid> VM.flags
查看类实例直方图:
jcmd <pid> GC.class_histogram
GC.class_histogram 可能触发较重的操作,具体影响取决于 JDK 实现和选项,不应在高峰期频繁执行。它适合在确认风险可接受时用于回答“哪些类占用了最多实例和空间”,不适合替代长期 GC 观测。
12.2 JFR 与 JDK Mission Control
JFR(Java Flight Recorder)可以记录 GC 暂停、堆占用、分配、线程和 CPU 等事件,再用 JDK Mission Control 分析。
一种示例方式:
jcmd <pid> JFR.start \
name=g1-diagnose \
settings=profile \
duration=10m \
filename=/tmp/g1-diagnose.jfr
命令的前置条件是:
- 进程用户权限允许执行
jcmd; - 目标 JVM 支持 JFR;
- 文件路径有写权限;
- 生产环境需要评估
profile配置带来的记录开销。
结束后用 JDK Mission Control 打开 .jfr 文件,重点对照:
- GC Pause 时间线;
- GC 原因;
- 堆占用变化;
- Allocation in New TLAB / outside TLAB;
- CPU 使用率;
- GC 线程运行情况;
- 并发标记周期是否被应用分配速度追上。
GC 日志适合精确查看 G1 内部阶段,JFR 适合把 GC 与应用分配、线程调度和 CPU 争用放到同一时间轴上。两者应结合使用。
13. 调优参数的因果关系
13.1 先确认是否真的需要换参数
启动时可查看关键参数:
java -XX:+PrintFlagsFinal -version \
| grep -E \
'UseG1GC|MaxGCPauseMillis|G1HeapRegionSize|G1ReservePercent|InitiatingHeapOccupancyPercent|ParallelGCThreads|ConcGCThreads'
先确认:
- 实际使用的收集器;
- 实际 Region 大小;
- 暂停目标;
- 是否显式覆盖了人体工学参数;
- 容器 CPU 和内存是否被 JVM 正确识别。
没有这些事实,直接修改多个参数会导致因果关系不可辨认。
13.2 堆大小
增加堆可能降低 GC 频率,因为可容纳更多短命对象,也给并发标记和 Mixed GC 留出更多时间。但它不能自动降低每次暂停:
- 更大的年轻代可能增加单次存活对象复制量;
- 更大的老年代可能增加标记范围;
- 更大的 Region 或更多对象可能增加 RSet 与标记元数据;
- 如果存活率高,增加堆只是延迟问题暴露。
-Xms 和 -Xmx 固定为相同值有助于稳定实验,但不等于所有生产环境都必须固定。容器环境还要为 Metaspace、线程栈、代码缓存、直接内存和 GC 元数据预留进程外内存。
13.3 暂停目标
建议把 MaxGCPauseMillis 当作约束条件,而不是性能承诺。设置明显低于应用实际可达到的水平,可能使 G1 频繁切分工作,造成更高的 GC 总时间和更大的分配压力。
调优时应同时观察:
暂停分位数
GC 总 CPU 时间
应用吞吐
分配速率
Old 占用趋势
Full GC 次数
只优化平均暂停而忽略 P99 或 Full GC,可能得到错误结论。
13.4 并行和并发线程
G1 同时包含:
- STW 阶段的并行 GC 工作线程;
- 并发标记线程;
- RSet 更新和相关后台线程。
CPU 不足时,提高线程数可能造成应用与 GC 争抢 CPU;CPU 充足且暂停中并行阶段耗时较高时,适度增加并行资源才可能有效。
不要仅因为“GC 慢”就直接设置:
-XX:ParallelGCThreads=32
-XX:ConcGCThreads=16
应先确认:
- 机器实际可用 CPU;
- 容器 CPU 配额;
- GC 阶段是并行扫描、复制,还是等待并发任务;
- 应用线程是否已经 CPU 饱和。
13.5 年轻代大小
G1 会动态调节年轻代。显式固定年轻代可能适用于非常明确的实验或特殊负载,但常见风险是:
- 年轻代过大:单次 Young GC 处理量大,暂停变长;
- 年轻代过小:GC 过于频繁,写屏障和根处理开销增加;
- 年轻代固定后:G1 无法根据分配速率和暂停目标调整。
应该先用 GC 日志判断是“频率过高”还是“单次太重”,再决定是否干预。
13.6 Region 大小
修改 G1HeapRegionSize 的理由应当来自具体证据,例如:
- Humongous 对象比例异常;
- Region 数量导致管理开销明显;
- 单个 Region 的存活对象处理量与暂停目标不匹配。
改变 Region 大小会同时改变:
- Humongous 判定阈值;
- Region 数量;
- RSet 组织粒度;
- 复制任务大小;
- 可回收空间的最小粒度。
因此必须在代表性负载下对比完整指标,而不是只比较一次 Full GC 前后的堆占用。
14. 常见误解与反例
14.1 “G1 保证每次暂停不超过 MaxGCPauseMillis”
错误。它是目标值,G1 会尽量预测和控制,但实际工作量可能超出预测,或者发生疏散失败、CPU 争用、并发周期赶不上分配等异常路径。
如果业务要求严格暂停上限,应在应用架构、流量控制、堆大小、对象生命周期和 GC 选择上共同设计,不能只依赖一个参数。
14.2 “G1 是完全并发的,不会停止应用”
错误。G1 的部分标记和整理工作可以并发,但 Young GC、Mixed GC、Initial Mark、Remark 等阶段包含 STW 工作。
正确理解是:
并发标记降低了长时间全堆 STW 的需求
≠
整个 GC 过程没有 STW
14.3 “Region 越小越好”
错误。小 Region 增加 Region 数量和管理结构,也可能让接近阈值的对象进入 Humongous 路径。大 Region 则可能增加单个回收单元的工作量。
Region 大小是堆布局参数,不是单独的暂停开关。
14.4 “Old 占用高就一定是内存泄漏”
错误。Old 占用高可能来自:
- 长生命周期缓存;
- 正常晋升;
- Mixed GC 尚未开始;
- Mixed GC 回收速度低于分配速度;
- Humongous 对象;
- 真正的对象保留或泄漏。
应将 Old 占用曲线、Full GC 后存活基线、类直方图、分配热点和业务缓存策略结合分析。
14.5 “减少对象分配就一定能降低暂停”
不一定。减少短命对象分配通常能降低 Young GC 压力,但如果暂停主要消耗在 RSet 扫描或老年代存活对象复制,单纯减少 Eden 分配可能效果有限。
例如:
Scan RS:60 ms
Object Copy:10 ms
此时重点不是先减少复制对象,而是分析跨 Region 引用和 RSet 维护成本。
14.6 “调用 System.gc() 可以解决 G1 堆压力”
System.gc() 只是向 JVM 提出建议,实际行为受 JVM 参数和实现控制。显式 Full GC 可能造成严重暂停,并且无法修复仍然被引用的对象。
如果必须验证显式 GC 行为,应在隔离环境中比较:
-XX:+ExplicitGCInvokesConcurrent
但该参数的实际效果、代价和与当前 JDK 行为的交互应通过 Java 25 的日志验证,不应把它当作通用生产修复方案。
15. 从症状到根因的诊断路径
症状一:Young GC 频繁但每次很短
先看:
单位时间分配量
Eden Region 数量
Young GC 间隔
GC 总 CPU 时间
可能原因是应用分配速率高,而不是单次 GC 低效。增加堆或改善对象生命周期可能降低频率,但应确认堆上限和内存预算允许。
症状二:暂停主要耗在 Object Copy
重点检查:
- 年轻代存活率;
- Survivor 是否快速装满;
- 晋升是否过快;
- 老 Region 存活对象是否过多;
- Humongous 对象是否参与回收;
- 是否存在突发流量。
如果 Full GC 后 Old 基线持续上升,应进一步做泄漏或缓存保留分析。
症状三:暂停主要耗在 Scan RS
重点检查:
- 跨 Region 引用密度;
- RSet 更新线程是否积压;
- 应用是否频繁修改跨 Region 容器;
- CPU 是否不足;
- Region 大小是否与对象规模严重不匹配。
此时直接调大 MaxGCPauseMillis 可能只是允许更长暂停,并没有减少 RSet 工作;直接调小年轻代也可能把问题推迟到更多次 GC。
症状四:出现 to-space exhausted 或疏散失败
这是空间安全问题,应优先检查:
-Xmx是否过小;G1ReservePercent是否过低;- Old 存活对象是否快速增长;
- 并发标记是否启动过晚;
- Humongous Region 是否占用大量连续空间;
- 应用分配速率是否在回收周期内突然升高。
恢复措施通常包括增加可用堆或降低分配压力,并重新验证并发标记时序。仅把暂停目标调大并不能保证恢复,因为核心问题是复制空间不足。
症状五:频繁 Full GC
应先区分 Full GC 的原因:
- 疏散空间不足;
- 并发标记未及时完成;
- Humongous 分配压力;
- 堆上大量对象确实存活;
- 外部显式 GC;
- 元空间或其他非 Java 堆资源问题。
GC 日志、JFR 和 jcmd 输出需要放在同一时间轴上分析。没有原因标签时,不应把所有 Full GC 都归因于“老年代太满”。
16. 一套可复现的实验方法
为了验证某个参数是否有效,可以先使用固定堆和完整日志:
java \
-Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-Xlog:gc*,gc+phases=debug,gc+heap=info:file=gc.log:time,uptime,level,tags \
-jar app.jar
实验至少记录:
- 每秒分配速率;
- GC 次数;
- Young、Mixed、Full GC 的暂停分布;
- P50、P95、P99 暂停;
- GC CPU 时间;
- Old 占用基线;
- Humongous Region 数量;
- 是否出现疏散失败;
- 应用吞吐和错误率。
一次有效的对比应只改变一个主要变量,例如:
基线:MaxGCPauseMillis=100
实验:MaxGCPauseMillis=200
然后在相同流量、相同机器资源和相同预热条件下比较。如果同时修改 Region 大小、堆大小、线程数和 IHOP,就无法判断收益来自哪个变化。
实验还必须包含恢复验证:
- 参数回滚后是否恢复原有行为;
- GC 日志是否能持续写入;
- 日志滚动是否会占满磁盘;
- JFR 采集是否产生可接受开销;
- 新参数是否降低了暂停,却增加了 Full GC 或应用 CPU。
17. 核心关系总结
G1 的几个核心术语不是彼此独立的:
Region
↓ 决定堆的回收与分配粒度
跨 Region 引用
↓ 由写屏障记录 Card
Remembered Set
↓ 限制回收时需要扫描的外部引用范围
候选 Region 的处理成本
↓ 影响暂停预测和 Collection Set 选择
MaxGCPauseMillis
↓ 提供启发式预算
Young/Mixed GC 的规模
↓ 影响单次暂停、回收速度和空间安全
To-Space 与并发标记时序
↓ 决定是否能持续运行而不进入 Full GC
因此,G1 调优不能简化为“把堆调大”或“把暂停目标调小”。应先回答三个问题:
- 暂停时间主要花在 RSet 扫描、对象复制、根处理还是线程等待?
- 堆压力来自高分配速率、对象存活率、Humongous 分配还是标记时序?
- 调整后是否同时改善了暂停分位数、吞吐、空间安全和 Full GC 风险?
Region 负责把堆切成可选择的回收单元,Remembered Set 负责让局部回收知道堆外部的引用来源,暂停目标负责约束一次回收集合的规模。三者共同构成 G1 的核心:在持续分配的应用中,用增量化、分区化和预测式选择换取更可控的垃圾回收暂停。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:JVM 逃逸分析:标量替换、锁消除、分配观测和误区
- 下一篇:JVM ZGC 深入:并发回收、染色指针、分代模式和容量规划
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论