JVM 三色标记、SATB 与 Incremental Update 深度解析
一、引言
现代 JVM 的垃圾回收器(例如 G1、Shenandoah、ZGC)普遍采用“三色标记 + 写屏障 + 并发标记”的架构,以达到短暂停顿、高吞吐和更可预测的 GC 行为。
在工程实践中,开发者经常会遇到诸如:
- 并发标记阶段新增引用会不会导致对象被误回收?
- SATB 和 Incremental Update 本质上有什么区别?
- 三色标记法到底如何保证一致性?
- 为什么需要写屏障?为何要分 pre-write 和 post-write?
这些问题都指向 JVM 在并发标记过程中保持可达性分析精确性的核心机制。本文将以深入但通俗的方式进行讲解,并引入日常生活类比,帮助强化理解。
二、三色标记法概述
三色标记法将堆中的对象视为三类:
| 颜色 | 含义 |
|---|---|
| 白色 | 未被发现、理论上可被回收 |
| 灰色 | 已发现但其引用尚未扫描 |
| 黑色 | 已发现且其引用已扫描完毕 |
三色标记依赖一个关键约束:
三色不变式:黑色对象不能直接引用白色对象。
一旦发生“黑指白”,白色对象可能被错误回收,因此写屏障的作用就是维护或补救这一不变式。
一个通俗类比:快递仓库清点库存
想象你在一个巨大仓库里清点包裹:
- 白色包裹:你还没看过,不确定是否需要保存。
- 灰色包裹:你已经记录了,但还没去检查它的附属包装。
- 黑色包裹:你明确已经检查、需要保留。
只要你检查黑包裹时,它的附属包装也必须被检查过,否则统计就有问题(类似“黑指白”的问题)。
GC 的整个任务就是:找到所有需要保留的包裹(可达对象)并把其余的清仓(回收)。
三、GC 全阶段拆解(以 G1 为模型)
JVM 的并发标记通常包含三个主要阶段:
1. 初始标记(Initial Mark,STW)
- 短暂暂停所有线程。
- 标记所有 GC Roots(栈、静态变量、本地句柄等)引用的对象为灰色。
- 仅标记根,不会扫描整个堆。
2. 并发标记(Concurrent Marking)
- 与应用线程同时运行。
- GC 从灰色队列中不断取对象,扫描并标记其引用对象。
- 过程中,应用线程可能新增或删除引用,写屏障负责维持正确性。
3. 重新标记(Remark,STW)
- 再次短暂停顿以修正少量并发修改导致的漏标问题。
- 清理灰队列,完成最后的扫描与一致性修复。
4. 清理或转移(Cleanup / Evacuation)
- 确定存活对象与可回收区域。
- 进入 Region Evacuation(针对 G1)或其他收集策略。
如下图所示
GC Start
│
├── Initial Mark (STW)
│ ↓
├── Concurrent Mark (threads run concurrently)
│ ↓
├── Remark (STW)
│ ↓
└── Cleanup / Evacuation (可并发或部分 STW)四、三色标记流程图
┌──────────────────┐
│ Initial Mark (T0)│
│ Scan GC Roots │
└────────┬─────────┘
│
▼
┌──────────────────────────────┐
│ Roots → GrayQueue │
└────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Concurrent Marking │
│ while GrayQueue not empty: │
│ obj = pop() │
│ scan fields → push white refs as gray │
│ obj → BLACK │
└────────────────┬────────────────────────┘
│
▼
┌──────────────────────────┐
│ Remark (Fix Races) │
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ Cleanup / Evacuation │
└──────────────────────────┘五、SATB 与 Incremental Update:写屏障的两种策略
三色标记本身不能应对“应用线程在并发标记期间修改引用”的情况,因此 JVM 引入“写屏障(Write Barrier)”。目前两种主流策略为: 1. SATB(Snapshot-At-The-Beginning)
2. Incremental Update(增量更新)
二者都能保证正确性,但语义和实现完全不同。下面给出深入解释与伪代码对比。
六、SATB(Snapshot-At-The-Beginning)机制
SATB 的语义是:
GC 认为标记开始那一刻的堆状态是一个快照,任何在快照时刻存活的对象都不能被回收。
这意味着:
- 如果应用线程删除了一个引用(覆盖 oldValue),GC 必须保留 oldValue。
- 因此 SATB 使用 pre-write barrier:在写入前记录旧引用。
SATB 写屏障(伪代码)
old = target.field
if (old != null and old.color == WHITE):
old.color = GRAY
GrayQueue.push(old)
target.field = new注意: SATB 的核心是记录 oldValue,确保快照中的可达对象不会丢失。 实际 HotSpot 实现中,也可能会把 newValue 作为“补充灰化”,以避免复杂边界情况。
通俗类比
在仓库清点包裹的过程中: 如果你要扔掉一个旧包裹,你必须先把它记在“待检查”列表上避免漏掉。 SATB 的行为类似于“先把旧包裹记一下再动它”。
七、Incremental Update 机制(CMS 使用)
Incremental Update(简称 IU)采用另一种策略: 关注新建立的引用关系。
含义是:
- 如果一个黑色对象(已经扫描完)突然引用了一个白色对象,则必须把白对象重新标记为灰色,否则“黑指白”违背三色不变式。
因此 IU 使用 post-write barrier:在写入后检查 newValue。
IU 写屏障(伪代码)
target.field = newValue
if (target.color == BLACK and newValue != null and newValue.color == WHITE):
newValue.color = GRAY
GrayQueue.push(newValue)通俗类比
在仓库清点过程中: 你已经确认无误的黑色包裹,如果突然发现它有附属包装,则必须把附属包装重新加入检查队列。
八、对比:SATB 与 Incremental Update
| 特性 | SATB | Incremental Update |
|---|---|---|
| 写屏障类型 | pre-write | post-write |
| 关注目标 | oldValue(删除引用) | newValue(新增引用) |
| 整体语义 | 保留标记开始时的快照 | 修复“黑指白”破坏三色不变式 |
| 使用场景 | G1、Shenandoah、ZGC | 传统 CMS |
| 处理并发新增引用 B→D | D 会被加入灰队列 | D 会被加入灰队列 |
| 核心作用 | 防止漏掉旧引用对象 | 防止黑对象引用白对象 |
结论: 在你关心的场景“并发阶段 B→D 发生时”,两者都不会导致 D 被误回收。区别只是触发点不同。
九、典型场景分析:并发标记时 B → D(D 为白色)
假设:
- D 正处于白色状态(未发现)。
- GC 已扫描完 B(即 B 为黑)。
- 应用线程突然执行 B.field = D。
在 SATB 中
写屏障可能同时处理 oldValue 和 newValue:
SATB_pre_write_barrier(old)
B.field = D
(可能) enqueue(D)最终:D 会被放入灰队列。
在 Incremental Update 中
B.field = D
IU_post_write_barrier(target=B, newValue=D)
→ 将 D 标灰最终:D 会被放入灰队列。
两种策略都会保证 D 不被误回收。
十、完整伪代码展示(GC 主循环 + 写屏障)
// ---- Initial Mark (STW) ----
for root in GC_Roots:
if root.color == WHITE:
root.color = GRAY
GrayQueue.push(root)
// ---- Concurrent Marking ----
while (GrayQueue not empty):
obj = GrayQueue.pop()
for ref in obj.fields:
if ref != null and ref.color == WHITE:
ref.color = GRAY
GrayQueue.push(ref)
obj.color = BLACK
// ---- Write Barriers ----
// SATB pre-write barrier
SATB_write(target, newValue):
old = target.field
if old != null and old.color == WHITE:
old.color = GRAY
GrayQueue.push(old)
target.field = newValue
// Incremental Update post-write barrier
IU_write(target, newValue):
target.field = newValue
if target.color == BLACK and newValue.color == WHITE:
newValue.color = GRAY
GrayQueue.push(newValue)十一、总结
本文从三色标记法出发,系统阐述了:
- JVM 并发标记的整体流程:初始标记 → 并发标记 → 重新标记 → 清理。
- 三色不变式与漏标问题的成因。
- SATB 与 Incremental Update 两种不同写屏障策略。
- 通过日常类比、图示和伪代码解释写屏障如何防止对象误回收。
- 在并发阶段新增引用(如 B→D)时,两种策略都能保证垃圾回收的正确性。
随着现代 JVM 越来越多地采用并发垃圾回收技术(G1/ZGC/Shenandoah),理解 SATB 和 IU 对工程实践具有重要意义,尤其是:
- 分析 GC 日志、诊断停顿时间问题
- 理解弱引用/软引用与并发标记的交互
- 掌握内存泄漏或对象“复活”相关问题