JVM之_12_G1
前言
Github:https://github.com/HealerJean
一、G1 介绍
G1GC,全称Garbage-FirstGarbageCollector,通过-XX:+UseG1GC参数来启用,作为体验版随着JDK 6u14版本面世,在JDK 7u4版本发行时被正式推出,相信熟悉JVM的同学们都不会对它感到陌生。在JDK9中,G1被提议设置为默认垃圾收集器(JEP 248)。
-
G1收集器的设计目标:G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色:G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。G1的StopTheWorld(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。 -
G1收集器的应用场景:G1是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。它是专门针对以下应用场景设计的:-
像
CMS收集器一样,能与应用程序线程并发执行。 -
整理空闲空间更快。
-
需要
GC停顿时间更好预测。 -
不希望牺牲大量的吞吐性能。
-
不需要更大的
JavaHeap。
-
二、G1 中几个重要概念
1、分区 Region
传统的
GC收集器:连续的内存空间划分为新生代、老年代和永久代(JDK8去除了永久代,引入了元空间Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。如下图所示:

1)Region 大小和数量有多少?
默认情况:G1 将堆内存划分为多个大小相等的 Region,JVM 在自动计算 Region Size 时,目标是让 Region 数量接近 2048,但只是一个启发式初始值,不是硬性规定,Region 的最大数量是 65536(即 2^16)。每个 Region 的大小通常是堆内存大小除以2048 ,但也可以通过 JVM 参数 -XX:G1HeapRegionSize 来手动指定 Region 的大小。需要注意的是,这个参数的值必须是 2 的幂,且范围在 1MB 到 32MB之间。
实际配置:在实际使用中,JVM 会根据堆内存的大小自动计算 Region 的数量,但也可以通过调整堆内存大小和-XX:G1HeapRegionSize 参数来间接控制 Region 的数量(手动指定 Region大小)。
-
例如,如果堆内存大小为
4096MB,且未指定-XX:G1HeapRegionSize参数,则默认每个Region的大小为2MB(4096MB/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:
-
如果显式设置了
-XX:G1HeapRegionSize=N,推荐用于生产环境,避免自动计算不一致 -
如果未设置,则
JVM自动计算:
- 目标:堆大小 ÷ ~2048 ≈
RegionSize - 但结果必须是: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并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换,如下图所示:

3)Region 数量和大小的关系是什么?
答案:当堆内存总量固定时,如果 Region的数量较多,那么每个 Region的大小就会相应减小。例如,如果堆内存是 4GB,你可以选择有 2048 个 Region (每个 Region 大约 2MB ),也可以选择有 1024 个 Region(每个 Region 大约 4MB),以此类推。
4) Region 数量较多好处
1、更好的内存利用率
答案:假设 JVM 的堆内存总量为 4GB,如果选择较少的 Region 数量(比如 512 个 Region,每个 Region 大约 8MB ),那么当有大量小对象被创建时,这些对象可能会分散在多个 Region中,导致内存碎片的产生。而如果选择较多的 Region 数量(比如 2048个Region,每个 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 可能用于以下场景:
- 空闲
Region(FreeRegions):G1维护一个全局的空闲Region列表(FreeList),用于动态分配给年轻代或老年代。 - 未使用的堆空间:
JVM可能未将堆的全部容量划分为Region,例如保留部分空间作为安全边际。 G1内部管理结构:G1需要额外的Region存储元数据(如RememberedSets、CardTable等),这些可能不直接计入各代。
7)类别理解
| 概念 | 类比 |
|---|---|
| 堆内存 | 一座城市 |
| Region | 城市中的街区(A区、B区…) |
| Card | 街区里的门牌号段(每512户一个段) |
| Card Table | 市政监控系统:标记“哪些门牌段最近有快递寄出”(Dirty) |
| RSet | 每个街区的“收件人登记簿”:记录“哪些其他街区的哪些门牌段给我寄过快递” |
| 写屏障 | 快递员寄快递时,必须登记寄件人地址 |
2、巨形对象 Humongous Region
1)什么是巨型对象?
答案:一个大小达到甚至超过分区大小一半的对象称为巨型对象( Humongous Object )。
2)巨型对象会直接进入老年代吗?
答案:G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1 有专门分配大对象的 Region 叫 Humongous 区,而不是让大对象直接进入老年代的 Region中
3)那巨型对象不直接进入老年代作用是什么呢?
答案:Humongous 区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的 GC开销。
4)既不直接进入老年代,那humongous区域大对象什么时候回收
答案:其实很简单,在新生代和老年代回收的时候,就会顺带着对大对象一并回收了,所以这就是 G1 内存模型下对大对象的分配和回收的策略。G1 垃圾收集器在处理巨型对象时,会避免进行拷贝操作,因为巨型对象的拷贝成本很高。在回收时,G1 会优先考虑回收那些没有巨型对象的 Region,以减少拷贝成本和提高回收效率。
5)如果 Region 放不下怎么办?
答案:如果一个H 区装不下一个巨型对象,那么G1会寻找连续的 H 分区来存储。G1 的大多数行为都把 Humongous Region作为老年代的一部分来进行看待(但还是不太一样哦)
6)如何减少 humongous 对象影响
- 大对象变普通对象:为了减少连续
H-objs分配对GC的影响,需要把大对象变为普通的对象,建议增大Regionsize(前提是已知有大对象,否则建议默认)。一个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数, - 监控
HumongousAllocation:开启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 Table占2MB=2097152/1024/1024
- 数组长度 = 堆大小 / 卡片大小 =
-
每个元素是一个字节,记录该
Card是否“脏”(即是否有老年代对象引用新生代对象)。 -
Card Table (2 MB) +-------------------+-------------------+ ... +-------------------+ | Card 0 | Card 1 | ... | Card 2097151 | +-------------------+-------------------+ ... +-------------------+
c、Region 与Card 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 |
1 个 Card 属于且仅属于 1 个 Region |
| 映射方式 | Region 是堆的物理划分 |
Card 是堆的逻辑划分,与地址线性对应 |
假设堆 = 2MB,RegionSize = 1MB,CardSize = 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 = 2048个Card - 总 Card 数:
4MB / 512B = 8192个 →Card Table有8192个字节
堆内存(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 Queue(DCQ),未来构建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,记录“哪些其他Region的Cards引用了本Region。
Card Table是构建RSet的底层支持。
1)Dirty Card 的处理由以下两者共同完成
Concurrent Refinement线程(异步、并发)- 在应用运行期间(非
STW),后台线程持续消费Dirty Card Queue - 解析引用关系,更新目标
Region 的 RSet
- 在应用运行期间(非
GC线程(同步、STW阶段兜底)- 当
Young GC或Mixed GC触发时 - 如果还有未被
Refinement处理的Dirty Cards GC线程会在STW期间亲自扫描这些Cards,确保RSet逻辑完整
- 当
b、为什么 Card 大小是 512 字节?
这是为了平衡空间开销和查找效率。
- 如果
Card太小,Card Table会非常大; - 如果
Card太大,精度会降低。
| 矛盾方向 | Card 太小(如 256B) |
Card 太大(如 2KB) |
|---|---|---|
Card Table 空间开销 |
更大(表项更多) | 更小 |
| 扫描精度 / 冗余扫描 | 更精确(只扫少量对象) | 更粗糙(可能扫很多无关对象) |
| 写屏障开销 | 更频繁更新(更多卡被标记) | 较少更新 |
- 扫描效率
- 当一个
Card被标记为Dirty,GC需要扫描整个 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、SATB(Snapshot At The Beginning)写屏障技术的应用
SATB(SnapshotAtTheBeginning)写屏障是一种特殊的写屏障技术,它主要用于解决并发标记算法中的漏标问题。
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. 结束:所有黑色对象为存活对象,白色对象为垃圾,可回收

2)漏标问题
CMS:“见黑生白,黑变灰” → 发现黑指向白,把黑变灰重扫。G1:“断链保白,白入栈” → 引用断开时,把白对象保存起来继续扫。
| 特性 | CMS(Incremental Update) |
G1(SATB) |
|---|---|---|
| 触发动作 | 新增引用:C.field = B(黑 → 白) |
删除引用:B.field = null(灰 → 白断开) |
| 保护谁? | 保护黑色对象(重标为灰) | 保护白色对象(推入日志) |
| 写屏障时机 | 引用写入后 | 引用覆盖前(记录旧值) |
| 哲学 | “不要漏掉新引用” | “不要丢掉快照中的存活对象” |
| 暂停时间 | 最终 Remark 阶段可能较长(需重新扫描黑对象) |
更可预测(并发处理 SATB 日志) |
适用 GC |
CMS |
G1, ZGC(变种) |
3)CMS:Incremental 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)G1:SATB(Snapshot-At-The-Beginning)
关注引用的删除:在并发标记开始时拍下对象图快照;若之后某灰色对象断开对白色对象的引用(灰 → 白消失),则将该白色对象记录下来,确保它仍被扫描,不会被误回收。
a、初始状态:
初始状态(并发标记开始,快照已建立):
┌─────────┐ ┌─────────┐ ┌─────────┐
Root → │ A │──────▶│ B │──────▶│ C │
│ (Gray) │ │ (Gray) │ │ (White) │
└─────────┘ └─────────┘ └─────────┘
→ 快照认为:A → B → C 是一条存活链,C 虽白但应存活。
b、并发阶段:用户线程执行 B.field = null;(断开 B → C)
- 假设
B是灰色(正在被扫描),C是白色(尚未被标记) - 用户代码断开了
B对C的引用- 问题:
C在快照中是存活的,但现在没有活跃引用指向它 → 如果不处理,C会被当成垃圾回收!
- 问题:
并发修改后:
┌─────────┐ ┌─────────┐ ┌─────────┐
Root → │ A │──────▶│ B │ │ C │
│ (Gray) │ │ (Gray) │ │ (White) │
└─────────┘ └─────────┘ └─────────┘
×(引用被删除)
c、G1 的应对:SATB(Snapshot-At-The-Beginning)
- 写屏障在
B.field = null执行前,捕获原引用值C - 将
C推入SATB日志缓冲区(log buffer) - 并发标记线程会定期处理这些日志,将
C重新加入标记队列(标为灰色)- 这样即使
B不再指向C,C仍会被GC扫描 → 避免漏标
- 这样即使
6)为什么 G1 采用 SATB 而不用 incremental update ?
核心原因:G1· 是基于
Region的分代 + 并发GC,其内存模型和回收策略决定了 Incremental Update 的开销不可接受,而SATB更契合其“局部回收 + 可预测停顿”的设计目标。
a、根本差异:GC 架构不同
G1必须知道“哪些 Region 被哪些其他 Region 引用” → 这依赖Remembered Set(RSet),而RSet的构建与 SATB 高度协同。
| 特性 | CMS(使用 Incremental Update) |
G1(使用 SATB) |
|---|---|---|
| 堆结构 | 连续的老年代 + 新生代 | 离散的 Region 集合(每个 Region 可属任意代) |
| 回收单位 | 整个老年代(或全堆) | 部分 Region(Collection Set) |
| 并发标记目标 | 标记整个老年代 | 标记所有 Region,但只回收部分 |
b、Incremental Update 何效率低
G1的核心思想是:只回收垃圾最多的若干Region(CSet),无需关心全局。- 但如果用
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 不回收,留到下次。- 但这可接受:
-
- 浮动垃圾 ≠ 内存泄漏(下次会回收)
- G1 通过
Mixed GC逐步清理老年代,容忍少量浮动垃圾 - 正确性 > 完美回收率
-
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、层级结构(两级索引)
结构:每个
Region的RSet包含一个 哈希表(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时可直接使用。
-
用户线程执行赋值
objInR1.field = objInR5; // R1 → R5,跨 Region 引用 -
写屏障触发:
JVM在每次引用写入时插入写屏障代码- 将
objInR1所在的Card标记为Dirty(在Card Table中) - 把该
Card地址放入Dirty Card Queue(DCQ)
-
Concurrent Refinement线程(后台线程):- 从
DCQ中取出DirtyCard - 扫描
Card内存,解析Card内存内容,找出所有跨Region引用 - 例如发现:
R1的Card #123引用了R5的对象
- 从
-
更新
RSet:将(R1, Card#123)记录到R5的RSet中 -
Young GC/Mixed GC触发时:如果DCQ中还有未处理的Dirty Cards- 所有缓冲区中是否还有未处理的
Dirty Cards? - 如果有 → 必须在
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的状态:-
如果
R1和R3也在本次 GC 中被回收 → 它们的引用即将消失,可忽略; -
如果 R7 不在回收集合中(仍存活) →
R5中被R7引用的对象 必须保留;
-
-
仅回收
R5中 未被任何存活Region引用的对象。
4)效果:
- 实现了 安全的老年代增量回收;
- 避免了
Full GC(FullGC会STW整个堆,停顿时间长); - 支撑了
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)RSet 在 Young GC 中如何使用
扫描范围从“整个老年代” → “
RSet中列出的几个Region的部分Card
- 目标:回收年轻代
Region - 挑战:
- 传统分代
GC的问题:每次Young GC都需扫描整个老年代,以确认是否有老年代对象引用年轻代对象。 - 这些
Region中的对象可能被老年代引用 → 不能回收
- 传统分代
- 解法:仅扫描
RSet(年轻代 Region)中列出的源Region;- 例如:
RSet(R5) = {R1, R3, R7}→ 只需检查R1、R3、R7中的相关Card。
- 例如:
- 实时:对每个待回收的年轻代
Region R:- 查看
RSet(R) - 如果非空,遍历其中记录的 源
Region + Card - 扫描这些
Card,找出实际引用 - 将这些引用作为
GC Roots的一部分,防止误回收
- 查看
6)RSet 在 Mixed 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
CollectionSet,是指在垃圾收集过程中被回收的Region集合,也可以理解为是垃圾收集暂停过程中被回收的目标
- 这些
Region可能包括年轻代(YoungGeneration)的Eden区、Survivor区,以及老年代(OldGeneration)的某些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提供了两种主要的垃圾收集模式:YoungGC和MixedGC。FullGC在G1中不是直接提供的,但在特定情况下(如MixedGC无法跟上内存分配速度)会回退到串行老年代收集器(SerialOldGC)进行全堆扫描。与
CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

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

1、触发条件
Eden区空间耗尽:当年轻代中的Eden区域被新创建的对象填满,无法再分配新的对象时,理论上会触发Young GC。然而,在G1中,这并不是立即发生的。- 回收时间预估:
G1收集器会计算当前Eden区的回收所需时间。如果这个预估时间远小于由参数-XX:MaxGCPauseMillis设定的最大暂停时间目标,那么G1可能会选择增加年轻代的region数量,允许更多的对象分配,而不是立即进行垃圾回收。 - 接近设定值时触发:只有当回收
Eden区的时间接近或达到-XX:MaxGCPauseMillis参数所设定的值时,才会真正触发Young GC。
2、详细流程
- 复制算法:一旦触发
Young GC,会采用复制算法进行垃圾回收。在这个过程中,存活的对象会被复制到另一个区域,通常是Survivor区域(如S0或S1),也可能根据具体情况直接晋升到老年代。 Stop the World事件:在执行Young GC期间,整个应用会暂时停止(即“Stop the World”状态),以便 JVM 安全地进行垃圾回收操作。这段时间内所有的用户线程都会被挂起。- 清理
Eden区:完成对象的复制后,原先Eden区中对应的Region将被清空,回收其中的空间以供后续的新对象分配使用。
1)确定回收集合(CSet)
G1会在遵循用户设置的GC暂停时间上限的基础上,选择一个最大年轻代区域数,将这个数量的所有年轻代区域(Eden区和Survivor区)作为收集集合(Collection Set,简称CSet)。
2)找出所有 GC Roots
G1在Young GC的STW阶段,并不会对整个堆做可达性分析,而是聚焦于CSet(Collection Set = Eden + Survivor Regions) 中的对象是否存活。为此,它从以下两类“根”出发进行标记:
a、标准 GC Roots
注意:如果这些
Roots直接引用了 老年代对象,Young GC会忽略该引用(因为目标不在CSet中),不会递归扫描老年代内部。
这些是 JVM 定义的全局可达起点,包括:
- 虚拟机栈中 活跃栈帧的局部变量 引用的对象
- 方法区中 类的静态属性(static fields) 引用的对象
JNI(本地代码)持有的 强引用对象
b、来自 RSet 的“外部引用”(跨代引用)
由于老年代对象可能引用年轻代对象(如
oldObj.field = youngObj)避免全堆扫描:没有
RSet,G1就必须扫描整个老年代来查找跨代引用,Young GC的STW时间将变得不可控。
-
扫描范围:
CSet=Eden+SurvivorRegion的RSet: -
目标:找出 老年代 → 年轻代 的跨代引用(作为额外 Roots)
-
查
RSet(R5)→ 假设得到{R100, R200} -
扫描
R100和R200中 特定的Card(512B 内存块) -
找到实际指向
R5的引用(比如objInR100.ref = objInR5) -
把
objInR100.ref当作GC Root,R5中的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 的前提下,逐步清理老年代垃圾,控制停顿时间,避免“全堆扫描”
- 它不仅回收 所有年轻代
Region(Eden+Survivor) - 还会选择性地回收部分老年代
Region(Old Generation Regions) - 同时也会清理 大对象
Region(Humongous 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 个子阶段,执行阶段有 N 轮 STW 回收 |
JVM 日志视角 |
多次 GC pause (mixed) |
每次 STW 回收都是一条独立日志 |
| 问题 | 回答 |
|---|---|
Mixed GC 是两个阶段吗? |
从流程上看,可分为 准备(标记) 和 执行(回收) 两大阶段。 |
| 但执行阶段是几次? | 通常是 多次 STW 回收(默认最多 8 次),不是一次完成。 |
全局并发标记是 Mixed GC 一部分吗? |
是的,它是 Mixed GC 的必要前置流程,没有它就没有 Mixed GC。 |
问题2:举个例子?
答案:假设你的应用堆使用率达到 45%,触发 IHOP:
-
第1步:启动 并发标记周期(耗时几秒,大部分并发)
-
第2步:标记完成,进入
Cleanup,G1 得到一个“回收优先级列表” -
第3步
:接下来的几次
GC不再是纯YoungGC,而是:GC pause (mixed)#1:回收 10 个老年代 RegionGC pause (mixed)#2:再回收 10 个- …
GC pause (mixed)#8:完成预定目标
-
第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 轮 来完成。 - 每轮
MixedGC只回收一部分 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:
- 优先选择 垃圾最多 的
Region(GarbageFirst策略) - 结合
-XX:MaxGCPauseMillis目标,估算本次能安全回收的 Region 数量 - 构建
CSet(包含部分Eden、Survivor和若干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-World(STW),因为GC Roots的数量相对较,扫描全堆,标记所有存活对象。 - 通常搭载
YoungGC执行- 复用其
STW时间,提升效率。 - 停顿时间 ≈
Young GC时间(几毫秒)
- 复用其
- 任务:
- 扫描
GCRoots(栈、静态变量等) - 标记
GCRoots直接引用的对象。 - 启动并发标记线程
- 扫描
问题1:为什么初始标记会搭载 Young GC?
- 减少停顿时间:
Young GC会 ` Stop The World,而初始标记刚好借着这个停顿时间,做一些额外的标记工作,从而减少STW` 的时间; - 提升效率:
Young GC是回收年轻代,而初始标记是标记年轻代和老年代中存活的对象。两者结合,就可以把处理年轻代这个重叠的过程给复用了,提高垃圾收集的效率;

2)并发标记 Concurrent Marking(同 CMS )—— 并发执行
此阶段由并发线程执行,过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。任务包括:
- 从
GCRoot开始,对堆中所有对象进行可达性分析,,标记存活对象 - 使用
SATB(Snapshot-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 中无效条目 |
释放那些指向已被回收 Region 的 RSet 条目,减少内存开销。 |
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)
根据全局并发标记的结果和停顿时间目标,选择部分老年代区域和整个年轻代区域作为 CSet。CSet(Collection Set)是本次 GC 要回收的 Region 集合。
- 包含:
- 所有年轻代
Region(Eden+Survivor) - 部分老年代
Region(按回收价值排序) - 可回收的大对象
Region(Humongous)
- 所有年轻代
- 选择策略:
- 根据
-XX:MaxGCPauseMillis(默认 200ms)估算可回收的 Region 数量。 - 优先选择 垃圾占比高、回收成本低 的 Region(即“Garbage-First”策略)。
- 根据
a:筛选器是如何回收的?
答案:G1 根据并发标记的结果,对每个 Region 的回收价值(垃圾占比)和回收成本(预估时间)进行评估,按“性价比”排序。结合 -XX:MaxGCPauseMillis 设定的停顿目标,选择一批总耗时不超过该目标的 Region 构成 CSet,在下一次 STW 阶段统一回收。
b:筛选器是如何实现的?
答案:G1 在后台维护一个按“回收效率”排序的优先级列表。每次 Mixed GC 前,优先选择那些单位时间内能回收最多垃圾的 Region。例如:一个 Region 用50ms 可回收 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 ?
CSet(Collection Set) =Eden区 + Survivor 区 + 若干被选中的 Old Regions(来自并发标记阶段的回收候选列表)。Mixed GC是G1垃圾收集器在完成并发标记后,对CSet中包含部分老年代区域的混合回收过程。- 整个
Mixed GC阶段运行于STW(Stop-The-World)窗口内。
| 问题 | 答案 |
|---|---|
Mixed GC 期间谁消耗 CPU? |
GC 工作线程(GC Worker Threads) |
| 主要消耗在哪? | RSet 扫描 + 对象复制(占 80%+ 时间) |
| 应用线程在干嘛? | 完全暂停(STW),不消耗 CPU |
| 如何定位? | 看 GC 日志中的 [Scan RS] 和 [Object Copy] 耗时 |
五、Full GC (全堆收集)
Fu1l GC 是 G1 最后的防护线,它本是 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% 的空间作为预留空间,用于在并发垃圾收集期间使用。 |
六、G1 和 CSM 差异
1、相同点
1、目标:两者都旨在通过回收
JVM堆内存中的无用对象来优化内存使用,并减少内存泄漏的风险。2、
FullGC:在特定条件下,G1和CMS都会触发FullGC,即对整个堆(包括年轻代和老年代)进行垃圾收集。
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引入了新的数据结构和算法(如RSet、CardTable等),使得实现相对复杂。这增加了维护的难度,也提高了出错的概率。
c、在某些场景下吞吐量可能不如其他收集器
虽然
G1在大多数情况下都能提供较好的性能,但在某些特定场景下(如小内存应用),其吞吐量可能不如其他收集器(如CMS,G1复杂的数据结构和算法会占用一定的性能;多线程并行执行由于小内存资源有限,多线程并行可能会引发线程资源竞争和上下文切换开销进而降低系统吞吐量 )。
3)CMS - 优点
a、低停顿时间
CMS采用了并发标记和并发清除的方式,大部分垃圾收集工作都可以与应用程序并发执行,从而减少了用户线程的停顿时间。
b、高吞吐量,与应用程序并发执行
由于
CMS在并发阶段不会暂停用户线程,因此可以保持较高的应用程序吞吐量,通过并发执行的方式实现这一目标。。
4)CMS -缺点
a、内存碎片
CMS采用标记-清除算法,可能会导致内存碎片的产生。当内存碎片过多时,可能需要提前触发FullGC来整理内存,从而影响性能。
b、对 CPU 资源敏感
CMS在并发标记和并发清除阶段会占用一部分CPU资源,这可能会导致应用程序的吞吐量下降。
c、无法处理浮动垃圾,可能产生”Concurrent Mode Failure“
在并发清除阶段,用户线程还在运行,可能会产生新的垃圾对象(浮动垃圾)。这些垃圾对象需要在下一次
GC时才能被清理,从而增加了GC的负担。当老年代内存不足以存放新产生的浮动垃圾时,CMS可能会触发”ConcurrentModeFailure“,导致另一次FullGC的产生。这会增加停顿时间,并影响性能。
4、什么场景适合 G1
1)50% 以上的堆被存活对象占用
使用
G1,就不用特意预留出很大的老年代空间,G1会根据对象存活状态,动态分配每种不同代对象需要占用的空间。
2)对象分配和晋升的速度变化非常大
前提还是大内存机器才使用
G1,大内存的主机如果对象分配和晋升的速度变化非常快的话,G1的这种内存设计可以很快的划分出对应所需的区域【区域占比动态增长,不像CMS等垃圾收集器要划分固定的空间来区分年轻代和老年代】,但因为G1算法比较复杂,在小内存机器里面性能不如CMS等主流垃圾收集器。
3)垃圾回收时间特别长,超过1秒,停顿时间是 500ms 以内
G1有一大好处就是可以设置我们每次想要回收的停顿时间【-XX:MaxGCPauseMillis】,可以有效提升用户体验。
4)8GB以上的堆内存(建议值)
G1适合8G以上内存的机器使用【结构设计,2048个Region,内存太小的话每个Region也很小,很容易就超过Region的一半被识别为超大对象,这样Humongous区东西会很多,反而不能很好的进行GC收集】
七、其他
1、关键参数
| 参数 | 说明 |
|---|---|
XX:+UseG1GC |
启用 G1垃圾收集器 |
-XX:G1HeapRegionSize |
每个分区的大小,默认值是会根据整个堆区的大小计算出来,范围是 1M ~ 32M,取值是 2 的幂,计算的倾向是尽量有 2048 个分区数。比如如果是 2G 的 heap ,那 region = 1M。16Gheap, 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收集器的一次 混合收集周期(MixedGCCycle)的开始,以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%)触发。
- 说明它触发了
- 内存变化:
Eden:5416MB→0MB(全部回收)Survivor:4096KB(4MB)未变(说明没有晋升或复制)- 堆总量:
11038.7MB→5622.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此阶段是并发执行的,不暂停应用线程。 - 扫描所有包含可能指向老年代对象的 根区域(
RootRegions),为并发标记做准备。时间很短,正常。
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]
STW(Stop-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]
- 耗时:约
11ms(STW) - 内存前后都是
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、调整策略与注意事项
- 不能太小:
- 如果设置得过小(例如
10ms),G1为了满足目标,每次只会回收很少的 Region。 - 这会导致老年代增长很快,
Mixed GC(混合回收)变得非常频繁,反而增加了总的 GC 时间,降低了应用的吞吐量。
- 如果设置得过小(例如
- 不能太大:
- 如果设置得过大(例如
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,一次 200ms 的 GC 被平均掉了 |
查看 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)监控信息

| 图表 | 现象 | 说明 |
|---|---|---|
| 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 会爆炸?
- 新构建的
newMap及其所有子对象(1300万)先分配在Eden- 监控为啥显示老年代呢?
- 因为对象在第一次
Young GC前就被老年代引用,导致Survivor溢出,直接晋升到老年代
- 因为对象在第一次
- 监控为啥显示老年代呢?
- 被
oldMap(老年代)引用 → 产生海量“老年代 → 年轻代”跨代引用 G1写屏障为每个跨代引用记录到 RSetYGC时必须扫描这些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]从828ms→40ms→ 说明部分旧对象已被回收- 老年代从
5707M→5047M→ Mixed GC 生效
4)结合监控图的完整事件链
| 时间 | 事件 | GC 行为 | 监控表现 |
|---|---|---|---|
| 04:20 | 刷新开始 | 构建新缓存 | 老年代稳定 7GB |
| 04:56~04:58 | 新对象分配 | Eden 快满 → Survivor 溢出 → 大量对象晋升 |
老年代暴涨至 10.5GB |
| 04:58:28 | YGC 触发 |
[Scan RS] = 828ms → YGC 耗时 117ms |
youngGC耗时/10s = 1200ms |
| 04:58:32 | Mixed GC 启动 |
清理高垃圾比例Region |
老年代快速下降 |
5)核心结论
- 直接原因:
Young GC中[Scan RS]耗时高达 828ms,导致单次 YGC 停顿达 117ms,引发接口 P99 延迟飙升。 - 根本原因:全局变量
oldMap(位于老年代)在缓存刷新后引用了一个 刚分配在Eden的1.8G大对象图,触发以下连锁反应:- 写屏障记录海量 “老年代 → 年轻代”跨代引用;
RSet(Remembered Set)条目暴增至百万级;- 首次
Young GC时Survivor空间不足,新对象快速晋升至老年代; - 老年代内存突增(
+3.5GB),旧缓存垃圾需等待Mixed GC回收。

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 region的RSet就会增加一条 entry。 - 1300 万条数据 × 高频查询 = 百万级 RSet 条目,导致 Mixed GC 中
[Scan RS]阶段耗时飙升(50~100ms 很常见)。
- 栈帧中的局部变量会临时持有
-
拆分后: 线程栈只直接引用数组元素(桶根),后续访问都在桶内部
-
外部只直接引用 8192 个桶(数组元素);
int idx = vendor.hashCode() & 8191; venderInsMap[idx].get(vendor).get(insurance); -
桶内部的对象(
Map、List、Demo)之间引用属于 同一 Region 或同代内引用,不需要写入 RSet; -
因此 RSet 条目 ≈ 8192(仅桶根被外部引用),Scan RS 时间从几十 ms 降到 1~3ms。
-
b、提高 G1 回收效率:生死分明,回收更高效
G1在Evacuation阶段要复制存活对象- 大
Map的Region存活对象分散 → 复制效率低- 即使
Map本身死了,Region里还有别的活对象 → 整个Region不能直接释放;
- 即使
- 小桶的
Region要么全活(热桶),要么全死(冷桶)→ 复制/释放更高效,CSet处理更快
c、降低 Full GC 风险
- 如果
1300万对象一次性晋升,短时间内大量Region被填满,G1可能:- 短时间内大量
Region被填满,来不及启动并发标记;; - 没有足够空闲
Region执行复制,导致无法进行有效的Mixed GC,只能fallback到FullGC - 触发
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 更高效 |
| 便于监控治理 | 可观测每个桶大小 | 快速定位数据倾斜问题 |


