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 大致经历:

  1. 停止 Java 线程;
  2. 扫描 GC Roots;
  3. 处理从 Old 指向 Eden 的引用;
  4. 找出 Eden 和当前 Survivor 中仍然存活的对象;
  5. 将存活对象复制到新的 Survivor 或 Old Region;
  6. 更新对象引用;
  7. 释放原来的 Eden Region;
  8. 恢复 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”决定,还取决于:

  1. 这些 Region 的 RSet 大小;
  2. RSet 的表示粒度;
  3. 需要扫描的 Card 数量;
  4. 从这些 Card 中发现的跨 Region 引用数量;
  5. 跨 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 启动并发标记。一个概念化流程是:

  1. Initial Mark:通常借助一次 STW 暂停开始标记;
  2. Root Region Scan:扫描与根相关的区域;
  3. Concurrent Mark:与应用线程并发标记;
  4. Remark:再次 STW,完成 SATB 处理和最终标记;
  5. Cleanup:整理标记结果,识别可回收 Region;
  6. 后续进行 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 集合为 CC,每个 Region ii 有:

  • LiL_i:预计需要处理的存活对象或复制工作量;
  • RiR_i:预计 RSet/Card 扫描工作量;
  • FiF_i:预计释放的空间;
  • TiT_i:预计处理时间。

可以抽象为:

Ti=αLi+βRi+γT_i = \alpha L_i + \beta R_i + \gamma

其中:

  • α\alpha 表示复制或对象处理速度的倒数;
  • β\beta 表示 RSet 扫描成本;
  • γ\gamma 表示固定阶段开销。

一次暂停选择集合 SS 时,希望满足:

iSTi+TrootTtarget\sum_{i \in S} T_i + T_{\text{root}} \leq T_{\text{target}}

同时尽量最大化收益:

maxiSFi\max \sum_{i \in S} F_i

这里:

  • TtargetT_{\text{target}} 是暂停目标附近的预算;
  • TrootT_{\text{root}} 是根处理、固定阶段和其他开销;
  • FiF_i 是回收 Region 后可释放的空间。

这不是 HotSpot 的公开精确算法,而是解释“垃圾多且处理便宜的 Region 更优先”的形式化模型。

7.3 完整算例

假设一次 Mixed GC 的固定开销预计为 8 ms,目标暂停为 50 ms,因此可用于 Region 处理的预算约为:

508=42 ms50 - 8 = 42\text{ ms}

有四个候选 Region:

Region 预计处理时间 TiT_i 预计释放空间 FiF_i
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:

25+20=45 ms>42 ms25 + 20 = 45\text{ ms} > 42\text{ ms}

这组集合超过预算。

选择 A、B、C:

8+12+20=40 ms8 + 12 + 20 = 40\text{ ms}

预计释放:

3.5+3.0+4.0=10.5 GiB3.5 + 3.0 + 4.0 = 10.5\text{ GiB}

选择 A、B、D:

8+12+25=45 ms8 + 12 + 25 = 45\text{ ms}

同样超过预算。

在这个简化模型中,A、B、C 是满足预算且收益较高的集合。D 虽然释放空间更多,但它的存活对象多或 RSet 扫描成本高,单位时间收益不一定更优。

真实 G1 还会考虑:

  • 年轻代必须及时回收;
  • 是否有足够的 To-Space;
  • 老 Region 回收的进度;
  • 分配速率;
  • 标记结果是否已经完成;
  • 历史暂停数据;
  • 并发线程和 CPU 资源。

因此不能根据这个算例反推 HotSpot 的精确选区结果。

7.4 为什么会超过目标

下面几类情况会使实际暂停明显超过目标:

  1. 存活对象估计偏低
    预测的复制量小于实际复制量。

  2. RSet 更新滞后
    暂停中需要处理积累的脏 Card。

  3. 对象复制失败
    没有足够的 To-Space,进入更重的处理路径。

  4. Humongous 对象占用空间
    大对象造成可用连续 Region 不足。

  5. CPU 被其他任务争抢
    GC 线程的实际运行速度低于历史模型。

  6. 应用分配速度突增
    在并发标记或回收尚未完成前,堆空间快速消耗。

所以暂停目标适合用作“控制方向”,不适合作为严格 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 来得太晚,应先确认:

  1. 并发标记是否按预期启动;
  2. 标记周期耗时多久;
  3. 应用在一个标记周期内分配了多少对象;
  4. 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:观察人体工学决策;
  • filecountfilesize:限制日志文件滚动规模。

日志中的一条 Young GC 可能类似:

Pause Young (Normal) (G1 Evacuation Pause)

这说明发生了 STW 疏散暂停,但不代表只复制了少量对象。应继续查看:

  • 暂停总耗时;
  • Eden 回收前后;
  • Survivor 和 Old 的变化;
  • RSet 扫描时间;
  • Object Copy 时间;
  • Termination 时间;
  • 是否出现 to-space exhaustedevacuation 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 或疏散失败

这是空间安全问题,应优先检查:

  1. -Xmx 是否过小;
  2. G1ReservePercent 是否过低;
  3. Old 存活对象是否快速增长;
  4. 并发标记是否启动过晚;
  5. Humongous Region 是否占用大量连续空间;
  6. 应用分配速率是否在回收周期内突然升高。

恢复措施通常包括增加可用堆或降低分配压力,并重新验证并发标记时序。仅把暂停目标调大并不能保证恢复,因为核心问题是复制空间不足。

症状五:频繁 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 调优不能简化为“把堆调大”或“把暂停目标调小”。应先回答三个问题:

  1. 暂停时间主要花在 RSet 扫描、对象复制、根处理还是线程等待?
  2. 堆压力来自高分配速率、对象存活率、Humongous 分配还是标记时序?
  3. 调整后是否同时改善了暂停分位数、吞吐、空间安全和 Full GC 风险?

Region 负责把堆切成可选择的回收单元,Remembered Set 负责让局部回收知道堆外部的引用来源,暂停目标负责约束一次回收集合的规模。三者共同构成 G1 的核心:在持续分配的应用中,用增量化、分区化和预测式选择换取更可控的垃圾回收暂停。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。