🎨 三色标记算法:并发GC的黑科技!

63 阅读10分钟

一边工作一边打扫卫生,会不会漏掉垃圾?三色标记算法解决这个世纪难题!


🎬 开场:并发GC的挑战

生活场景 🏠

想象你在打扫房间:

传统方式(STW) ❌:

1. 喊:"大家都别动!"(Stop The World)
2. 扫描所有房间,标记垃圾
3. 清理垃圾
4. 喊:"好了,可以继续活动了!"

问题:
- 所有人都被暂停了
- 房间大了,暂停时间长
- 用户体验差!

并发方式(Concurrent) ✅:

1. 一边让大家继续活动
2. 一边扫描房间标记垃圾
3. 一边清理垃圾

挑战:
- 你在扫描时,别人可能:
  * 扔新垃圾
  * 把垃圾藏起来
  * 把有用的东西扔了
- 怎么确保不漏掉垃圾?🤔

🎯 三色标记算法的核心思想

三种颜色的含义 🎨

┌──────────────────────────────────────┐
│  白色(White)                        │
│  ═════════════                        │
│  未被访问的对象                       │
│  可能是垃圾,也可能是活对象           │
│  GC结束后,白色对象会被回收           │
└──────────────────────────────────────┘

┌──────────────────────────────────────┐
│  灰色(Gray)                         │
│  ═════════════                        │
│  已被访问,但子对象还没被扫描         │
│  在"待处理队列"中                     │
│  需要继续扫描其引用的对象             │
└──────────────────────────────────────┘

┌──────────────────────────────────────┐
│  黑色(Black)                        │
│  ═════════════                        │
│  已被访问,且子对象已扫描完           │
│  确定是活对象                         │
│  不会再被扫描                         │
└──────────────────────────────────────┘

颜色转换流程 🔄

对象生命周期:

初始状态:
所有对象 → 白色 ⚪

GC开始:
GC Roots → 灰色 ⚪

扫描过程:
1. 从灰色队列取一个对象
2. 扫描它引用的所有白色对象
3. 这些白色对象 → 灰色
4. 当前对象 → 黑色

最终状态:
黑色对象:存活 ⚫ → 保留
白色对象:垃圾 ⚪ → 回收

🎪 三色标记的详细过程

示例场景 📚

对象引用关系:
AB → 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会被标记为存活

此时,应用线程执行了两个操作:
操作1A.ref = D  (黑色对象引用白色对象)
操作2B.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(灰删除引用)
屏障类型写后屏障写前屏障
实现黑色对象重新标记为灰色旧引用标记为灰色
浮动垃圾较少较多
重新标记时间较长较短
使用GCCMSG1, 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%
总体:性能大幅提升!

🎨 总结:三色标记的精髓

┌──────────────────────────────────────┐
│      三色标记一句话总结               │
├──────────────────────────────────────┤
│  用"颜色"追踪对象标记状态,            │
│  用"屏障"解决并发修改问题!            │
│                                      │
│  白色:未访问(可能是垃圾)            │
│  灰色:已访问但未扫描完(待处理)       │
│  黑色:已扫描完(确定存活)            │
│                                      │
│  核心:防止对象消失!🎯              │
└──────────────────────────────────────┘

记住四个关键点:

  1. 三色标记支持并发GC 🎨

    • 白色→灰色→黑色的转换过程
    • 可以暂停和恢复
  2. 对象消失问题必须解决 😱

    • 条件1:黑→白引用
    • 条件2:灰删除引用
  3. 两种解决方案 🛡️

    • 增量更新:破坏条件1(写后屏障)
    • SATB:破坏条件2(写前屏障)
  4. 写屏障是关键 ✍️

    • 自动插入,程序员无感知
    • 性能开销10-20%,但停顿时间大幅减少

下次面试官问三色标记,你就说

"三色标记是并发GC的核心算法,用白灰黑三种颜色表示对象的标记状态。并发标记时会出现对象消失问题:黑色对象新增白色引用,且灰色对象删除对白色对象的引用,导致白色对象被误回收。解决方案有两种:增量更新使用写后屏障,把黑色对象重新标记为灰色;SATB使用写前屏障,保留原始引用快照。G1使用SATB,CMS使用增量更新。写屏障有10-20%性能开销,但换来的是96%以上的停顿时间减少!" 🎓

🎉 掌握三色标记,理解并发GC的核心! 🎉