问题引入

2002 年 CMS 收集器随 JDK 1.4.2 发布时,Java 堆的主流容量还在几百 MB 级别。到了今天,几十 GB 的堆司空见惯。如果仍然采用 Serial GC 的全程 STW(Stop-The-World)策略,一次 Full GC 的停顿时间将从秒级滑向分钟级——这对在线服务是毁灭性的。

CMS 的答案是并发标记:让 GC 线程和用户线程同时工作。但并发带来了一个致命的一致性问题——用户线程在 GC 线程扫描时修改了对象引用关系,导致"本该存活的对象被误杀"。

三色标记算法就是为了解决这个一致性困境而诞生的,而漏标(Lost Object Problem)则是并发标记必须跨过的最深陷阱。

核心概念

三色标记

三色标记将堆中的对象划分为三种颜色状态:

颜色 含义 状态
白色 未访问 候选垃圾
灰色 自身已访问,引用字段未扫描完 待处理
黑色 自身和全部引用字段都已扫描 确认存活

标记过程从 GC Roots 开始,初始时所有对象都是白色。GC Roots 直接引用的对象变为灰色,然后从灰色集合中不断取出对象,将其引用到的白色对象染灰,自身染黑,直到灰色集合为空。最终白色对象即为垃圾。

graph TD subgraph "标记前" W1[白色对象A] W2[白色对象B] W3[白色对象C] end subgraph "标记中" G1[灰色对象A] W4[白色对象B] W5[白色对象C] end subgraph "标记完成" B1[黑色对象A] B2[黑色对象B] W6[白色对象C] end G1 -.->|扫描引用| B2 style G1 fill:#ff9,stroke:#333,stroke-width:2px style B1 fill:#9f9,stroke:#333,stroke-width:2px style B2 fill:#9f9,stroke:#333,stroke-width:2px style W6 fill:#fff,stroke:#333,stroke-width:2px

读图导引:黄色节点(灰色)是标记工作的"前沿"——灰色集合不为空,标记就未完成。绿色节点(黑色)是已确认存活的对象。注意最右侧白色对象 C,如果标记结束时仍为白色,它将被回收。

不变性:三色标记正确工作的前提是——黑色对象不会直接引用白色对象。如果黑色直接指向白色,而灰色集合已经为空,GC 就不会再去扫描那个白色对象,导致误杀。

并发标记的困境

当用户线程和 GC 标记线程并行执行时,引用关系会被动态修改。假设 GC 正在标记,用户线程执行了如下操作:

java 复制代码
objA.field = objC;  // 黑色 objA 新增指向白色 objC 的引用
objB.field = null;  // 灰色 objB 删除了指向 objC 的引用

如果 objC 只被 objB 引用(灰色),GC 原本会在扫描 objB 时发现 objC。但 objB 删除了引用,而 objA(已被扫描完,已成黑色)新增了对 objC 的引用——GC 不会再扫描 objA 的引用字段。objC 被漏标了。

原理分析

漏标的充要条件

漏标不是任意引用修改都会发生的,必须两个条件同时满足

graph TD A[漏标发生] --> B[条件1] A --> C[条件2] B --> B1[黑色对象<br/>插入了指向白色对象的引用] C --> C1[灰色对象<br/>删除了指向该白色对象的引用] style B fill:#f9f,stroke:#333,stroke-width:2px style C fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点(条件1)和蓝色节点(条件2)必须同时成立才会漏标。只满足一个不会出问题——如果条件1不满足(黑色没指向白色),白色对象确实无根可寻;如果条件2不满足(灰色还指向白色),灰色变黑色时会扫描到白色。

理解这个"与"关系至关重要:只要破坏其中一个条件,漏标就不会发生。CMS 和 G1 选择了不同的破坏路径。

CMS:增量更新

CMS(Concurrent Mark Sweep)选择破坏条件1。它的策略是:当黑色对象插入指向白色对象的引用时,把这个黑色对象重新变回灰色

sequenceDiagram participant GC as GC线程 participant USER as 用户线程 participant OBJ as 对象A<br/>(黑色) participant QUEUE as 灰色队列 USER->>OBJ: objA.field = objC<br/>(黑色→白色引用插入) USER->>QUEUE: 写屏障触发<br/>对象A重新入灰色队列 GC->>QUEUE: 取出对象A重新扫描 GC->>OBJ: 扫描A的引用字段<br/>发现objC Note over GC,OBJ: objC被正确标记为存活

读图导引:纵向观察时间轴,关键转折点在第二条消息——写屏障(Write Barrier)在引用赋值后触发,将已完成的黑色对象重新拉回待处理的灰色状态。

术语锚定

  • 增量更新(Incremental Update):记录黑色对象新插入的指向白色对象的引用,然后将黑色对象重置为灰色,重新扫描
  • 写屏障(Write Barrier):在引用类型字段赋值时插入的额外逻辑,类似于 AOP 的切面;CMS 使用写后屏障(Post-Write Barrier),在赋值操作完成后触发

暗面:增量更新的代价是可能重复扫描。如果黑色对象已经扫描了大量引用,重新变灰意味着这些工作要重来一次。更严重的是,如果用户线程持续修改引用,黑色对象可能反复被拉回灰色队列,导致标记阶段长时间无法结束——这就是 CMS 的并发模式失败(Concurrent Mode Failure)诱因之一。

G1:原始快照 SATB

G1(Garbage-First)选择破坏条件2。它的策略是:当灰色对象删除指向白色对象的引用时,把这个引用关系记录到快照中,让白色对象在本次 GC 中存活。

sequenceDiagram participant USER as 用户线程 participant OBJ as 对象B<br/>(灰色) participant SNAP as SATB队列 participant GC as GC线程 USER->>SNAP: 写屏障触发<br/>记录旧引用(B→C) USER->>OBJ: objB.field = null<br/>(删除灰色→白色引用) GC->>SNAP: 从SATB队列取出记录 GC->>GC: 从 SATB 记录中标记 objC Note over GC: objC可能已是垃圾<br/>但本次GC不回收

读图导引:注意时间顺序——写屏障在引用删除前触发(Pre-Write Barrier),先记录旧值,再执行赋值。objC 因此被标记为存活,即使它在逻辑上可能已经是垃圾(浮动垃圾)。

术语锚定

  • SATB(Snapshot At The Beginning):在并发标记开始时建立逻辑快照,任何在快照中存活的对象,即使引用被删除,也在本次 GC 中存活
  • 前写屏障(Pre-Write Barrier):在引用赋值操作之前触发,记录被覆盖的旧引用值
  • 浮动垃圾(Floating Garbage):并发标记期间引用关系变化导致的、本该回收却被标记为存活的对象;下次 GC 再回收,不会导致内存泄漏

暗面:SATB 的代价是产生浮动垃圾。由于 SATB 保守地保留快照中存活的对象,并发标记期间变成垃圾的对象会被延后回收。G1 的 Region 化设计可以缓解这个问题——下次 Young GC 或 Mixed GC 时这些浮动垃圾所在的 Region 会被清理。

两种方案对比

维度 CMS 增量更新 G1 SATB
破坏条件 条件1(黑色插入白色引用) 条件2(灰色删除白色引用)
写屏障时机 Post-Write(赋值后) Pre-Write(赋值前)
核心代价 重复扫描黑色对象 产生浮动垃圾
失败模式 并发模式失败(标记跟不上分配) 较少出现
实现复杂度 较低 较高(需维护 SATB 队列)
graph LR subgraph CMS["CMS"] C1[黑色对象] -->|新增引用| C2[白色对象] C1 -.->|写后屏障| C3[重新变灰\n重复扫描] end subgraph G1["G1"] G11[灰色对象] -->|删除引用| G12[白色对象] G11 -.->|写前屏障| G13[SATB记录\n保守存活] end style C3 fill:#f9f,stroke:#333,stroke-width:2px style G13 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点展示 CMS 的代价——重新扫描;蓝色节点展示 G1 的代价——保守标记。这是两种工程哲学:CMS 追求精确但承担重复工作风险,G1 追求稳定但承担空间浪费。

写屏障的本质

写屏障不是 JVM 层面的"钩子",而是 JIT 编译器在引用类型字段赋值时自动插入的一段机器码。以伪代码表示:

java 复制代码
// 原始代码
obj.field = newValue;

// CMS 的 Post-Write Barrier 插入后
obj.field = newValue;
if (isBlack(obj) && isWhite(newValue)) {
    enqueue(obj);  // 将黑色对象重新标记为灰色
}

// G1 的 Pre-Write Barrier 插入后
Object oldValue = obj.field;  // 先读旧值
if (isGray(obj) && isWhite(oldValue)) {
    enqueue(oldValue);  // SATB 记录
}
obj.field = newValue;

暗面:写屏障是高频操作——每次引用赋值都要执行额外逻辑。如果屏障逻辑太重(如频繁入队列),会拖慢用户线程。G1 为此做了大量优化:SATB 队列使用本地缓冲区减少同步,标记采用位图(Bitmap)而非修改对象头,等等。

实战与源码

场景:CMS 并发模式失败排查

某服务使用 CMS,日志频繁出现 concurrent mode failure,随后退化为 Serial Old GC,停顿数秒。

根因分析:

  1. 并发标记阶段,新生代对象晋升速度过快,老年代空间在标记完成前就被填满
  2. 或者,增量更新导致黑色对象反复被拉回灰色队列,标记周期被拉长

解决方向:

  • 调低触发 CMS 的阈值 -XX:CMSInitiatingOccupancyFraction(默认 92%,可降至 70%)
  • 增大老年代空间
  • 减少大对象分配(-XX:PretenureSizeThreshold
  • 根本方案:升级到 G1 或 ZGC

场景:G1 的 Remark 阶段停顿优化

G1 的并发标记分为四个阶段:Initial Mark(STW)→ Concurrent Mark → Remark(STW)→ Cleanup(STW)。其中 Remark 阶段处理 SATB 队列中的剩余记录。

如果 Remark 停顿过长,通常是 SATB 队列积压过多。可开启:

  • -XX:+ParallelRefProcEnabled:并行处理 Reference 对象
  • 调整 -XX:RefsPerThread:控制每个线程处理的引用数

常见问题

Q1:漏标会导致什么后果?

漏标意味着存活对象被当作垃圾回收。如果对象 A 被漏标,后续代码再次访问 A 时,要么读到空指针(NullPointerException),要么读到被其他对象复用的内存(更危险,数据混乱)。这是并发 GC 的底线问题——宁可多保留(浮动垃圾),不可误杀。

Q2:为什么 SATB 不会漏标,但增量更新理论上也可能漏标?

增量更新在黑色重新变灰后,如果用户线程再次在 GC 重新扫描之前修改了引用(比如又把那个引用删了),理论上仍存在漏标窗口。虽然实际中通过多次迭代和最终 STW remark 阶段兜底,但 SATB 的"快照保守"策略在理论上更严密——只要快照时对象可达,就一定不会被回收。

Q3:ZGC 和 Shenandoah 还用三色标记吗?

ZGC/Shenandoah 的并发标记阶段仍然基于三色标记的思想,但它们不依赖写屏障来解决漏标。ZGC 使用染色指针(Colored Pointers)和读屏障(Load Barrier)——在读取对象引用时检查指针颜色,如果对象还未被重映射(Relocated),就触发并发处理。这彻底绕过了"黑色指向白色"的问题,因为读屏障保证了用户线程永远不会通过"坏指针"访问到错误位置。

Q4:浮动垃圾是内存泄漏吗?

不是。浮动垃圾只是延迟回收——它们在本次 GC 中幸存,但下次 GC 时如果引用关系确认已断,就会被回收。真正的内存泄漏是对象永远被某个 GC Root 引用着,永远无法回收。SATB 的保守策略不会产生泄漏,只可能暂时浪费一些堆空间。

总结

三色标记是并发 GC 的理论基石,而漏标问题揭示了一致性并发性之间的根本张力。

  • 三色不变性(黑色不指向白色)是标记正确的充要条件
  • 漏标需要两个条件同时满足:黑色插入白色引用,且灰色删除指向同一白色对象的引用
  • CMS 的增量更新破坏条件1,代价是黑色对象可能反复被拉回重扫
  • G1 的 SATB 破坏条件2,代价是产生浮动垃圾,但标记过程更稳定可控
  • 写屏障是实现上述策略的底层机制,CMS 用写后屏障,G1 用写前屏障

理解这个选择——"重扫还是保守"——是理解 G1 取代 CMS 的设计逻辑的关键一步。ZGC 与 Shenandoah 则走得更远,干脆抛弃了"写屏障修补三色标记"的思路,转而在指针和读屏障层面做文章。