一边工作一边打扫卫生,会不会漏掉垃圾?三色标记算法解决这个世纪难题!
🎬 开场:并发GC的挑战
生活场景 🏠
想象你在打扫房间:
传统方式(STW) ❌:
1. 喊:"大家都别动!"(Stop The World)
2. 扫描所有房间,标记垃圾
3. 清理垃圾
4. 喊:"好了,可以继续活动了!"
问题:
- 所有人都被暂停了
- 房间大了,暂停时间长
- 用户体验差!
并发方式(Concurrent) ✅:
1. 一边让大家继续活动
2. 一边扫描房间标记垃圾
3. 一边清理垃圾
挑战:
- 你在扫描时,别人可能:
* 扔新垃圾
* 把垃圾藏起来
* 把有用的东西扔了
- 怎么确保不漏掉垃圾?🤔
🎯 三色标记算法的核心思想
三种颜色的含义 🎨
┌──────────────────────────────────────┐
│ 白色(White) │
│ ═════════════ │
│ 未被访问的对象 │
│ 可能是垃圾,也可能是活对象 │
│ GC结束后,白色对象会被回收 │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ 灰色(Gray) │
│ ═════════════ │
│ 已被访问,但子对象还没被扫描 │
│ 在"待处理队列"中 │
│ 需要继续扫描其引用的对象 │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ 黑色(Black) │
│ ═════════════ │
│ 已被访问,且子对象已扫描完 │
│ 确定是活对象 │
│ 不会再被扫描 │
└──────────────────────────────────────┘
颜色转换流程 🔄
对象生命周期:
初始状态:
所有对象 → 白色 ⚪
GC开始:
GC Roots → 灰色 ⚪
扫描过程:
1. 从灰色队列取一个对象
2. 扫描它引用的所有白色对象
3. 这些白色对象 → 灰色
4. 当前对象 → 黑色
最终状态:
黑色对象:存活 ⚫ → 保留
白色对象:垃圾 ⚪ → 回收
🎪 三色标记的详细过程
示例场景 📚
对象引用关系:
A → B → D
A → C → E
C → F
初始状态(所有对象都是白色):
A⚪ → B⚪ → D⚪
↓
C⚪ → E⚪
↓
F⚪
标记过程 🔍
步骤1:标记GC Roots
A是GC Root(比如栈上的引用)
A⚪ → 灰色 ⚫
灰色队列:[A]
状态:
A⚫(灰) → B⚪ → D⚪
↓
C⚪ → E⚪
↓
F⚪
步骤2:处理A
1. 从灰色队列取出A
2. 扫描A的引用:B和C
3. B⚪ → 灰色, C⚪ → 灰色
4. A 灰色 → 黑色
灰色队列:[B, C]
状态:
A⚫(黑) → B⚫(灰) → D⚪
↓
C⚫(灰) → E⚪
↓
F⚪
步骤3:处理B
1. 从灰色队列取出B
2. 扫描B的引用:D
3. D⚪ → 灰色
4. B 灰色 → 黑色
灰色队列:[C, D]
状态:
A⚫(黑) → B⚫(黑) → D⚫(灰)
↓
C⚫(灰) → E⚪
↓
F⚪
步骤4:处理C
1. 从灰色队列取出C
2. 扫描C的引用:E和F
3. E⚪ → 灰色, F⚪ → 灰色
4. C 灰色 → 黑色
灰色队列:[D, E, F]
状态:
A⚫(黑) → B⚫(黑) → D⚫(灰)
↓
C⚫(黑) → E⚫(灰)
↓
F⚫(灰)
步骤5:处理D、E、F
1. D没有引用,直接变黑
2. E没有引用,直接变黑
3. F没有引用,直接变黑
灰色队列:[](空)
最终状态:
A⚫(黑) → B⚫(黑) → D⚫(黑)
↓
C⚫(黑) → E⚫(黑)
↓
F⚫(黑)
所有对象都是黑色 → 全部存活!
😱 并发标记的问题:对象消失
什么是对象消失?
场景:
1. GC线程正在并发标记
2. 应用线程同时在修改对象引用
3. 可能导致:活对象被误判为垃圾!💥
对象消失的条件 🎯
浮动垃圾(Floating Garbage):可以容忍
- 本应该回收的垃圾,这次GC没回收
- 下次GC会回收
- 无害!
对象消失:绝对不能容忍!❌
- 本应该存活的对象,被误回收了
- 程序崩溃!💥
对象消失的两个充要条件 📋
条件1:黑色对象新增了指向白色对象的引用
条件2:原来指向白色对象的灰色对象删除了引用
同时满足这两个条件 → 白色对象会"消失"!
详细示例 💣
初始状态:
A⚫(黑) → B⚫(灰) → D⚪(白)
↓
C⚫(灰)
GC线程的想法:
- A已经扫描完(黑色),不会再看A
- B还要扫描(灰色),会扫描到D
- D会被标记为存活
此时,应用线程执行了两个操作:
操作1:A.ref = D (黑色对象引用白色对象)
操作2:B.ref = null(删除灰色到白色的引用)
变成:
A⚫(黑) → D⚪(白) ← A已经扫描过,不会再看!
↓
B⚫(灰) → null ← B也找不到D了!
↓
C⚫(灰)
结果:
- GC扫描完B和C
- D仍然是白色
- D被当成垃圾回收!💥
- 但实际上A还在引用D!
- 程序崩溃!
🛡️ 解决方案1:增量更新(Incremental Update)
核心思想 💡
破坏条件1:不允许黑色对象直接引用白色对象!
实现方式 ✍️
使用写后屏障(Post-Write Barrier):
// 伪代码
public void updateReference(Object obj, Object newRef) {
// 正常的引用更新
obj.field = newRef;
// 写后屏障
if (obj是黑色 && newRef是白色) {
// 把黑色对象重新标记为灰色!
// 或者把白色对象标记为灰色
markGray(obj); // 重新加入扫描队列
}
}
效果 📊
应用线程:A.ref = D
写后屏障触发:
- 检测到:A(黑) → D(白)
- 动作:A 黑色 → 灰色(重新标记)
状态:
A⚫(灰) → D⚪ ← A重新变灰,会再次扫描
↓
B⚫(灰) → null
↓
C⚫(灰)
GC继续扫描:
- 处理A时,发现A → D
- D 白色 → 灰色 → 黑色
- D被保留!✅
使用增量更新的GC 🏷️
- CMS GC(Concurrent Mark Sweep)
- G1 GC(部分阶段)
🛡️ 解决方案2:原始快照(SATB)
核心思想 💡
破坏条件2:保留灰色对象删除引用前的状态!
SATB = Snapshot At The Beginning(开始时的快照)
实现方式 ✍️
使用写前屏障(Pre-Write Barrier):
// 伪代码
public void updateReference(Object obj, Object newRef) {
// 写前屏障(在引用更新之前)
Object oldRef = obj.field; // 读取旧引用
if (oldRef != null && oldRef是白色) {
// 把旧引用标记为灰色!
markGray(oldRef);
}
// 正常的引用更新
obj.field = newRef;
}
效果 📊
应用线程:B.ref = null
写前屏障触发:
- 读取旧值:oldRef = D
- 检测到:D是白色
- 动作:D 白色 → 灰色
状态:
A⚫(黑) → D⚫(灰) ← D被标记为灰色!
↓
B⚫(灰) → null
↓
C⚫(灰)
GC继续扫描:
- 处理D
- D 灰色 → 黑色
- D被保留!✅
使用SATB的GC 🏷️
- G1 GC(主要使用)
- Shenandoah GC
- ZGC
🎯 增量更新 vs SATB
对比表 📋
| 特性 | 增量更新 | SATB |
|---|---|---|
| 破坏条件 | 条件1(黑→白) | 条件2(灰删除引用) |
| 屏障类型 | 写后屏障 | 写前屏障 |
| 实现 | 黑色对象重新标记为灰色 | 旧引用标记为灰色 |
| 浮动垃圾 | 较少 | 较多 |
| 重新标记时间 | 较长 | 较短 |
| 使用GC | CMS | G1, Shenandoah, ZGC |
浮动垃圾对比 🗑️
增量更新:
- 只保留新增的引用
- 浮动垃圾:本轮GC新产生的垃圾
SATB(原始快照):
- 保留所有开始时的引用
- 浮动垃圾:本轮GC删除引用后的垃圾 + 新产生的垃圾
- 更多浮动垃圾!
例如:
开始时:A → B → C
并发标记中:B.ref = null(删除B→C的引用)
SATB:
- C仍然被标记为存活(保留快照)
- C是浮动垃圾,下次GC才回收
增量更新:
- 如果扫描到B时,B.ref已经是null
- C可能被回收(如果没有其他引用)
🎪 实战案例
案例1:G1 GC的SATB
# G1 GC日志
[GC pause (G1 Evacuation Pause) (young)
[Update RS: 5.0 ms] ← 更新RSet
[Scan RS: 10.0 ms] ← 扫描RSet
[Object Copy: 20.0 ms]
[SATB Filtering: 2.0 ms] ← SATB过滤
[Termination: 1.0 ms]
45.0 ms]
# SATB Filtering:
# - 处理SATB标记的对象
# - 过滤掉已经是黑色的对象
# - 只处理白色和灰色的对象
案例2:CMS的增量更新
# CMS GC日志
[GC (CMS Initial Mark) ← 初始标记(STW)
[1 CMS-initial-mark: 2000M(4000M)]
0.05 secs]
[CMS-concurrent-mark-start] ← 并发标记开始
[CMS-concurrent-mark: 1.5 secs] ← 并发标记
[GC (CMS Final Remark) ← 最终重新标记(STW)
[YG occupancy: 500M]
[Rescan (parallel): 0.1 secs] ← 重新扫描(增量更新的对象)
[weak refs processing: 0.05 secs]
[class unloading: 0.02 secs]
0.2 secs]
# Final Remark阶段:
# - 重新扫描并发标记期间改变的对象
# - 使用增量更新记录的信息
🔧 写屏障的性能开销
性能测试 📊
public class BarrierOverhead {
static class Node {
Node next;
}
public static void main(String[] args) {
Node head = new Node();
Node current = head;
// 创建100万个节点
for (int i = 0; i < 1_000_000; i++) {
current.next = new Node();
current = current.next;
}
// 测试引用更新性能
long start = System.nanoTime();
current = head;
for (int i = 0; i < 10_000_000; i++) {
current.next = new Node(); // 触发写屏障
}
long end = System.nanoTime();
System.out.println("耗时: " + (end - start) / 1_000_000 + "ms");
}
}
// 结果(近似):
// 无写屏障:100ms
// 有写屏障:110-120ms
// 性能损耗:10-20%
优化策略 🚀
// 1. 批量操作优化
public void batchUpdate(List<Object> objects) {
// JVM会尝试批量处理写屏障
for (Object obj : objects) {
updateReference(obj);
}
}
// 2. 避免频繁更新同一个引用
// ❌ 不好的做法
for (int i = 0; i < 1000000; i++) {
obj.field = new Object(); // 每次都触发写屏障
}
// ✅ 更好的做法
Object temp = null;
for (int i = 0; i < 1000000; i++) {
temp = new Object(); // 使用局部变量
}
obj.field = temp; // 只触发一次写屏障
🎓 面试高频问题
Q1: 为什么需要三色标记?
A:
因为要实现并发标记:
- 一边GC线程标记对象
- 一边应用线程修改引用
三色标记的优势:
1. 清晰地表示对象的标记状态
2. 可以暂停和恢复标记过程
3. 支持增量标记
如果没有三色标记:
- 只能STW(Stop The World)
- 停顿时间长
- 用户体验差
Q2: 增量更新和SATB哪个更好?
A:
没有绝对的好坏,各有优劣:
增量更新(CMS):
✅ 浮动垃圾少
✅ 内存利用率高
❌ 重新标记时间长
❌ 实现复杂
SATB(G1):
✅ 重新标记时间短
✅ 实现简单
❌ 浮动垃圾多
❌ 可能需要更多内存
选择依据:
- 追求低延迟:SATB(G1, ZGC)
- 追求高吞吐:增量更新(CMS)
Q3: 写屏障的性能开销大吗?
A:
开销:10-20%左右
但是值得:
- 写屏障的开销:每次引用更新多几条指令
- 并发标记的收益:大幅减少STW时间
例如:
无并发标记:Full GC停顿5秒
有并发标记:并发标记1秒 + Final Remark 0.2秒
停顿时间:5秒 → 0.2秒,减少96%!
写屏障性能影响:10-20%
停顿时间减少:96%
总体:性能大幅提升!
🎨 总结:三色标记的精髓
┌──────────────────────────────────────┐
│ 三色标记一句话总结 │
├──────────────────────────────────────┤
│ 用"颜色"追踪对象标记状态, │
│ 用"屏障"解决并发修改问题! │
│ │
│ 白色:未访问(可能是垃圾) │
│ 灰色:已访问但未扫描完(待处理) │
│ 黑色:已扫描完(确定存活) │
│ │
│ 核心:防止对象消失!🎯 │
└──────────────────────────────────────┘
记住四个关键点:
-
三色标记支持并发GC 🎨
- 白色→灰色→黑色的转换过程
- 可以暂停和恢复
-
对象消失问题必须解决 😱
- 条件1:黑→白引用
- 条件2:灰删除引用
-
两种解决方案 🛡️
- 增量更新:破坏条件1(写后屏障)
- SATB:破坏条件2(写前屏障)
-
写屏障是关键 ✍️
- 自动插入,程序员无感知
- 性能开销10-20%,但停顿时间大幅减少
下次面试官问三色标记,你就说:
"三色标记是并发GC的核心算法,用白灰黑三种颜色表示对象的标记状态。并发标记时会出现对象消失问题:黑色对象新增白色引用,且灰色对象删除对白色对象的引用,导致白色对象被误回收。解决方案有两种:增量更新使用写后屏障,把黑色对象重新标记为灰色;SATB使用写前屏障,保留原始引用快照。G1使用SATB,CMS使用增量更新。写屏障有10-20%性能开销,但换来的是96%以上的停顿时间减少!" 🎓
🎉 掌握三色标记,理解并发GC的核心! 🎉