前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

一、G1 介绍

G1 GC,全称 Garbage-First Garbage Collector,通过 -XX : +UseG1GC 参数来启用,作为体验版随着 JDK 6u14版本面世,在 JDK 7u4 版本发行时被正式推出,相信熟悉 JVM 的同学们都不会对它感到陌生。在 JDK 9 中,G1 被提议设置为默认垃圾收集器(JEP 248)。

  • G1 收集器的设计目标:G1 收集器的设计目标是取代 CMS 收集器,它同 CMS 相比,在以下方面表现的更出色: G1 是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。 G1Stop The World ( STW )更可控,G1 在停顿时间上添加了预测机制,用户可以指定期望停顿时间

  • G1 收集器的应用场景:G1 是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。它是专门针对以下应用场景设计的:

    • CMS 收集器一样,能与应用程序线程并发执行。

    • 整理空闲空间更快。

    • 需要 GC 停顿时间更好预测。

    • 不希望牺牲大量的吞吐性能。

    • 不需要更大的 Java Heap

二、G1 中几个重要概念

1、分区 Region

传统的 GC 收集器:连续的内存空间划分为新生代、老年代和永久代(JDK 8 去除了永久代,引入了元空间 Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。如下图所示:

image-20240108174208757

1)Region 大小和数量有多少?

默认情况:G1 将堆内存划分为多个大小相等的 Region,JVM 在自动计算 Region Size 时,目标是让 Region 数量接近 2048,但只是一个启发式初始值,不是硬性规定Region 的最大数量是 65536(即 2^16)。每个 Region 的大小通常是堆内存大小除以2048 ,但也可以通过 JVM 参数 -XX:G1HeapRegionSize 来手动指定 Region 的大小。需要注意的是,这个参数的值必须是 2 的幂,且范围在 1MB32MB之间。

实际配置:在实际使用中,JVM 会根据堆内存的大小自动计算 Region 的数量,但也可以通过调整堆内存大小和-XX:G1HeapRegionSize 参数来间接控制 Region 的数量(手动指定 Region大小)。

  • 例如,如果堆内存大小为 4096MB,且未指定 -XX:G1HeapRegionSize参数,则默认每个Region 的大小为 2MB4096MB / 2),Region的数量为 2048个。 生产环境建议显式设置 -XX:G1HeapRegionSize

  • 推荐值:示例-XX:G1HeapRegionSize= 4M 表示每个 Region大小为 4MB

    • 小堆( ≤ 4GB):2MB
    • 中等堆(4GB~32GB):4MB~8MB
    • 大堆( ≥ 32GB ):16MB~32MB

问题1:为什么有人说“最多 2048”?这是怎么来的

答案:JVM 在自动决定 Region Size 时,目标是让 Region 总数接近 2048。但这只是一个“启发式初始值”,不是上限

概念 数值 说明
初始默认目标 Region 数量 ~2048 JVM 启动时“希望”划分成约 2048 个 Region(用于自动计算 Region Size)
实际最大 Region 数量 65536 系统硬上限,不可超过

问题2:G1 Region 大小是如何确定的?

答案:G1 使用如下优先级顺序决定 Region Size

  1. 如果显式设置了 -XX:G1HeapRegionSize=N,推荐用于生产环境,避免自动计算不一致

  2. 如果未设置,则 JVM 自动计算:

  • 目标:堆大小 ÷ ~2048 ≈ Region Size
  • 但结果必须是:1MB、2MB、4MB、8MB、16MB、32MB 中的一个
  • 然后反向计算出实际 Region 数量

问题3:推荐配置策略是什么(根据堆大小)

堆大小 目标 Region 数 推荐 Region Size 实际 Region 数
4GB ~2048 2MB 2048
8GB ~2048 4MB 2048
32GB ~2048 16MB 2048
64GB ~2048 32MB 2048
128GB ~2048 但最大只能 32MB → 实际 Region Size=32MB → 实际 Region 数 = 128*1024/32 = 4096  

2)Region 各代存储地址是连续的吗?

G1 的各代存储地址是不连续的,每一代都使用了 n 个不连续的大小相同的 Region

⬤ **G1 保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合, **

  • 一个Region 可能之前是年轻代,如果 Region 进行了垃圾回收,之后可能又会变成老年代,每个Region占有一块连续的虚拟内存地址。
  • G1 并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换,如下图所示:

image-20240108174302364

3)Region 数量和大小的关系是什么?

答案:当堆内存总量固定时,如果 Region的数量较多,那么每个 Region的大小就会相应减小。例如,如果堆内存是 4GB,你可以选择有 2048 Region (每个 Region 大约 2MB ),也可以选择有 1024Region(每个 Region 大约 4MB),以此类推。

4) Region 数量较多好处

1、更好的内存利用率

答案:假设 JVM 的堆内存总量为 4GB,如果选择较少的 Region 数量(比如 512Region,每个 Region 大约 8MB ),那么当有大量小对象被创建时,这些对象可能会分散在多个 Region中,导致内存碎片的产生。而如果选择较多的 Region 数量(比如 2048Region,每个 Region 大约 2MB ),则小对象可以更加紧密地填充在 Region中,减少了内存碎片,提高了内存利用率。

2、更灵活的 GC 管理

答案:在 GC 过程中,G1 会尝试并行地处理多个 Region,以缩短 GC 的停顿时间。如果 Region数量较多,G1可以更加灵活地选择需要回收的 Region而不是每次都必须处理大量的内存区域。这样,G1 可以更加精确地控制 GC的范围和时机,减少对整个应用的影响

3、更好的负载平衡

答案:在多核处理器环境下,G1 可以并行地使用多个GC 线程来回收 Region。如果 Region 数量较多,这些 GC线程可以更加均匀地分配到各个 Region 上,实现更好的负载平衡。这样,每个 GC 线程都有足够的工作来做,避免了某些线程空闲而其他线程过载的情况。

4、更容易处理大对象

答案:虽然大对象(巨型对象)会占用多个 Region,但在 Region 数量较多的情况下,这些大对象对 GC 的影响相对较小。因为即使是大对象,也只是占用了相对较少的 Region 数量(尽管这些 Region 在物理上可能是连续的)。这样,在 GC 过程中,G1 可以更容易地处理这些大对象,而不会导致整个 GC 过程的性能下降,如果数量较小,则 Region 会比较大,导致无法获取巨型对象,不容处理实际出现的大对象

5、更可预测的停顿时间

答案G1 收集器提供了可预测的停顿时间模型,允许用户设置期望的 GC 停顿时间。在 Region 数量较多的情况下,G1 可以更加精确地控制每次 GC 的停顿时间,因为它可以更加灵活地选择需要回收的 Region 。这样,G1 可以更加接近地满足用户设置的期望停顿时间,提高了应用的响应性和可预测性。

5)初始 Region是怎么分配的

  • 年轻代与老年代的划分:虽然 G1 在逻辑上保留了年轻代(包括 Eden 区和两个 Survivor 区)和老年代的概念,但在物理上它们并不再是固定且隔离的。相反,G1会根据需要动态地将 Region 分配给年轻代或老年代。默认情况下,年轻代占整个堆内存的 5%,但这个比例可以通过 -XX:G1NewSizePercent -XX:G1MaxNewSizePercent 参数进行调整 。

  • 动态调整:在 JVM 运行过程中,G1 会根据应用的内存使用情况和垃圾收集的效果动态地调整 ``Region 的分配。例如,如果年轻代中的对象频繁晋升到老年代,G1 可能会增加老年代中 Region`的数量以容纳更多的对象

6)如何查看 Regin 分配情况

使用 jmap - head命令,理论总 Region 可能和实际分配的不一致。JVM 可能不会将所有 Region 都分配给年轻代、老年代或Survivor 区,原因包括:

  • 内存对齐JVM 可能对堆起始地址或 Region 边界进行对齐(如按页大小对齐),导致部分空间未被使用。
  • 保留空间JVM 可能预留部分 Region 用于特殊用途(如巨型对象分配、元空间映射等)。
  • 动态调整G1 在运行时可能动态调整各代大小,但总 Region 数不变。

未分配的 Region 原因和 去向,这些“缺失”的 Region 可能用于以下场景:

  1. 空闲 RegionFree Regions):G1 维护一个全局的空闲 Region 列表(Free List),用于动态分配给年轻代或老年代。
  2. 未使用的堆空间:JVM 可能未将堆的全部容量划分为 Region ,例如保留部分空间作为安全边际。
  3. G1 内部管理结构:G1 需要额外的 Region 存储元数据(如 Remembered SetsCard Table等),这些可能不直接计入各代。

7)类别理解

概念 类比
堆内存 一座城市
Region 城市中的街区(A区、B区…)
Card 街区里的门牌号段(每512户一个段)
Card Table 市政监控系统:标记“哪些门牌段最近有快递寄出”(Dirty)
RSet 每个街区的“收件人登记簿”:记录“哪些其他街区的哪些门牌段给我寄过快递”
写屏障 快递员寄快递时,必须登记寄件人地址

2、巨形对象 Humongous Region

1)什么是巨型对象?

答案:一个大小达到甚至超过分区大小一半的对象称为巨型对象( Humongous Object )。

2)巨型对象会直接进入老年代吗?

答案:G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1 有专门分配大对象的 RegionHumongous 区,而不是让大对象直接进入老年代的 Region

3)那巨型对象不直接进入老年代作用是什么呢?

答案:Humongous 区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的 GC开销

4)既不直接进入老年代,那humongous区域大对象什么时候回收

答案:其实很简单,在新生代和老年代回收的时候,就会顺带着对大对象一并回收了,所以这就是 G1 内存模型下对大对象的分配和回收的策略。G1 垃圾收集器在处理巨型对象时,会避免进行拷贝操作,因为巨型对象的拷贝成本很高。在回收时,G1 会优先考虑回收那些没有巨型对象的 Region,以减少拷贝成本和提高回收效率。

5)如果 Region 放不下怎么办?

答案:如果一个H 区装不下一个巨型对象,那么G1会寻找连续的 H 分区来存储。G1 的大多数行为都把 Humongous Region作为老年代的一部分来进行看待(但还是不太一样哦)

6)如何减少 humongous 对象影响

  • 大对象变普通对象:为了减少连续 H-objs 分配对 GC 的影响,需要把大对象变为普通的对象,建议增大 Region size (前提是已知有大对象,否则建议默认)。一个 Region的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围从 1M32M ,且是 2 的指数,
  • 监控 Humongous Allocation开启 GC 日志,-XX:+PrintHeapAtGC,观察是否有大量 H-obj:

3、卡表 Card Table

  • 目的解决跨代引用问题:在进行 Minor GC(新生代回收) 时,如何快速找到“老年代对象 → 新生代对象”的引用?
  • Minor GC 只扫描新生代,但如果老年代有对象引用了新生代对象,这个新生代对象就不能被回收。所以必须知道哪些老年代区域包含了对新生代的引用
  • 原理:将堆内存划分为固定大小的小块(称为“Card”),每个 Card 用一个字节记录其状态
    • 当一个对象 A 修改了它的字段,使其指向另一个代的对象 B 时,JVM 会将 “对象 A 所在的 Card” 标记为 Dirty
    • 比如:当老年代对象引用了新生代对象时,该老年代对象所在的 Card 被标记为“脏”-

1)基本概念

a、Card(卡片)

  • 堆内存进一步被逻辑划分为更小的固定大小单元,称为 Card(通常为 512 字节)。
  • 每个 Card 对应 Card Table 中的一个字节(byte),用于标记该 Card 是否“脏”(Dirty)。
  • “脏”表示:Card 内存在对象字段发生了写操作,且可能建立了从老年代到新生代的引用

b、Card Table 结构

  • 是一个全局的字节数组,也存储到 region上,每个元素对应一个 Card

    • 数组长度 = 堆大小 / 卡片大小 = 1GB / 512B = 2097152 个元素。
    • Card Table2MB =2097152/1024/1024
  • 每个元素是一个字节,记录该 Card 是否“脏”(即是否有老年代对象引用新生代对象)。

  • Card Table (2 MB)
    +-------------------+-------------------+ ... +-------------------+
    | Card 0            | Card 1            | ... | Card 2097151      |
    +-------------------+-------------------+ ... +-------------------+
    

c、RegionCard Table 的关系

一个 Region 包含多个连续的 Cards,这些 Cards 在 Card Table 中也是连续的。

  • 如果 Region 0(老年代)中的某个对象修改字段,指向了 Region 1(新生代)的对象,
  • JVM 会通过 写屏障(Write Barrier 找到该对象所在的 Card(比如 Card2),
  • Card Table[2] 标记为 Dirty(如设为 1)。
维度 Region Card
粒度 较粗(如 1MB 较细(如 512B
用途 GC 回收单位、内存分配单位 跨代引用跟踪(Remembered Set 的基础)
数量关系 1 个 Region = (RegionSize / CardSize) 个 Cards 1Card 属于且仅属于 1 个 Region
映射方式 Region 是堆的物理划分 Card 是堆的逻辑划分,与地址线性对应

假设堆 = 2MBRegionSize = 1MBCardSize = 512B

Heap (2 MB)
+---------------------------+---------------------------+
| Region 0 (Old)            | Region 1 (Eden)           |
| [Card0][Card1][Card2][Card3] | [Card4][Card5][Card6][Card7] |
+---------------------------+---------------------------+

Card Table (8 bytes)
+-----+-----+-----+-----+-----+-----+-----+-----+
| C0  | C1  | C2  | C3  | C4  | C5  | C6  | C7  |
+-----+-----+-----+-----+-----+-----+-----+-----+
  ↑     ↑     ↑     ↑     ↑     ↑     ↑     ↑
Region0 ←───────────────   Region1 ←───────────────

2)原理

简单说:Region 是“街区”,Card 是“门牌号”,Card Table 是“门牌状态登记簿”。当老街区的某户人家(老年代对象)给新街区的人写信(引用新生代对象),就在登记簿上把这户的门牌标记为“有动作”(Dirty),以便邮局(GC)只查这些户,不用挨家挨户跑。

  • 堆总大小:4 MB
  • Region 大小:1 MB → 共 4 个 Region(Region 0 ~ Region 3)
  • Card 大小:512 字节 → 每个 Region 包含 1MB / 512B = 2048Card
  • 总 Card 数:4MB / 512B = 8192 个 → Card Table8192 个字节
堆内存(4 MB)
+------------+------------+------------+------------+
| Region 0   | Region 1   | Region 2   | Region 3   |
| (Old)      | (Eden)     | (Survivor) | (Old)      |
+------------+------------+------------+------------+
 ↑            ↑            ↑            ↑
 Cards 0~2047  Cards 2048~4095 ...      Cards 6144~8191

a、JVM 如何处理

  • 对象 A 位于 Region 0(老年代),地址为 0x00050000(即 Region 0 内偏移 0x50000 - 0x00000000 = 327680 字节)
  • 对象 B 位于 Region 1(新生代 Eden)
A.ref = B;  // 老年代对象 A 指向新生代对象 B

步骤 1:触发写屏障(Write Barrier)

  • JVM 在字段赋值时插入写屏障代码
  • 检测到:从老年代写入指向新生代的引用 → 需要记录!

步骤 2:计算对象 A 所在的 Card 编号,比如 640

步骤 3:将 CardTable[640] = 1(或其他非 0 值,表示“脏”)

步奏 4:把该 Card 地址放入 Dirty Card QueueDCQ),未来构建Rset

b、关系总结图

Region 0 (Old, 1MB)
│
├── Cards 0 to 2047
│    │
│    └── Card 640 ←─ 对象 A 所在位置 → 被标记为 Dirty
│
Region 1 (Eden, 1MB)
│
└── 对象 B(被 A 引用)

Card Table:
...
[639] = 0
[640] = 1  ←─ Dirty!
[641] = 0
...

3)构建 Rset 基础

G1 中每个 Region 都有一个 RSet,记录“哪些其他 RegionCards 引用了本 Region

Card Table 是构建 RSet 的底层支持。

1)Dirty Card 的处理由以下两者共同完成

  • Concurrent Refinement 线程(异步、并发)
    • 在应用运行期间(非 STW),后台线程持续消费 Dirty Card Queue
    • 解析引用关系,更新目标 Region 的 RSet
  • GC 线程(同步、STW 阶段兜底)
    • Young GCMixed GC 触发时
    • 如果还有未被 Refinement 处理的 Dirty Cards
    • GC 线程会在 STW 期间亲自扫描这些 Cards,确保 RSet 逻辑完整

b、为什么 Card 大小是 512 字节?

这是为了平衡空间开销和查找效率。

  • 如果 Card 太小,Card Table 会非常大;
  • 如果 Card 太大,精度会降低。
矛盾方向 Card 太小(如 256B) Card 太大(如 2KB)
Card Table 空间开销 更大(表项更多) 更小
扫描精度 / 冗余扫描 更精确(只扫少量对象) 更粗糙(可能扫很多无关对象)
写屏障开销 更频繁更新(更多卡被标记) 较少更新
  • 扫描效率
    • 当一个 Card 被标记为 DirtyGC 需要扫描整个 Card(512B)来找出引用。
    • 如果 Card 是 4KB(如页大小),可能包含上百个对象,大量无效扫描,延长 GC Pause。
  • 空间开销计算
    • 假设堆大小 = 32 GB
    • Card 大小 = 512 B → Card Table 大小 = 32GB / 512B = 64 MB
    • 若 Card = 256B → 表大小 = 128 MB
    • 若 Card = 1KB → 表大小 = 32 MB

a、如何计算 Card 的位置索引?

  • 堆起始地址: 是指 JVM 在虚拟内存中为 Java 堆分配的连续内存区域的起始(最低)地址
  • 对象在堆中的偏移量。 = (对象地址 - 堆开始地址) / 512

d、”脏 “只会标记老年代对象吗

新生代对象引用老年代对象(也会触发,但意义不同,通常不重要,但技术上会发生。)

  • 技术上:在 G1 中,youngObj.field = oldObj 是跨 Region 写 → 触发写屏障 → youngObj 所在 Card 被标 Dirty
  • 但实际上
    • Young GC 时,我们只关心“谁引用了我(young)”,不关心“我引用了谁”。
    • Mixed GC 回收某个 Old Region 时,如果该 Old Region 被 Young Region 引用,RSet 会记录这一点,确保安全回收。
      • 但通常 Young Region 生命周期短,很快会被回收,所以这种引用“转瞬即逝”,影响小。

4、SATBSnapshot At The Beginning)写屏障技术的应用

SATBSnapshot At The Beginning)写屏障是一种特殊的写屏障技术,它主要用于解决并发标记算法中的漏标问题。

1)工作原理

目标:并发标记 期间,用户线程还在运行,对象引用可能随时变化。 SATB 的目标是:确保不会漏掉任何原本可达、但后来被断开引用的对象

项目 说明
写屏障做什么 A.field = C 之前,把 A.field 原来的值(B)记录下来
为什么能防漏标 即使 B 后来没人引用,GC 也会因为“它曾被引用”而重新检查它
代价是什么 可能多标一些已经“真正死亡”的对象(称为“浮动垃圾”),但安全

a、工作流程

  • 原来:A 引用 B,B 是可达的,会被标记

  • 用户线程执行:A.field = C,A 不再引用 B(B 可能变成不可达)

  • 触发 SATB 写屏障:在赋值前,JVM 记录:B 曾经被引用过!,把 B 加入“待标记队列”

  • GC 后续会处理 B,确保它被正确标记

b、标记过程:

并发标记开始时(快照):
   A → B → C
     ↗
    D

用户线程修改:
   A → C     // 断开对 B 的引用

SATB 写屏障:
   “等等!B 被删了,先记下来!”
   → 把 B 加入标记栈

GC 继续标记:
   从栈中取出 B,继续标记 C、D……
   → B 不会被漏标

2)优点

优点 说明
防止漏标 在并发标记期间,即使对象引用被修改,也能通过“删除前记录”机制,确保存活对象不被错误回收。
低性能开销 写屏障仅在引用修改时触发,只记录被删除的引用目标,对应用线程影响小。
支持高效并发 无需暂停用户线程,是 G1 等垃圾回收器实现并发标记的关键技术。
RSet 协同高效 RSet 告诉 GC “谁引用了我”,SATB 确保“被断开的引用对象不丢失”,两者配合实现精准、快速的跨区标记。

5、并发标记算法(三色标记法)

CMS 和 G1 在并发标记阶段都基于“三色标记法”,通过 白、灰、黑 三种颜色动态标记对象的可达性状态,以识别存活对象。 理想情况下:灰色对象全部处理完,仅剩黑色(存活)和白色(垃圾)

颜色 正确性保证 含义
白色 垃圾,未处理或不可达,最终被回收 对象尚未被标记,默认状态。标记阶段结束后,仍为白色的对象将被回收。
灰色 正在处理中,不会丢失 对象自身已被标记,但它的 成员字段(引用)还未被处理。它是“正在处理”的中间状态,通常存放在标记栈或队列中。
黑色 已完全处理,不会遗漏 对象自身已被标记,且 所有成员字段指向的对象也都被标记完成。它是“已完成”的状态。

1)标记过程:

1. 初始状态:所有对象为 白色
2. GC Roots(根对象)直接引用的对象 → 标记为 灰色,加入标记栈

3. 从栈中取出一个灰色对象
4. 扫描它的所有引用字段
5. 将其引用的白色对象标记为 灰色,入栈
6. 当该对象的所有引用都被处理后 → 标记为 黑色

7. 重复 3~6,直到标记栈为空
8. 结束:所有黑色对象为存活对象,白色对象为垃圾,可回收

image-20240108195525678

2)漏标问题

  • CMS“见黑生白,黑变灰” → 发现黑指向白,把黑变灰重扫。
  • G1“断链保白,白入栈” → 引用断开时,把白对象保存起来继续扫。
特性 CMSIncremental Update G1SATB
触发动作 新增引用:C.field = B(黑 → 白) 删除引用:B.field = null(灰 → 白断开)
保护谁? 保护黑色对象(重标为灰) 保护白色对象(推入日志)
写屏障时机 引用写入后 引用覆盖(记录旧值)
哲学 “不要漏掉新引用” “不要丢掉快照中的存活对象”
暂停时间 最终 Remark 阶段可能较长(需重新扫描黑对象) 更可预测(并发处理 SATB 日志)
适用 GC CMS G1, ZGC(变种)

3)CMSIncremental Update(增量更新)

关注引用的增加:只要发现“黑 → 白”的新引用被创建,就把黑色对象重新标灰,确保下次再扫它的引用。

a、初始状态

初始状态(并发标记开始前):
        ┌─────────┐       ┌─────────┐       ┌─────────┐
Root → │   A     │──────▶│   B     │──────▶│   C     │
        │ (Gray)  │       │ (Gray)  │       │ (White) │
        └─────────┘       └─────────┘       └─────────┘

→ GC 线程正在标记:A 是灰色,B 还没扫到。

b、并发阶段:用户线程执行 C.field = B;

  • 假设 C 已被标记为 黑色(已完成扫描)
  • 现在 C 指向了白色对象 B
    • 问题:B 是白色,但被黑色对象 C 引用 → 如果不处理,B 会被当成垃圾回收!
并发修改后:
        ┌─────────┐       ┌─────────┐
Root → │   A     │──────▶│   B     │ ←┐
        │ (Gray)  │       │ (White) │  │
        └─────────┘       └─────────┘  │
              ▲                        │
              │                        │
        ┌─────────┐       ┌─────────┐  │
        │   C     │───────┘          │
        │ (Black) │                  │
        └─────────┘ ←─ 新增引用 ──────┘

c、CMS 的应对:Incremental Update

a、并发阶段:用户线程执行 C.field = B;

  • 假设 C 已被标记为 黑色(已完成扫描)
  • 现在 C 指向了白色对象 B
    • 问题:B 是白色,但被黑色对象 C 引用 → 如果不处理,B 会被当成垃圾回收!
并发修改后:
        ┌─────────┐       ┌─────────┐
Root → │   A     │──────▶│   B     │ ←┐
        │ (Gray)  │       │ (White) │  │
        └─────────┘       └─────────┘  │
              ▲                        │
              │                        │
        ┌─────────┐       ┌─────────┐  │
        │   C     │───────┘          │
        │ (Black) │                  │
        └─────────┘ ←─ 新增引用 ──────┘
  • 写屏障检测到 黑 → 白 的引用赋值
  • C 重新标记为灰色(即使它之前已完成扫描)
    • 下次 GC 线程会重新扫描 C 的字段,从而发现 B,将其标灰 → 避免漏标
修正后:
        ┌─────────┐       ┌─────────┐
Root → │   A     │──────▶│   B     │
        │ (Gray)  │       │ (White) │
        └─────────┘       └─────────┘
              ▲
              │
        ┌─────────┐
        │   C     │ ← 现在是 Gray!
        │ (Gray)  │
        └─────────┘

5)G1SATBSnapshot-At-The-Beginning

关注引用的删除:在并发标记开始时拍下对象图快照;若之后某灰色对象断开对白色对象的引用(灰 → 白消失),则将该白色对象记录下来,确保它仍被扫描,不会被误回收

a、初始状态:

初始状态(并发标记开始,快照已建立):
        ┌─────────┐       ┌─────────┐       ┌─────────┐
Root → │   A     │──────▶│   B     │──────▶│   C     │
        │ (Gray)  │       │ (Gray)  │       │ (White) │
        └─────────┘       └─────────┘       └─────────┘

→ 快照认为:A → B → C 是一条存活链,C 虽白但应存活。

b、并发阶段:用户线程执行 B.field = null;(断开 B → C)

  • 假设 B 是灰色(正在被扫描),C 是白色(尚未被标记)
  • 用户代码断开了 BC 的引用
    • 问题:C 在快照中是存活的,但现在没有活跃引用指向它 → 如果不处理,C 会被当成垃圾回收!
并发修改后:
        ┌─────────┐       ┌─────────┐       ┌─────────┐
Root → │   A     │──────▶│   B     │         │   C     │
        │ (Gray)  │       │ (Gray)  │         │ (White) │
        └─────────┘       └─────────┘         └─────────┘
                                 ×(引用被删除)

c、G1 的应对:SATBSnapshot-At-The-Beginning

  • 写屏障在 B.field = null 执行前,捕获原引用值 C
  • C 推入 SATB 日志缓冲区(log buffer
  • 并发标记线程会定期处理这些日志,C 重新加入标记队列(标为灰色)
    • 这样即使 B 不再指向 CC 仍会被 GC 扫描 → 避免漏标

6)为什么 G1 采用 SATB 而不用 incremental update

核心原因:G1· 是基于 Region 的分代 + 并发 GC,其内存模型和回收策略决定了 Incremental Update 的开销不可接受,而 SATB 更契合其“局部回收 + 可预测停顿”的设计目标。

a、根本差异:GC 架构不同

G1 必须知道“哪些 Region 被哪些其他 Region 引用” → 这依赖 Remembered SetRSet),而 RSet 的构建与 SATB 高度协同。

特性 CMS(使用 Incremental Update) G1(使用 SATB)
堆结构 连续的老年代 + 新生代 离散的 Region 集合(每个 Region 可属任意代)
回收单位 整个老年代(或全堆) 部分 Region(Collection Set)
并发标记目标 标记整个老年代 标记所有 Region,但只回收部分

b、Incremental Update 何效率低

  • G1 的核心思想是:只回收垃圾最多的若干 RegionCSet,无需关心全局。
  • 但如果用 Incremental Update,必须重新扫描

c、写屏障只记录“旧引用”,不触发重扫

  • SATB 写屏障在引用被覆盖前,仅将原白色对象推入日志缓冲区
  • 后续由并发线程异步处理这些日志,不会打断 Mutator(应用线程),也不会立即触发深度扫描。
  • 日志处理可批量、并发、限流,开销可控

d、天然支持快照语义,保障正确性

  • G1 的并发标记基于“开始时的对象图快照”。
  • SATB 确保:快照中存活的对象,即使后来断链,也不会被回收
  • 这与 G1 的“最终一致性”回收模型完美匹配。

e、Remembered Set(RSet)协同高效

  • 没有 SATB:并发标记不可靠 → 可能误判 Region 垃圾比例 → 回收不该回收的 Region程序崩溃
    • SATB 保证:在标记期间断开的引用,其目标对象仍会被标记,从而避免 RSet 不完整导致的漏标。
  • 没有 RSet:无法安全回收老年代 Region → 只能 Full GC 失去低延迟优势
    • RSet 记录“谁引用了我”,用于快速识别 CSet 的根。
  • 二者共同构成 G1 并发标记 + 局部回收 的基石。
    • RSet 提供根 → SATB 保护断链对象 → 并发标记完成 → 安全回收 CSet

d、G1 缺点-浮动垃圾

G1 宁可多留一点垃圾,也不冒漏标风险 —— 这是所有 GC 的基本原则。

  • SATB 会产生浮动垃圾:例如:对象在快照中存活,但之后变成垃圾,本次 GC 不回收,留到下次。
  • 但这可接受
      1. 浮动垃圾 ≠ 内存泄漏(下次会回收)
      2. G1 通过 Mixed GC 逐步清理老年代,容忍少量浮动垃圾
      3. 正确性 > 完美回收率

6、RSet(Remember Set ) 已记忆集合

1)RSet 基本定义

  • 全称Remembered Set(已记忆集合)
  • 归属:每个 Region 独立拥有一个 RSet
  • 语义RSet of Region X = { 所有 外部 Region 中,包含指向 X 内对象的引用 的那些 Region }
  • 方向points-into(指向我),不是 points-from(我指向谁)
  • 举例:RSet(R5) = {R1, R3, R7} 表示:R1、R3、R7 中有对象引用了 R5 中的对象

2)RSet 的内部结构

RSet 并不是简单地存一个 Region 列表,而是更精细地记录 哪些 Card 包含引用

a、层级结构(两级索引)

结构:每个 RegionRSet 包含一个 哈希表(Hash Table),其结构如下:

层级 内容
第一层(Key Region 的 ID(Region Index)
第二层(Value 该源 Region 中,哪些 Card 包含对本 Region 的引用

b、存储模式(根据引用密度自适应)

这个集合根据密度分为两种模式:

模式 说明
Fine-Grained 使用 BitMap,每个 bit 代表一个 Card,适用于引用较少的场景
Coarse-Grained 用单个 bit 表示整个源 Region 存在引用;适用于引用密集场景(如大对象区)

3)RSet 如何被维护? ——写屏障 + 并发处理

RSet 不是实时更新的,而是通过 写屏障(Write Barrier) + 后台并发线程 异步构建。RSet 就在 GC 发生前准备好了,GC 时可直接使用。

  1. 用户线程执行赋值

    objInR1.field = objInR5;  // R1 → R5,跨 Region 引用
    
  2. 写屏障触发

    • JVM 在每次引用写入时插入写屏障代码
    • objInR1 所在的 Card 标记为 Dirty(在 Card Table 中)
    • 把该 Card 地址放入 Dirty Card QueueDCQ
  3. Concurrent Refinement 线程(后台线程):

    • DCQ 中取出 Dirty Card
    • 扫描 Card 内存,解析 Card 内存内容,找出所有跨 Region 引用
    • 例如发现:R1Card #123 引用了 R5 的对象
  4. 更新 RSet:将 (R1, Card#123) 记录到 R5RSet

  5. Young GC / Mixed GC 触发时:如果 DCQ 中还有未处理的 Dirty Cards

    1. 所有缓冲区中是否还有未处理的 Dirty Cards
    2. 如果有 → 必须在 STW 期间处理它们

4)为什么需要 RSet

a、避免全堆扫描:反向引用索引(“谁引用了我”)

1)问题背景:在传统分代 GC(如 Parallel Scavenge + Parallel Old)中:

  • 年轻代 GC(Young GC)时,必须确认年轻代中的对象是否被 老年代对象引用
  • 如果没有这种信息,就可能把仍在使用的对象当成垃圾回收 → 程序崩溃
  • 于是,传统做法是:每次 Young GC 都扫描整个老年代,看有没有引用指向年轻代。

2)代价巨大

  • 老年代可能占堆的 80% 以上;
  • 扫描整个老年代 = O(堆大小),STW 时间不可控;
  • 违背了 G1 “低延迟、可预测停顿” 的设计目标。

3)RSet 如何解决?

  • RSet 为每个 Region 维护一个 反向引用列表,记录:RSet(R5) = { R1, R3, R7 }

  • 表示:只有 R1、R3、R7 可能引用了 R5 中的对象。

  • Young GC 回收 R5 时:

    • 不需要扫描整个堆

    • 只需检查 R1、R3、R7 中的相关内存区域(通过 Card 精确定位);

    • 如果这些引用存在,将这些引用作为 GC Roots 的一部分,把被引用的对象标记为存活。

4)效果:

  • 扫描范围从 “整个堆” 缩小到 “几个 Region 的部分 Card”
  • STW 时间大幅降低,且与堆大小基本解耦;
  • 实现了 局部化、精准化 GC

b、支持增量式回收:精确定位跨 Region 引用

1)问题背景

  • G1的核心特性是 Mixed GC —— 在一次 GC 中,同时回收年轻代 + 部分老年代 Region
  • 但回收一个老年代 Region(比如 R5)前,必须回答:
    • “还有没有其他 存活的 Region 在引用 R5 中的对象?”
    • 如果没有,就可以安全回收;如果有,就必须保留或移动这些对象。

2)挑战

  • 老年代 Region 之间可能存在大量交叉引用;
  • 如果不知道引用关系,就无法判断某个老年代 Region 是否“真正可回收”。

3)RSet 如何支持?

  • 当 G1 决定回收 R5 时:查看 RSet(R5),得到所有可能引用 R5 的源 Region(如 R1、R3、R7);

  • 判断这些源 Region 的状态:

    • 如果 R1R3 也在本次 GC 中被回收 → 它们的引用即将消失,可忽略

    • 如果 R7 不在回收集合中(仍存活)R5 中被 R7 引用的对象 必须保留

  • 仅回收 R5未被任何存活 Region 引用的对象

4)效果

  • 实现了 安全的老年代增量回收
  • 避免了 Full GCFull GCSTW 整个堆,停顿时间长);
  • 支撑了 G1“垃圾优先(Garbage-First)”策略:优先回收垃圾比例高的 Region。

c、提升 GC 效率:空间换时间 + 异步维护

RSet 的设计本质上是一种 空间换时间 的优化。

  • 空间成本:每个 Region 都要维护一个 RSet,占用额外内存。
  • 时间收益:极大减少了 GC 时的扫描工作量。
  • Young GC 更快
    • 只需扫描 RSet 中的少量 Region,而不是整个堆。
    • STW 时间更短,应用响应更及时。
  • Mixed GC 更高效
    • 快速定位跨 Region 引用,避免无效回收尝试。
    • 提高了 GC 的吞吐量和可预测性。
  • 写屏障 + 延迟更新
    • G1 使用写屏障(Write Barrier)异步维护脏卡表。
    • RSet 的更新可以在 GC 间隙进行,避免在 GC 时做大量工作。
    • 进一步平滑了 GC 停顿。

5)RSetYoung GC 中如何使用

扫描范围从“整个老年代” → “RSet 中列出的几个 Region 的部分 Card

  • 目标:回收年轻代 Region
  • 挑战:
    • 传统分代 GC 的问题:每次 Young GC 都需扫描整个老年代,以确认是否有老年代对象引用年轻代对象。
    • 这些 Region 中的对象可能被老年代引用 → 不能回收
  • 解法:仅扫描 RSet(年轻代 Region) 中列出的源 Region
    • 例如:RSet(R5) = {R1, R3, R7} → 只需检查 R1R3R7 中的相关 Card
  • 实时:对每个待回收的年轻代 Region R
    • 查看 RSet(R)
    • 如果非空,遍历其中记录的 Region + Card
    • 扫描这些 Card,找出实际引用
    • 将这些引用作为 GC Roots 的一部分,防止误回收

6)RSetMixed GC 中如何使用

  • 目标:G1 会优先回收“垃圾比例高”的老年代 Region
  • 挑战:回收老年代 Region R5 前,必须确保无其他 存活 Region 引用它。
  • 解法:使用 RSet 快速判断:
    • RSet(R5) 中的源 Region 也被回收 → 引用无效,可忽略
    • RSet(R5) 中的源 Region 不被回收R5 中被引用的对象必须保留
  • 效果
    • 安全实现“垃圾优先(Garbage-First)”策略;
    • 避免 Full GC,实现低延迟老年代回收。

8)RSet 的代价

缺点 说明
内存开销 每个 Region 维护一个 RSet,通常占堆内存 1%~2%
CPU 开销 写屏障 + 并发 Refinement 线程带来轻微运行时开销
实现复杂性 需支持 Sparse RSet、Fine/Coarse 模式切换、并发更新等优化机制

5、收集集合:Collection Set

Collection Set,是指在垃圾收集过程中被回收的 Region 集合,也可以理解为是垃圾收集暂停过程中被回收的目标

  • 这些 Region 可能包括年轻代(Young Generation)的 Eden 区、Survivor区,以及老年代(Old Generation)的某些Region
  • G1收集器会根据 Region 中的垃圾数量和回收收益来决定哪些 Region 应该被加入 CSet 进行回收。

1)CSet 的生成与回收过程

1、在垃圾收集开始时,G1 收集器会根据一定的策略(如垃圾数量、回收收益等)选择一部分 Region 组成 CSet

2、然后,G1 收集器会暂停应用程序的执行( Stop - The - World ),并对CSet 中的 Region 进行垃圾收集。

3、收集过程中,G1 会采用复制算法(对于年轻代)或标记-整理算法(对于老年代)来回收垃圾对象,并将存活的对象移动到其他Region 中。

4、收集完成后,CSet 中的 Region 会被释放并加入到空闲 Region 队列中,供后续对象分配使用。

三、G1 垃圾回收过程

G1 提供了两种主要的垃圾收集模式:Young GCMixed GCFull GCG1 中不是直接提供的,但在特定情况下(如 Mixed GC 无法跟上内存分配速度)会回退到串行老年代收集器( Serial Old GC)进行全堆扫描。

CMS 的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

image-20240802174157376

参数 说明
Young-only phase Young- only ” 是指垃圾收集器只会回收年轻代,该阶段主要完成回收过程中的 年轻代回收(Young GC) 和 老年代并发标记周期(Concurrent Marking Cycle) 两个过程
Space-reclamation phase 空间回收阶段,该阶段会进行多次年轻代收集(Young GC)以及增量回收部分老年代,被称为混合收集(Mixed GC)。
G1 判断继续回收老年代不足以释放更多的空间,或者停顿时间大于 MaxGCPauseMillis(默认 200ms)时,会退出该阶段。空间回收阶段对应回收过程中的混合回收(Mixed GC

四、Young GC(年轻代收集)(复制算法)

image-20240802173406541

1、触发条件

  • Eden 区空间耗尽:当年轻代中的 Eden 区域被新创建的对象填满,无法再分配新的对象时,理论上会触发 Young GC。然而,在 G1 中,这并不是立即发生的。
  • 回收时间预估G1 收集器会计算当前 Eden 区的回收所需时间。如果这个预估时间远小于由参数 -XX:MaxGCPauseMillis 设定的最大暂停时间目标,那么 G1 可能会选择增加年轻代的 region 数量,允许更多的对象分配,而不是立即进行垃圾回收。
  • 接近设定值时触发:只有当回收 Eden 区的时间接近或达到 -XX:MaxGCPauseMillis 参数所设定的值时,才会真正触发 Young GC

2、详细流程

  • 复制算法:一旦触发 Young GC,会采用复制算法进行垃圾回收。在这个过程中,存活的对象会被复制到另一个区域,通常是 Survivor 区域(如 S0S1),也可能根据具体情况直接晋升到老年代。
  • Stop the World 事件:在执行 Young GC 期间,整个应用会暂时停止(即“Stop the World”状态),以便 JVM 安全地进行垃圾回收操作。这段时间内所有的用户线程都会被挂起。
  • 清理 Eden:完成对象的复制后,原先 Eden 区中对应的 Region 将被清空,回收其中的空间以供后续的新对象分配使用。

1)确定回收集合(CSet

G1 会在遵循用户设置的 GC 暂停时间上限的基础上,选择一个最大年轻代区域数,将这个数量的所有年轻代区域(Eden 区和 Survivor 区)作为收集集合( Collection Set,简称 CSet )。

2)找出所有 GC Roots

G1Young GCSTW 阶段,并不会对整个堆做可达性分析,而是聚焦于 CSet(Collection Set = Eden + Survivor Regions) 中的对象是否存活。为此,它从以下两类“根”出发进行标记:

a、标准 GC Roots

注意:如果这些 Roots 直接引用了 老年代对象Young GC 会忽略该引用(因为目标不在CSet 中),不会递归扫描老年代内部

这些是 JVM 定义的全局可达起点,包括:

  • 虚拟机栈中 活跃栈帧的局部变量 引用的对象
  • 方法区中 类的静态属性(static fields) 引用的对象
  • JNI(本地代码)持有的 强引用对象

b、来自 RSet 的“外部引用”(跨代引用)

由于老年代对象可能引用年轻代对象(如 oldObj.field = youngObj

避免全堆扫描:没有 RSetG1 就必须扫描整个老年代来查找跨代引用,Young GCSTW 时间将变得不可控。

  • 扫描范围:CSet = Eden + Survivor RegionRSet

  • 目标:找出 老年代 → 年轻代 的跨代引用(作为额外 Roots)

  • RSet(R5) → 假设得到 {R100, R200}

  • 扫描 R100R200特定的 Card(512B 内存块)

  • 找到实际指向 R5 的引用(比如 objInR100.ref = objInR5

  • objInR100.ref 当作 GC RootR5 中的 objInR5 是存活的

3)标记 & 复制存活对象

  • 从所有 Roots 出发,遍历引用链
  • CSet 中的每个存活对象:
    • 如果年龄够大(或 Survivor 空间不足)→ 晋升到老年代
    • 否则 → 复制到新的 Survivor Region(比如 R13

4)释放 CSet

  • R1~R12 全部清空,变回 空闲 Region
  • 下一轮 Eden 分配可复用这些 Region

5)更新元数据

  • 更新 RSet
    • 在更新 RSet 之前,G1 首先处理脏卡表。脏卡表记录了自上次 GC 以来哪些内存区域发生了变化(例如,可能有新的跨代引用产生)。通过检查这些“脏”区域,G1 可以识别出需要更新 RSet 的部分。
    • 根据脏卡表的信息,G1 找到老年代中指向年轻代对象的所有引用,并将这些信息更新到 RSet 中。这一步骤对于避免误回收非常重要,因为它确保了年轻代中被老年代引用的对象不会被错误地当作垃圾回收掉
  • 记录本次 GC 耗时、回收量等统计信息

五、Mixed GC(混合收集)

Mixed GC 是 在不触发 Full GC 的前提下,逐步清理老年代垃圾,控制停顿时间,避免“全堆扫描”

  • 它不仅回收 所有年轻代 RegionEden + Survivor
  • 还会选择性地回收部分老年代 RegionOld Generation Regions
  • 同时也会清理 大对象 RegionHumongous Regions
特性 说明
触发条件 堆使用率 ≥ IHOP(默认 45%)→ 启动并发标记 → 标记完成后进入 Mixed GC
回收范围 所有 Young Region + 部分 Old Region + Humongous Region
目标 实现“增量式老年代回收”,避免 Full GC
控制停顿 通过 G1MixedGCCountTarget 分多轮执行,每轮控制 STW 时间
避免过度回收 G1HeapWastePercent=5%,达到后提前结束 Mixed GC
风险 Region 不足或并发失败,仍会退化为 Full GC

1、Mixed GC 触发条件

触发逻辑:当 整个 Java 堆的占用率 达到设定阈值(默认 45%)时,G1 会启动一个 并发标记周期(Concurrent Marking Cycle

-XX:InitiatingHeapOccupancyPercent=45  # 默认值 45%

问题1:Mixed GC 到底是几个阶段?

视角 阶段划分 说明
宏观视角 2 个阶段 1. 全局并发标记(准备)
2. 多轮 Mixed GC 执行(回收)
微观视角 多个阶段 标记周期有 4 个子阶段,执行阶段有 NSTW 回收
JVM 日志视角 多次 GC pause (mixed) 每次 STW 回收都是一条独立日志
问题 回答
Mixed GC 是两个阶段吗? 从流程上看,可分为 准备(标记)执行(回收) 两大阶段。
但执行阶段是几次? 通常是 多次 STW 回收(默认最多 8 次),不是一次完成。
全局并发标记是 Mixed GC 一部分吗? 是的,它是 Mixed GC必要前置流程,没有它就没有 Mixed GC

问题2:举个例子?

答案:假设你的应用堆使用率达到 45%,触发 IHOP:

  1. 第1步:启动 并发标记周期(耗时几秒,大部分并发)

  2. 第2步:标记完成,进入 Cleanup,G1 得到一个“回收优先级列表”

  3. 第3步

    :接下来的几次 GC 不再是纯 Young GC,而是:

    • GC pause (mixed) #1:回收 10 个老年代 Region
    • GC pause (mixed) #2:再回收 10 个
    • GC pause (mixed) #8:完成预定目标
  4. 第4步:如果还有高价值 Region,可能继续;否则回到 Young GC

2、Mixed GC 执行策略

为了保证每次 GC 停顿时间可控(由 -XX:MaxGCPauseMillis 控制,默认 200ms),G1 不会一次性回收所有老年代 Region,而是分批进行。

1)-XX:G1MixedGCCountTarget=8(默认 8 次)

举例:如果有 16 个高垃圾率的老年代 Region 要回收,G1 会尽量在 8 次 Mixed GC 中完成,每次回收 ~2 个。

  • 表示:G1 期望将所有待回收的老年代 Region 分成 最多 8 轮 来完成。
  • 每轮 Mixed GC 只回收一部分 Region。
  • 目的是:摊薄总暂停时间,避免某一次 GC 停顿过长。
    • 值越大,每次 Mixed GC 回收得越少,停顿越短,但总回收轮次越多
    • 值越小,每次回收更多,单次停顿略长,但更快清空垃圾

2)-XX:G1HeapWastePercent=5(默认 5%)

  • 含义:在 Mixed GC 过程中,允许“浪费”的空间比例为堆的 5%。
  • 即:当已经回收到 空闲 Region 数量 ≥ 堆总 Region 数 × 5% 时,G1 会提前终止后续的 Mixed GC
  • 为什么需要这个?
    • 因为越往后回收,剩下的 Region 垃圾越少,收益递减。继续回收可能得不偿失(花了很多时间,只腾出一点点空间)。
  • 效果:

    • 避免“过度回收”

    • 提高 GC 吞吐量

    • 减少不必要的 STW

3)动态调整回收集(Collection Set, CSet

G1 在每次 Mixed GC 前都会动态决定本次要回收哪些 Region:

  • 优先选择 垃圾最多RegionGarbage First 策略)
  • 结合 -XX:MaxGCPauseMillis 目标,估算本次能安全回收的 Region 数量
  • 构建 CSet(包含部分 EdenSurvivor 和若干 Old Region

3、阶段1:全局并发标记

目标:给整个堆“拍快照”,找出哪些对象还活着

子阶段 类型 说明
1. 初始标记(Initial Mark) STW 搭载 Young GC,标记 GC Roots 直接引用的对象
2. 并发标记(Concurrent Mark) 并发 遍历对象图,标记所有存活对象
3. 重新标记(Remark) STW 修正并发期间的引用变化(使用 SATB)
4. 清理(Cleanup) 部分 STW + 部分并发 统计存活、释放空 Region、排序回收优先级

1)初始标记 Initial Mark(同 CMS)—— STW

  • 特点:短暂的 Stop-The-WorldSTW),因为 GC Roots 的数量相对较,扫描全堆,标记所有存活对象。
  • 通常搭载 Young GC 执行
    • 复用其 STW 时间,提升效率。
    • 停顿时间 ≈ Young GC 时间(几毫秒)
  • 任务:
    • 扫描 GC Roots(栈、静态变量等)
    • 标记 GC Roots 直接引用的对象。
    • 启动并发标记线程

问题1:为什么初始标记会搭载 Young GC

  • 减少停顿时间Young GC 会 ` Stop The World,而初始标记刚好借着这个停顿时间,做一些额外的标记工作,从而减少 STW` 的时间;
  • 提升效率Young GC 是回收年轻代,而初始标记是标记年轻代和老年代中存活的对象。两者结合,就可以把处理年轻代这个重叠的过程给复用了,提高垃圾收集的效率;

image-20240802174804447

2)并发标记 Concurrent Marking(同 CMS )—— 并发执行

此阶段由并发线程执行,过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。任务包括:

  • GC Root 开始,对堆中所有对象进行可达性分析,,标记存活对象
  • 使用 SATBSnapshot-At-The-Beginning)写屏障,用于后续修正
    • 如果应用断开引用(如 objA.ref = null),会把原引用对象加入缓冲区
    • 确保“标记开始时还活着的对象,不会被漏掉”

3)最终标记:Remark —— STW

由于并发标记阶段应用线程还在运行,可能会产生新的对象或改变对象的引用关系,因此 G1 垃圾收集器需要再次进行短暂的STW,以标记那些在并发标记阶段发生变化的对象。重新标记主要任务:

  • 处理在并发标记期间发生的引用变更(通过 SATB 记录的“已删除引用”)
  • 标记在并发期间新晋升或新分配的存活对象。
  • 完成最终的存活对象标记。

4)清理阶段 Cleanup —— 部分 STW + 部分并发

Cleanup 阶段是 全局并发标记周期(Global Concurrent Marking)的最后一个阶段,它标志着标记周期的结束,并为即将开始的 Mixed GC 做准备。,确定哪些 Region 值得回收。只有完全空的 Region 才会被立即回收。部分存活的 Region 不会在此阶段回收,而是进入后续的 Mixed GC 阶段处理。

Cleanup 阶段在短暂 STW 中完成存活统计、空 Region 释放和 RSet 清理,随后并发地重置空 Region 并构建 CSet 优先级列表。它为 Mixed GC 提供决策依据,但不执行对象复制或内存整理。

a、STW 阶段(短暂暂停):此 STW 阶段非常短暂,仅处理元数据,不移动对象。

阶段 任务 说明
存活对象统计 统计各 Region 的存活对象数量 基于并发标记的结果,计算每个 Region 中存活对象的大小。这是后续 CSet 选择的依据。
Region 识别与释放 识别并释放完全为空的 Region 如果某个 Region存活对象为 0,则将其标记为“可回收”,并立即从老年代/年轻代中移除。
RSet 元数据清理 清理 RSet 中无效条目 释放那些指向已被回收 RegionRSet 条目,减少内存开销。

b、并发阶段(与应用线程并发执行)

阶段 任务 说明
Region 重置与归还 将空 Region 返回空闲列表 被释放的空 Region 会被 G1 并发地重置并加入到堆的空闲Region 列表中,供后续分配使用。
CSet 优先级排序 准备 CSet 优先级列表 G1 会根据 Region 的“垃圾占比”(即存活率低)进行排序,构建一个 回收价值优先级队列,用于 Mixed GC 时选择 CSet

4、阶段2:Mixed GC 回收(多次 STW

轮次 说明
第1次 Mixed GC 回收一部分 CSet(年轻代 + 部分老年代 Region)
第2次 Mixed GC 继续回收下一批高价值 Region
第N次 Mixed GC 直到满足回收目标或达到 G1MixedGCCountTarget

1)构建收集集合(CSet

根据全局并发标记的结果和停顿时间目标,选择部分老年代区域和整个年轻代区域作为 CSetCSetCollection Set)是本次 GC 要回收的 Region 集合。

  • 包含:
    • 所有年轻代 RegionEden + Survivor
    • 部分老年代 Region(按回收价值排序)
    • 可回收的大对象 Region(Humongous)
  • 选择策略:
    • 根据 -XX:MaxGCPauseMillis(默认 200ms)估算可回收的 Region 数量。
    • 优先选择 垃圾占比高、回收成本低 的 Region(即“Garbage-First”策略)。

a:筛选器是如何回收的?

答案:G1 根据并发标记的结果,对每个 Region回收价值(垃圾占比)和回收成本(预估时间)进行评估,按“性价比”排序。结合 -XX:MaxGCPauseMillis 设定的停顿目标,选择一批总耗时不超过该目标的 Region 构成 CSet,在下一次 STW 阶段统一回收。

b:筛选器是如何实现的?

答案:G1 在后台维护一个按“回收效率”排序的优先级列表。每次 Mixed GC 前,优先选择那些单位时间内能回收最多垃圾的 Region。例如:一个 Region50ms 可回收 20MB 垃圾,优于另一个用 200ms 回收 10MB 的 Region。这种策略体现了 G1 “Garbage-First” 的设计思想。

2)执行 GC,移动/拷贝存活对象(STW

CSet 中每个 Region

  • RSet,找出外部引用(如其他 Old Region 引用了它)
  • GC Roots + RSet 引用出发,标记存活对象
  • 将存活对象复制到新的 Region
    • 年轻代对象 → 新的 Survivor 或 老年代
    • 老年代对象 → 其他空闲老年代 Region
  • Region 被清空,内存释放。

3)更新元数据

  • 更新 RSet(新引用关系)
  • 记录回收效果

5、FQA

1)Mixed GC 期间,谁在消耗 CPU ?

  • CSetCollection Set = Eden 区 + Survivor 区 + 若干被选中的 Old Regions(来自并发标记阶段的回收候选列表)。
  • Mixed GCG1 垃圾收集器在完成并发标记后,对 CSet 中包含部分老年代区域的混合回收过程。
  • 整个 Mixed GC 阶段运行于 STWStop-The-World)窗口内
问题 答案
Mixed GC 期间谁消耗 CPU? GC 工作线程(GC Worker Threads
主要消耗在哪? RSet 扫描 + 对象复制(占 80%+ 时间)
应用线程在干嘛? 完全暂停(STW),不消耗 CPU
如何定位? 看 GC 日志中的 [Scan RS][Object Copy] 耗时

五、Full GC (全堆收集)

Fu1l GCG1 最后的防护线,它本是 G1 设计时需要尽量避免的。在 G1 中,Full GC 通常不是由 G1直接触发的,而是在特定情况下(如Mixed GC 无法跟上内存分配速度)会回退到串行老年代收集器( Serial Old GC )进行全堆扫描。Full GC是一个耗时的过程,会停止所有应用线程,直到垃圾收集完成。

1、何时会退化为 Full GC?

原因 说明
晋升失败(Promotion Failure) 老年代无足够空间容纳晋升对象
疏散失败(Evacuation Failure) CSet 中存活对象无法复制到目标 Region
并发模式失败(Concurrent Mode Failure) 并发标记未完成,老年代已满

1)晋升失败(Promotion Failure)

  • 年轻代对象要晋升到老年代
  • 但老年代没有足够的连续 Region(G1 虽然不要求连续内存,但仍需完整 Region)
  • 且无法通过 Mixed GC 快速腾出空间
  • → 触发 Full GC(单线程、Stop-The-World、压缩整个堆)

2)疏散失败(Evacuation Failure)

  • 在 Young GC 或 Mixed GC 中,要将存活对象从原 Region “疏散”(复制)到新 Region
  • 但目标 Region(如 Survivor 或 Old)空间不足
  • 导致对象只能留在原地,引发碎片问题
  • 最终可能触发 Full GC

3) 并发模式失败(Concurrent Mode Failure)

  • 并发标记还没完成,老年代就满了
  • 应用线程无法等待,被迫进入 Full GC

2、 如何避免 Full GC?(调优建议)

问题 解决方案
晋升失败 增加堆大小 / 提前触发并发标记(降低 IHOP
IHOP 默认太高 若应用老年代增长快,可调低 IHOP(如设为 35%
Mixed GC 太慢 调整 G1MixedGCCountTarget 或增大 MaxGCPauseMillis
对象太大太多 减少大对象分配,或增大 G1HeapRegionSize

3、要触发 Full GC 相关参数

G1 主要通过以下几个参数和指标来决定是否需要触发 Full GC

参数 默认值 说明 举例
-XX:G1HeapWastePercent 5% 堆中可以容忍的最大垃圾比例。如果在 Mixed GC 之后,垃圾的比例超过了这个阈值,G1 可能会触发 Full GC 来回收更多的空间 -XX:G1HeapWastePercent=10 意味着 G1将在预计回收超过堆的 10% 时启动混合垃圾收集。
-XX:G1MixedGCLiveThresholdPercent 85% Old 区中的对象占用的比例超过多少时,这部分区域会被包含在 Mixed GC 中,默认 85。如果这个比例设置得太低,可能会导致过多的Old 区域被包含在 Mixed GC 中,进而增加 GC 的工作量和停顿时间,最终可能引发 Full GC -XX:G1MixedGCLiveThresholdPercent=60 表示如果混合垃圾收集后旧区中的存活对象比例低于 60%G1 将尝试在下一次混合垃圾收集中回收更多区域。
-XX:G1MixedGCCountTarget 8 在开始进行 Full GC 之前,可以执行的 Mixed GC的最大次数。如果连续的 Mixed GC 没有有效地回收内存,达到这个次数限制后,G1 可能会触发 Full GC -XX:G1MixedGCCountTarget=4 表示 G1 将尝试在并发标记周期结束时,通过执行至多 4 次混合垃圾收集来清理足够的空间。
-XX:G1ReservePercent 10% 保留的堆内存的百分比,默认是 10,作为一个缓冲区来减少 Full GC的发生。如果可用内存低于这个阈值,G1 可能会触发 Full GC

阈值”并不是指保留区域的百分比本身,而是指堆内存的整体使用情况。具体来说,当堆内存的使用率达到某个临界点(这个临界点取决于多个因素,包括堆内存的大小、G1ReservePercent的设置、GC停顿时间目标等)时,G1垃圾收集器可能会决定触发Full GC来清理内存。然而,由于保留区域的存在,G1在触发Full GC之前通常会有更多的选择和灵活性来管理内存。
-XX:G1ReservePercent=10 表示 JVM 堆中将保留 10% 的空间作为预留空间,用于在并发垃圾收集期间使用。

六、G1CSM 差异

1、相同点

1、目标:两者都旨在通过回收JVM 堆内存中的无用对象来优化内存使用,并减少内存泄漏的风险。

2、Full GC:在特定条件下,G1CMS 都会触发 Full GC,即对整个堆(包括年轻代和老年代)进行垃圾收集。

2、区别

  G1 CMS
适用场景 适用于大内存、多 CPU 的服务端应用 适用于对停顿时间要求较高的应用,如互联网网站或B/S 系统的服务端
垃圾收集算法 基于区域的内存管理,将堆划分为多个 Region,采用标记-整理算法,局部(Region之间)可能采用复制算法 基于标记-清除算法,仅作用于老年代
Full GC时的停顿 在进行Full GC 时需要暂停用户线程 Full GC 时尽量不暂停用户线程,但初始标记和重新标记阶段会 STW
内存碎片 通过整理内存区域,减少了内存碎片 由于采用标记-清除算法,可能会产生内存碎片
内存压缩 支持在垃圾收集时进行内存整理和压缩 不支持内存压缩,特定条件下支持压缩,即UseCMSCompactAtFullCollection设置为true时),CMS是支持在Full GC过程中进行内存压缩整理的。通过合理设置CMSFullGCsBeforeCompaction参数,用户可以在减少内存碎片与降低停顿时间之间找到最佳的平衡点。
CPU资源需求 需要更多的 CPU 资源来运行,以缩短 STW 时间 CPU 资源敏感,但总体需求相对较少
默认性 JDK 9开始,G1 成为默认的垃圾回收器 在早期 JDK 版本中,CMS 是可选的并发垃圾收集器,但不是默认选项

3、优缺点

1)G1 - 优点

1、高效并行与并发,适用于大内存、多核环境

G1 采用了并行和并发的方式进行垃圾收集,可以充分利用多核处理器的计算能力。在回收期间,多个GC 线程可以同时工作,提高了垃圾收集的效率。G1 的设计初衷就是针对拥有多核处理器和大内存的机器,通过并行和并发的方式提高垃圾收集的效率,同时减少停顿时间,满足服务端应用的需求。

b、分代收集

G1 虽然依然区分年轻代和老年代,但不再坚持固定大小和固定数量的堆区域划分。它将堆空间分为若干个区域(Region),这些区域在逻辑上可以是年轻代或老年代的一部分,回收的粒度更细,范围更小,使得垃圾收集更加灵活。

c、空间整合与减少内存碎片

G1 使用标记-整理算法,对内存进行压缩和整理,减少了内存碎片的产生。这种特性有利于程序长时间运行,尤其是在分配大对象时不会因为找不到连续的内存空间而提前触发 GC

d、可预测的停顿时间

G1 允许用户设定 GC 的停顿时间目标,通过跟踪各个 Region 的垃圾堆积价值,优先回收价值最大的 Region,从而在保证吞吐量的同时,尽量满足用户设定的停顿时间要求。

2)G1 - 缺点

a、CPU 资源消耗

G1 在垃圾收集过程中需要多个GC 线程同时工作,这会增加 CPU 的负载。尤其是在高负载情况下,可能会影响应用程序的性能。

b、实现复杂度

G1 引入了新的数据结构和算法(如 RSetCard Table等),使得实现相对复杂。这增加了维护的难度,也提高了出错的概率。

c、在某些场景下吞吐量可能不如其他收集器

虽然 G1 在大多数情况下都能提供较好的性能,但在某些特定场景下(如小内存应用),其吞吐量可能不如其他收集器(如 CMSG1 复杂的数据结构和算法会占用一定的性能;多线程并行执行由于小内存资源有限,多线程并行可能会引发线程资源竞争和上下文切换开销进而降低系统吞吐量 )。

3)CMS - 优点

a、低停顿时间

CMS 采用了并发标记和并发清除的方式,大部分垃圾收集工作都可以与应用程序并发执行,从而减少了用户线程的停顿时间。

b、高吞吐量,与应用程序并发执行

由于 CMS 在并发阶段不会暂停用户线程,因此可以保持较高的应用程序吞吐量,通过并发执行的方式实现这一目标。。

4)CMS -缺点

a、内存碎片

CMS 采用标记-清除算法,可能会导致内存碎片的产生。当内存碎片过多时,可能需要提前触发 Full GC 来整理内存,从而影响性能。

b、对 CPU 资源敏感

CMS 在并发标记和并发清除阶段会占用一部分 CPU 资源,这可能会导致应用程序的吞吐量下降。

c、无法处理浮动垃圾,可能产生”Concurrent Mode Failure

在并发清除阶段,用户线程还在运行,可能会产生新的垃圾对象(浮动垃圾)。这些垃圾对象需要在下一次 GC 时才能被清理,从而增加了GC 的负担。当老年代内存不足以存放新产生的浮动垃圾时, CMS 可能会触发” Concurrent Mode Failure“,导致另一次Full GC 的产生。这会增加停顿时间,并影响性能。

4、什么场景适合 G1

1)50% 以上的堆被存活对象占用

使用 G1 ,就不用特意预留出很大的老年代空间,G1 会根据对象存活状态,动态分配每种不同代对象需要占用的空间。

2)对象分配和晋升的速度变化非常大

前提还是大内存机器才使用 G1,大内存的主机如果对象分配和晋升的速度变化非常快的话,G1 的这种内存设计可以很快的划分出对应所需的区域【区域占比动态增长,不像 CMS 等垃圾收集器要划分固定的空间来区分年轻代和老年代】,但因为 G1 算法比较复杂,在小内存机器里面性能不如 CMS 等主流垃圾收集器。

3)垃圾回收时间特别长,超过1秒,停顿时间是 500ms 以内

G1 有一大好处就是可以设置我们每次想要回收的停顿时间【 -XX:MaxGCPauseMillis】,可以有效提升用户体验。

4)8GB以上的堆内存(建议值)

G1 适合8G以上内存的机器使用【结构设计,2048Region ,内存太小的话每个 Region 也很小,很容易就超过 Region 的一半被识别为超大对象,这样 Humongous 区东西会很多,反而不能很好的进行 GC收集】

七、其他

1、关键参数

参数 说明
XX:+UseG1GC 启用 G1垃圾收集器
-XX:G1HeapRegionSize 每个分区的大小,默认值是会根据整个堆区的大小计算出来,范围是 1M ~ 32M,取值是 2 的幂,计算的倾向是尽量有 2048 个分区数。比如如果是 2Gheap ,那 region = 1M16Gheap, region=8M
-XX:ConcGCThreads 并发执行的线程数,默认值接近整个应用线程数的1/4。
-XX:G1MixedGCCountTarget: 一次全局并发标记之后,后续最多执行的 MixedGC次数。默认值是8.
MaxGCPauseMillis  

2)年轻代

参数 类型 默认 说明 是否可被突破 触发条件 建议
-XX:G1MaxNewSizePercent=60 硬上限   最大年轻代占堆 永远不会超过 流量突增、对象暴涨 可适当提高(如 70),但不要 >80
-XX:G1NewSizePercent=30 软下限   最小年轻代占堆 极端情况下可能低于 老年代占满、堆太小、MaxGCPause 太严 需配合足够堆空间和合理 IHOP
-XX:G1ReservePercent   10 预留堆空间用于 GC 复制,防晋升失败      

2、GC 完整日志

2025-09-18T02:45:51.042+0800: 90821.593: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0098560 secs]
   [Parallel Time: 7.1 ms, GC Workers: 8]
      [GC Worker Start (ms): Min: 90821593.9, Avg: 90821594.0, Max: 90821594.0, Diff: 0.1]
      [Ext Root Scanning (ms): Min: 3.7, Avg: 3.9, Max: 4.6, Diff: 0.9, Sum: 31.2]
      [Update RS (ms): Min: 1.7, Avg: 2.1, Max: 2.2, Diff: 0.5, Sum: 16.7]
         [Processed Buffers: Min: 12, Avg: 19.8, Max: 33, Diff: 21, Sum: 158]
      [Scan RS (ms): Min: 0.1, Avg: 0.3, Max: 0.3, Diff: 0.2, Sum: 2.2]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      [Object Copy (ms): Min: 0.2, Avg: 0.3, Max: 0.5, Diff: 0.3, Sum: 2.7]
      [Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.0, Sum: 0.5]
         [Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 8]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      [GC Worker Total (ms): Min: 6.6, Avg: 6.7, Max: 6.7, Diff: 0.1, Sum: 53.4]
      [GC Worker End (ms): Min: 90821600.6, Avg: 90821600.6, Max: 90821600.7, Diff: 0.0]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.5 ms]
   [Other: 2.2 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 0.3 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 0.1 ms]
      [Humongous Register: 0.1 ms]
      [Humongous Reclaim: 0.0 ms]
      [Free CSet: 0.9 ms]
   [Eden: 5416.0M(5416.0M)->0.0B(5416.0M) Survivors: 4096.0K->4096.0K Heap: 11038.7M(12288.0M)->5622.7M(12288.0M)]
 [Times: user=0.06 sys=0.00, real=0.01 secs] 
2025-09-18T02:45:51.052+0800: 90821.603: [GC concurrent-root-region-scan-start]
2025-09-18T02:45:51.058+0800: 90821.609: [GC concurrent-root-region-scan-end, 0.0062772 secs]
2025-09-18T02:45:51.058+0800: 90821.609: [GC concurrent-mark-start]
2025-09-18T02:45:59.022+0800: 90829.574: [GC concurrent-mark-end, 7.9644062 secs]
2025-09-18T02:45:59.024+0800: 90829.575: [GC remark 2025-09-18T02:45:59.024+0800: 90829.575: [Finalize Marking, 0.0004404 secs] 2025-09-18T02:45:59.024+0800: 90829.576: [GC ref-proc, 0.0007889 secs] 2025-09-18T02:45:59.025+0800: 90829.576: [Unloading, 0.0390341 secs], 0.0496933 secs]
 [Times: user=0.34 sys=0.00, real=0.05 secs] 
2025-09-18T02:45:59.074+0800: 90829.626: [GC cleanup 6360M->6360M(12288M), 0.0111116 secs]
 [Times: user=0.08 sys=0.01, real=0.02 secs] 

1)整体时间线概览

  • 时间范围:2025-09-18 02:45:51.042 ~ 02:45:59.074(共约 8.03 秒)
  • GC 类型G1 收集器的一次 混合收集周期(Mixed GC Cycle)的开始,以 initial-mark 触发并发标记。
  • 堆配置:最大堆 12288 MB(约 12GB)
阶段 类型 耗时 是否 STW 说明
Initial Mark Young GC 9.86ms ✅ 是 触发并发标记,回收了 5.4GB
Root Region Scan Concurrent 6.3ms ❌ 否 快速完成
Concurrent Mark Concurrent 7.96s ❌ 否 较长,注意 CPU 占用
Remark Final Mark 50ms ✅ 是 可接受,但 Unloading 占主导
Cleanup Cleanup 11ms ✅ 是 正常

2) 初始标记阶段(Initial Mark)

2025-09-18T02:45:51.042+0800: 90821.593: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0098560 secs]
...
[Eden: 5416.0M(5416.0M)->0.0B(5416.0M) Survivors: 4096.0K->4096.0K Heap: 11038.7M(12288.0M)->5622.7M(12288.0M)]
[Times: user=0.06 sys=0.00, real=0.01 secs]

这是一个 Young 区域的 Evacuation Pause,同时也承担了 Initial Marking Task(初始标记)。

  • 停顿时长0.009856s ≈ 9.86ms,非常短,属于正常范围。
  • GC 类型:虽然是 young GC,但带有 (initial-mark) 标记
    • 说明它触发了 G1 的并发标记周期(Concurrent Marking Cycle
    • 通常由老年代占用率达到 InitiatingHeapOccupancyPercent(默认 45%)触发。
  • 内存变化:
    • Eden5416MB0MB(全部回收)
    • Survivor4096KB4MB)未变(说明没有晋升或复制)
    • 堆总量:11038.7MB5622.7MB(减少了约 5.4GB):说明本次 Young GC 回收了大量短期对象。

3)并发根区域扫描(Concurrent Root Region Scan

2025-09-18T02:45:51.052+0800: 90821.603: [GC concurrent-root-region-scan-start]
2025-09-18T02:45:51.058+0800: 90821.609: [GC concurrent-root-region-scan-end, 0.0062772 secs]
  • 耗时:约 6.3ms 此阶段是并发执行的,不暂停应用线程。
  • 扫描所有包含可能指向老年代对象的 根区域(Root Regions,为并发标记做准备。时间很短,正常。

4)并发标记(Concurrent Marking

2025-09-18T02:45:51.058+0800: 90821.609: [GC concurrent-mark-start]
2025-09-18T02:45:59.022+0800: 90829.574: [GC concurrent-mark-end, 7.9644062 secs]
  • 持续时间:约 7.96 秒并发执行,应用线程可继续运行。
  • JVM 在后台遍历堆中所有可达对象,标记存活对象。
  • 耗时较长,说明堆中存在大量需要遍历的对象,或 CPU 资源紧张。
  • 若频繁发生长周期并发标记,可能意味着老年代增长较快或对象存活率高。

  • 关注点7.96 秒属于较长的并发标记周期。如果系统对延迟敏感,应关注是否会影响后续的 remark 暂停或整体响应时间。

5)最终标记(Remark)—— STW 阶段

2025-09-18T02:45:59.024+0800: 90829.575: [GC remark 2025-09-18T02:45:59.024+0800: 90829.575: [Finalize Marking, 0.0004404 secs] 2025-09-18T02:45:59.024+0800: 90829.576: [GC ref-proc, 0.0007889 secs] 2025-09-18T02:45:59.025+0800: 90829.576: [Unloading, 0.0390341 secs], 0.0496933 secs]
 [Times: user=0.34 sys=0.00, real=0.05 secs] 
  • STWStop-The-World)暂停
  • 实际暂停时间real=0.05s = 50ms
  • 包含子任务:
    • Finalize Marking: 0.44ms(完成标记)
    • GC ref-proc: 0.79ms(处理软/弱/虚引用)
    • Unloading: 39.03ms(卸载无用类,即类卸载 + 方法区回收)

6)清理阶段(Cleanup)—— 部分 STW

2025-09-18T02:45:59.074+0800: 90829.626: [GC cleanup 6360M->6360M(12288M), 0.0111116 secs]
[Times: user=0.08 sys=0.01, real=0.02 secs]
  • 耗时:约 11msSTW
  • 内存前后都是 6360M → 表示没有释放空间?
    • 实际上,这里的 6360M->6360M 是指堆使用量未变,但 G1 会识别哪些区域可回收,并为后续的 Mixed GC 做准备
  • 主要任务:
    • 统计每个 Region 的存活对象数量
    • 标记完全空的 Region
    • 决定哪些老年代 Region 加入后续的 Mixed GC 集合(CSet
  • 虽然堆使用未降,但这是为后续真正回收做准备。

3、问题

1)停顿时间怎么设置啊,我高流量

-XX:MaxGCPauseMillis目标最大停顿时间(单位:毫秒),默认200 毫秒。 这是一个比较平衡的默认值,适用于大多数通用场景

a、如何设置合理的值?

应用类型 建议值 理由
实时系统 / 高频交易 10 - 30ms 对延迟极度敏感,要求极低的停顿时间。但需注意,过小的值可能导致GC过于频繁,影响整体吞吐量。对于这种场景,也可以考虑使用 ZGC 或 Shenandoah 等超低延迟收集器。
Web 应用 (电商、金融) 50 - 100ms 用户交互型应用,需要快速响应。50-100ms 的停顿通常用户不易察觉。
内部管理系统 / API 服务 100 - 200ms 可以接受稍长的停顿,追求更高的吞吐量和稳定性。
大数据批处理 / 后台任务 200 - 500ms 更注重整体处理速度(吞吐量),对单次停顿不敏感。较大的值可以减少GC频率,提高效率。

b、调整策略与注意事项

  1. 不能太小
    • 如果设置得过小(例如 10ms),G1 为了满足目标,每次只会回收很少的 Region。
    • 这会导致老年代增长很快,Mixed GC(混合回收)变得非常频繁,反而增加了总的 GC 时间,降低了应用的吞吐量。
  2. 不能太大
    • 如果设置得过大(例如 1000ms),虽然 GC 频率会降低,但每次 GC 的停顿时间会很长,用户会明显感觉到卡顿,影响体验。

c、配合其他参数使用

-XX:MaxGCPauseMillis 不是孤立的,它与 G1 的其他参数协同工作。例如:

  • -XX:InitiatingHeapOccupancyPercent (IHOP):控制并发标记的触发时机,避免 Mixed GC 太晚导致 Full GC
  • -XX:G1HeapRegionSize:调整 Region 大小,影响回收粒度。
  • -XX:G1NewSizePercent / -XX:G1MaxNewSizePercent:控制年轻代大小,影响 Young GC 行为。

2)永远不可能3角

低延迟(短暂停) + 高吞吐(高 TPS) + 有限资源(机器性能)

只能三选二:

  • 高吞吐 + 低频率 → 接受稍长停顿(如 300ms
  • 高吞吐 + 短停顿 → 接受更频繁 GC
  • 低频率 + 短停顿 → 需要更大堆或更少对象
         吞吐量高
         /     \
        /       \
  GC频率低       停顿短

a、停顿时间 vs GC 频率

参数设置 MaxGCPauseMillis=100 MaxGCPauseMillis=300
目标停顿 ≤100ms ≤300ms
GC 频率 很高(每 5~10 秒一次) 较低(每 15~30 秒一次)
单次停顿 很短(80~120ms) 稍长(200~300ms)
晋升压力 高(频繁 GC → 更多对象晋升) 低(回收更彻底)
老年代增长 快(容易逼近 11GB) 慢(更安全)
尾部延迟(P99) 可能更高(因频繁 STW) 更稳定(STW 少)
适合场景 大堆 + 低延迟要求 小堆 + 老年代紧张

b、为什么“短停顿”反而可能导致“更差的用户体验”?

  • 单次停顿短 ≠ 体验好
  • 频繁的短停顿 可能比 较少的稍长停顿 更影响用户体验

场景模拟(假设每秒分配 200MB

设置 GC 间隔 每分钟 GC 次数 用户请求被打中的概率 实际体验
100ms 8 秒 ~7.5 次 高(每分钟多次卡顿) “这服务怎么老是卡一下”
300ms 20 秒 ~3 次 “偶尔慢一次,能接受”

3)为什么GC时看不到接口耗时变长

  • GC 停顿 vs 接口耗时:关系图解
时间轴:
t=0     t=100   t=150                t=400               t=800
|       |       |                    |                   |
        |---- 请求A (400ms->650ms) -----------------------------|
                |======== GC STW (250ms) =========|
                                          |---- 请求B (400ms->650ms) ----|
时间 事件
t=50ms 请求A 开始处理
t=150ms G1 触发 Young GC → JVM 发出 STW 信号
t=150ms 所有应用线程被强制暂停(包括处理请求A的线程)
t=150~400ms GC 线程工作,应用线程“冻结”
t=400ms GC 结束 → JVM 恢复所有应用线程
t=400ms 请求A 从被暂停的地方继续执行
t=800ms 请求A 最终完成(400ms 执行时间 + 250ms 等待)
  • STW 是全局暂停:所有 Java 线程都停,不只是“新请求”
  • 影响的是“活跃请求”:在 GC 时刻正在运行的线程都会被卡
  • 监控要看 P99/P999:因为只有部分请求被打中,平均值看不出来

a、常见原因

原因 说明 如何验证
GC 停顿短(<300ms 你的接口平均 RT 是 500ms,一次 200msGC 被平均掉了 查看 P99/P999 延迟,不是平均值
GC 不频繁 每分钟 1 次 GC,只有 1% 的请求被打中 查看 GC 时间戳 vs 请求时间戳
监控粒度太粗 Prometheus 抓的是 1 分钟平均 RT,掩盖了尖刺 用 APM(如 SkyWalking、Pinpoint)看单次请求
GC 发生在空闲期 凌晨 3 点 GC,没人访问 对比 GC 日志时间流量曲线
应用线程未满 8 核 CPU,只用了 4 个线程,GC 时其他线程还能处理请求 查看 CPU 利用率、线程池状态
使用了异步/队列 请求入队就返回,GC 不影响响应时间 这是“掩盖”而非“消除”

b、GC 真的会停顿,但你“看不见”是因为

问题 解释
GC 会停顿吗? 会!所有线程暂停,真实 STW
为什么接口耗时没变? 被平均了(看 P99) 时间没对齐(查日志) 监控粒度粗(换 APM)
是否可以忽略? 不能! 尾部延迟、用户体验、SLA 都受影响

八、内存问题

1、大对象全量刷新

  • 总对象数:1300 万 Demo + 外层 map 结构
  • 内存占用:约 1.5~2.0G
  • 刷新频率:每天 4:20 一次
// 全局引用(长期存活 → 老年代)
private Map<String, Map<String, List<Demo>>> oldMap;

// 刷新时
Map<String, Map<String, List<Demo>>> newMap = buildNew(); // 1300万对象,~1.8G

omdMap = newMap; // 老年代字段 now 引用新 map(可能在年轻代)

1)监控信息

image-20251212174652198

图表 现象 说明
youngGC耗时/10s 在 04:56 出现 1200ms 峰值 单次 YGC 耗时超 1 秒 → 接口卡死
老年代内存 从 7GB → 10.5GB → 5GB 刷新导致老年代暴涨,依赖 Mixed GC 清理
堆使用率 从 70% → 90% → 60% 内存压力剧烈波动
Full GC 0 次 G1 成功避免 Full GC

结论:

  • 当前 YGC 耗时高达 1.2 秒直接导致接口超时、连接断开
  • 这不是“偶尔抖动”,而是每晚必发的严重问题

2)GC 日志

a、场景 1:正常 YGC(刷新前)

2025-12-12T04:07:44.172+0800: [GC pause (G1 Evacuation Pause) (young), 0.0072087 secs]
[Parallel Time: 5.3 ms, GC Workers: 8]
  [Ext Root Scanning (ms): Min: 2.2, Avg: 2.9, Max: 4.7, Sum: 22.9]
  [Update RS (ms): Min: 0.2, Avg: 1.8, Max: 2.3, Sum: 14.5]
  [Scan RS (ms): Min: 0.0, Avg: 0.2, Max: 0.2, Sum: 1.3]   ← 极低!
[Eden: 4940.0M->0.0B Heap: 11894.6M->6954.4M]
  • [Scan RS] 总和仅 1.3ms → RSet 条目少,跨代引用少
  • YGC 耗时 7ms → 正常

b、场景 2:刷新后 YGC(问题爆发)

2025-12-12T04:58:28.354+0800: [GC pause (G1 Evacuation Pause) (young), 0.1178551 secs]
[Parallel Time: 115.7 ms, GC Workers: 8]
  [Ext Root Scanning (ms): Min: 2.9, Avg: 3.3, Max: 4.4, Sum: 26.5]
  [Update RS (ms): Min: 7.4, Avg: 8.3, Max: 8.7, Sum: 66.5]
  [Scan RS (ms): Min: 103.5, Avg: 103.6, Max: 103.7, Sum: 828.8]  ← 爆炸!
[Eden: 660.0M->0.0B Heap: 5707.4M->5047.0M]

根因锁定:RSet 扫描开销剧增

  • [Scan RS] 总和 828.8ms(单线程平均 103.6ms)→ 增长 600+ 倍!
  • YGC 耗时 117ms → 接近监控图中的 1200ms/10s(多次 YGC 累积)

为什么 RSet 会爆炸?

  1. 新构建的 newMap 及其所有子对象(1300 万)先分配在 Eden
    • 监控为啥显示老年代呢?
      • 因为对象在第一次 Young GC 前就被老年代引用,导致 Survivor 溢出,直接晋升到老年代
  2. oldMap(老年代)引用 → 产生海量“老年代 → 年轻代”跨代引用
  3. G1 写屏障为每个跨代引用记录到 RSet
  4. YGC 时必须扫描这些 RSet[Scan RS] 时间飙升

c、场景3:Mixed GC 开始回收(老年代下降)

025-12-12T04:58:32.817+0800: [GC pause (G1 Evacuation Pause) (young), 0.0193663 secs]
[Parallel Time: 17.6 ms, GC Workers: 8]
  [Scan RS (ms): Min: 5.0, Avg: 5.1, Max: 5.2, Sum: 40.7]  ← 仍高,但下降
[Heap: ... ->5047.0M]
  • [Scan RS]828ms40ms → 说明部分旧对象已被回收
  • 老年代从 5707M5047MMixed GC 生效

4)结合监控图的完整事件链

时间 事件 GC 行为 监控表现
04:20 刷新开始 构建新缓存 老年代稳定 7GB
04:56~04:58 新对象分配 Eden 快满 → Survivor 溢出 → 大量对象晋升 老年代暴涨至 10.5GB
04:58:28 YGC 触发 [Scan RS] = 828msYGC 耗时 117ms youngGC耗时/10s = 1200ms
04:58:32 Mixed GC 启动 清理高垃圾比例Region 老年代快速下降

5)核心结论

  • 直接原因Young GC[Scan RS] 耗时高达 828ms,导致单次 YGC 停顿达 117ms,引发接口 P99 延迟飙升。
  • 根本原因:全局变量 oldMap(位于老年代)在缓存刷新后引用了一个 刚分配在 Eden1.8G 大对象图,触发以下连锁反应:
    1. 写屏障记录海量 “老年代 → 年轻代”跨代引用
    2. RSetRemembered Set)条目暴增至百万级;
    3. 首次 Young GCSurvivor 空间不足,新对象快速晋升至老年代
    4. 老年代内存突增(+3.5GB),旧缓存垃圾需等待 Mixed GC 回收。

image-20251212183043557

6)拆分/分区

虽然数据总量没变,但拆分后让 JVM “看得更清楚、扫得更快、扔得更轻松”,从而避免了每天 4:20 刷新时的 GC 卡顿。

// 不拆分:
map → [1.8G Map]  // 1 个引用,指向 1300 万个对象

// 拆分后:
map → [Array(8192)] → [分区0][分区1]...[分区8191]
                              ↑      ↑           ↑
                           ~225KB ~225KB      ~225KB

a、RSet 扫描减少:核心收益,

关键洞察:JVM 的写屏障只记录“直接引用”,不递归记录内部对象引用。

  • oldMap.get(a).get(b) 这种链式调用中:

    • 栈帧中的局部变量会临时持有 List<Demo> 的引用
    • 如果该 List 在老年代(大概率),而栈在年轻代(Eden/Survivor),
    • G1 的写屏障(pre-write barrier)会记录:young region → old region 的引用关系
    • 这个 old regionRSet 就会增加一条 entry。
    • 1300 万条数据 × 高频查询 = 百万级 RSet 条目,导致 Mixed GC 中 [Scan RS] 阶段耗时飙升(50~100ms 很常见)。
  • 拆分后: 线程栈只直接引用数组元素(桶根),后续访问都在桶内部

    • 外部只直接引用 8192 个桶(数组元素)

      int idx = vendor.hashCode() & 8191;
      venderInsMap[idx].get(vendor).get(insurance);
      
    • 桶内部的对象(MapListDemo)之间引用属于 同一 Region 或同代内引用不需要写入 RSet

    • 因此 RSet 条目 ≈ 8192(仅桶根被外部引用),Scan RS 时间从几十 ms 降到 1~3ms。

b、提高 G1 回收效率:生死分明,回收更高效

  • G1Evacuation 阶段要复制存活对象
  • MapRegion 存活对象分散 → 复制效率低
    • 即使 Map 本身死了,Region 里还有别的活对象 → 整个 Region 不能直接释放;
  • 小桶的 Region 要么全活(热桶),要么全死(冷桶)→ 复制/释放更高效,CSet 处理更快

c、降低 Full GC 风险

  • 如果 1300 万对象一次性晋升,短时间内大量 Region 被填满,G1 可能:
    • 短时间内大量 Region 被填满,来不及启动并发标记;;
    • 没有足够空闲 Region 执行复制,导致无法进行有效的 Mixed GC,只能 fallbackFull GC
    • 触发 Full GC(Serial Old)STW 达秒级
  • 拆分后让对象分批进入老年代,给 G1 留出时间:
    • 避免在并发标记完成前就耗尽老年代空间
    • 启动并发标记周期;在后续 Mixed GC 中逐步回收和压缩;
    • 维持足够的空闲 Region 缓冲区(由 -XX:G1ReservePercent 控制)。

d、总结

好处 说明 对你场景的价值
RSet 扫描减少 跨 Region 引用从 1300 万 → 8196 解决 4:20 GC 卡顿(核心)
内存局部性提升 对象紧凑,CPU 缓存友好 查询更快,P99 延迟更低
降低 Full GC 风险 无大对象,回收平滑 系统更稳定,避免秒级停顿
提升 Evacuation 效率 桶内对象生死分明 GC 更快,Mixed GC 更高效
便于监控治理 可观测每个桶大小 快速定位数据倾斜问题

ContactAuthor