G1 的流程
bash
+------------------------------------+
| 触发条件 |
| - 内存使用达到阈值 |
| - 系统调用 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 初始标记 (STW) |
| - 标记 GC Roots 引用的对象 |
| - 暂停时间较短 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 并发标记 (无 STW) |
| - 遍历对象图 |
| - 标记所有可达对象 |
| - 记录引用变更 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 最终标记 (STW) |
| - 处理引用变更 |
| - 完成所有存活对象的标记 |
| - 暂停时间较短 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 筛选回收 (部分 STW) |
| - 选择高收益 Region |
| - 清理选定 Region |
| - 移动存活对象 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 结束 |
+------------------------------------+
bash
[开始 Young GC]
│
├─ ⏸️ 初始标记(STW 1ms)
│ → 找到 GC Roots(比如栈里的 user 变量)
│
├─ ▶ 并发标记(200ms,程序正常跑!)
│ → GC 线程从 user 出发,标记 User → Order → Item...
│ → 你的程序同时在处理新订单、改地址......
│ → 每次改引用,写屏障默默记录
│
├─ ⏸️ 最终标记(STW 2ms)
│ → 检查写屏障日志:"哦,有个 Address 被断开了?赶紧标上!"
│
└─ ▶ 筛选回收
→ 把垃圾最多的 Region 清掉,活对象搬走
G1 的核心流程口诀
"一标(初始)停,二标(并发)跑,
三标(最终)停,四筛(部分 STW)扫。
整理移,少碎片,
高效收,暂停少。"
CMS 的流程
bash
+------------------------------------+
| 触发条件 |
| - 老年代使用达到阈值 |
| - 系统调用 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 初始标记 (STW) |
| - 标记 GC Roots 引用的对象 |
| - 暂停时间较短 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 并发标记 (无 STW) |
| - 遍历对象图 |
| - 标记所有可达对象 |
| - 记录引用变更 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 重新标记 (STW) |
| - 处理引用变更 |
| - 完成所有存活对象的标记 |
| - 暂停时间较短 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 并发清除 (无 STW) |
| - 回收未标记的垃圾对象 |
| - 不移动对象,只释放内存 |
+-----------------+------------------+
|
v
+-----------------+------------------+
| 结束 |
+------------------------------------+
CMS 的核心流程口诀
"一标(初始)停,二标(并发)跑,
三标(重新)停,四清(并发)扫。
不整理,有碎片,
卡顿时,Full GC 来救场。"
CMS + ParNew 的整体 Young/Old 回收配合
| 区域 | 回收器 | 算法 | 是否 STW |
|---|---|---|---|
| 年轻代 | ParNew | 多线程标记-复制 | ✅ 全程 STW(但很快) |
| 老年代 | CMS | 并发标记-清除 | ⏸️ 仅初始+重新标记 STW |
| 回收器 | 年轻代算法 | 老年代算法 | 是否分代 |
|---|---|---|---|
| CMS | ParNew(多线程复制) | Concurrent Mark-Sweep(并发标记-清除) | ✅ 是 |
| G1 | G1 Young GC(分区复制) | G1 Mixed GC(分区复制 + 并发标记) | ✅ 是(但弱化分代) |
| 维度 | CMS | G1 |
|---|---|---|
| 年轻代算法 | ParNew(多线程复制) | G1 Young GC(分区复制) |
| 老年代算法 | CMS(并发标记-清除) | G1 Mixed GC(并发标记-分区复制) |
| 是否移动对象 | ❌ 老年代不移动 | ✅ 全堆移动(复制) |
| 内存碎片 | ✅ 严重(老年代) | ❌ 无 |
| Full GC 风险 | ⚠️ 高(碎片导致) | ✅ 极低 |
| 停顿可预测性 | 中(Remark 阶段可能较长) | 高(可设 MaxGCPauseMillis) |
| 大堆支持 | 差(>64GB 易失控) | 优秀(TB 级) |
| 对比维度 | CMS | G1 |
|---|---|---|
| 设计目标 | 最小化老年代停顿时间 | 可控停顿 + 高吞吐 + 无碎片 |
| 内存模型 | 严格分代: Eden / Survivor / 老年代(连续) | 分区(Region): 堆划分为等大 Region,可动态扮演 Eden/Survivor/Old |
| 回收范围 | 只回收老年代 (年轻代靠 ParNew) | 混合回收: 一次 GC 可同时回收年轻代 + 部分老年代 |
| 是否整理内存 | ❌ 不整理 → 内存碎片严重 | ✅ 整理(Region 间复制)→ 无碎片 |
| 写屏障用途 | 记录新增引用(增量更新) | 记录跨 Region 引用(维护 Remembered Set) |
| 漏标解决方案 | 增量更新(Incremental Update) → 重新扫描新引用 | SATB(Snapshot-At-The-Beginning) → 保留"开始时"的快照,记录消失的引用 |
| Full GC 风险 | ⚠️ 高(碎片导致) | ✅ 极低(自动避免碎片) |
| 是否废弃 | ✅ JDK 14 移除 | ✅ JDK 9+ 默认回收器 |
1️⃣ 内存布局:分代 vs 分区
CMS:老年代是一整块连续内存。清除后留下"洞",大对象可能放不下。
G1:堆被切成 2048 个左右的 Region(默认 1~32MB),每个 Region 独立管理。
→ 回收时只选"垃圾最多的几个 Region",局部整理,全局无碎片。
🧩 就像:
CMS:清理整栋老楼,但房间大小不一,留空隙
G1:把城市分成小区,每次只拆重建几个最破的小区
屏障的作用?
2️⃣ 屏障(Barrier)虽然都有,但干的事不同!
✅ CMS 的写屏障:监控"新引用"
当代码执行 A.ref = B(A 在老年代,B 在老年代),
写屏障会记录:"A 现在引用了 B"
在 重新标记阶段,CMS 会重新扫描这些"新引用",防止漏标
🎯 目标:确保新产生的引用被标记到
✅ G1 的写屏障:监控"跨 Region 引用"
G1 的核心难题:一个 Region 里的对象,可能被其他 Region 引用
写屏障的作用:当 A(在 R1).ref = B(在 R2),
自动在 R2 的 Remembered Set(记忆集) 中记录:"R1 引用了我"
Young GC 时,只需扫描 Eden 区 + 被引用的老年代 Region 的 Remembered Set,不用扫整个老年代!
🎯 目标:加速根扫描,避免全堆遍历
💡 所以:CMS 的屏障为"标记正确性"服务,G1 的屏障为"分区效率"服务。
最终清理(如回收内存)虽然可能部分并发,但关键操作(如释放内存块、更新空闲列表)通常发生在"安全点"或短暂 STW 阶段,确保应用线程不会访问即将被回收的对象。
CMS 清垃圾,不动活对象;
死者无人问,清扫可并发;
只怕碎片多,Full GC 来救场!
🎯 核心原则:宁可错杀(多标),不可漏杀(少标)
"标",指的是「标记活着的对象」。
那怎么实现的不漏标呢?
CMS 如何防止?→ 增量更新(Incremental Update)
CMS 的思路是:
"只要引用关系变了,我就重新扫描源头!"
具体做法:
当发生 C.ref = B(老年代内部引用变更)
写屏障记录:"C 新增了一个引用"
在 重新标记阶段(STW),CMS 会:
把所有"新增引用的源头"(如 C)重新加入扫描队列
从 C 出发,再次遍历 → 发现 B → 标记 B 为活
✅ 结果:B 不会被漏掉
🧠 类比:
老师点名时,班长报告:"刚才小明坐到了第三排!"
老师立刻回头确认:"第三排的小明,到!" → 不会漏点。
⚠️ 但 CMS 有个弱点:
如果 A.ref = null 导致 B 只被 C 引用,而 C 本身是活的,CMS 能靠"新增引用"发现 B。
但如果引用是 断开 + 重建 的复杂链,理论上仍有极小风险(实践中极少发生)。
G1 如何防止?→ SATB(Snapshot-At-The-Beginning)
G1 的思路更激进:
"我不管中间怎么变,只要在标记开始时它是活的,我就保它到底!"
具体做法:
在 初始标记阶段,拍一张"快照":记录当时所有存活对象
当发生 A.ref = null(断开对 B 的引用)
写屏障捕获这个"消失的引用"
把 B 加入待处理队列(即使 B 后来被 C 引用,也不影响)
在 最终标记阶段,强制标记 B 为活
✅ 结果:B 绝对不会被回收
🧠 类比:
上课铃响时(标记开始),小明在教室 → 老师说:"今天小明算到课!"
即使小明中途溜去厕所(A 不再指向 B),甚至被隔壁班拉去帮忙(C 指向 B),
老师依然认为他到课了 ------ 因为铃响时他在!
💡 SATB 的优势:天然防漏标,即使引用链断裂又重建,也不怕。
ZGC / Shenandoah:更极致的保障
它们用 读屏障 + 转发指针:
只要你的程序试图访问一个对象,
读屏障会检查它是否已被标记为"待回收"
如果是,就自动阻止回收或重定向
✅ 相当于:"只要你还想用,我就不能收!"
| 阶段 | G1 | CMS |
|---|---|---|
| 标记阶段是否需要 STW? | ✅ 是(最终标记) | ✅ 是(重新标记) |
| 清理/清除阶段是否需要 STw? | ⚠️ 部分需要(复制存活对象) | ❌ 不需要(纯并发) |
| 为什么清理能并发? | 因为要移动对象,需同步 | 因为只释放死对象,而死对象已不可达 |
🔑 关键区别:G1 要"移动活对象",CMS 只"释放死对象"。
| 问题 | CMS | G1 |
|---|---|---|
| 是否有 STW? | ✅ 有(初始标记 + 重新标记) | ✅ 有(初始标记 + 最终标记) |
| 清除/回收阶段是否并发? | ✅ 是(只释放死对象) | ⚠️ 部分并发(复制活对象需同步) |
| 为什么清除能并发? | 死对象已不可达,程序碰不到 | ------ |
| 如果程序突然引用一个"死对象"? | 不可能!因为那就说明它不是死的 | 同上 |
| 最大风险 | 内存碎片 → Full GC | 混合回收停顿略长 |
| 回收器 | 如何处理死对象? | 内存布局变化 | 是否产生碎片 |
|---|---|---|---|
| CMS | 标记后直接释放死对象的内存块 (就地清除) | 老年代仍是一整块,但内部有"空洞" | ✅ 严重碎片 |
| G1 | 不处理死对象!只把活对象复制走 (整 Region 丢弃) | 源 Region 变为空闲,可复用 | ❌ 无碎片 |
🔑 G1 根本不在乎"死对象在哪"------它只关心"活对象去哪"。
🧠 类比:
CMS:打扫房间,把垃圾一件件扔出去,但家具位置不动 → 地板有空隙
G1:直接把整间脏屋子(Region)废弃,搬进新装修的房子 → 旧屋子推平重建