POST / POSTS

JVM 三色标记、SATB 与 Incremental Update 深度解析

AI摘要
本文是一篇深入且结构化的 JVM 垃圾回收(GC)三色标记法技术解析文章。文中通过通俗类比、图形化流程、GC 全阶段拆解、SATB 与 Incremental Update 对比分析及伪代码展示,帮助读者系统理解现代虚拟机如何在并发环境下保证对象不被误回收、如何维护三色不变式,并说明 G1 等收集器如何在写屏障的参与下实现高效并发标记。

JVM 三色标记、SATB 与 Incremental Update 深度解析

G1 并发标记流程 G1 并发标记流程
图 1:三色标记在不同主题下的渲染

一、引言

现代 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)或其他收集策略。

如下图所示

TEXT
GC Start
   ├── Initial Mark (STW)
   │       ↓
   ├── Concurrent Mark (threads run concurrently)
   │       ↓
   ├── Remark (STW)
   │       ↓
   └── Cleanup / Evacuation (可并发或部分 STW)

四、三色标记流程图

TEXT
                           ┌──────────────────┐
                           │ 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 写屏障(伪代码)

TEXT
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 写屏障(伪代码)

TEXT
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:

TEXT
SATB_pre_write_barrier(old)
B.field = D
(可能) enqueue(D)

最终:D 会被放入灰队列。

在 Incremental Update 中

TEXT
B.field = D
IU_post_write_barrier(target=B, newValue=D)
    → 将 D 标灰

最终:D 会被放入灰队列。

两种策略都会保证 D 不被误回收。


十、完整伪代码展示(GC 主循环 + 写屏障)

TEXT
// ---- 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)

十一、总结

本文从三色标记法出发,系统阐述了:

  1. JVM 并发标记的整体流程:初始标记 → 并发标记 → 重新标记 → 清理。
  2. 三色不变式与漏标问题的成因。
  3. SATB 与 Incremental Update 两种不同写屏障策略。
  4. 通过日常类比、图示和伪代码解释写屏障如何防止对象误回收。
  5. 在并发阶段新增引用(如 B→D)时,两种策略都能保证垃圾回收的正确性。

随着现代 JVM 越来越多地采用并发垃圾回收技术(G1/ZGC/Shenandoah),理解 SATB 和 IU 对工程实践具有重要意义,尤其是:

  • 分析 GC 日志、诊断停顿时间问题
  • 理解弱引用/软引用与并发标记的交互
  • 掌握内存泄漏或对象“复活”相关问题