在 JVM 垃圾收集的核心技术中,三色标记算法是实现 "并发标记" 的基石 ------ 从 CMS GC 的并发标记阶段,到 G1 GC 的并发标记流程,再到 ZGC/Shenandoah GC 的极致低延迟设计,都离不开三色标记的支撑。但多数开发者仅停留在 "知道有这个算法" 的层面,不清楚其核心逻辑、解决的问题以及如何处理并发场景下的引用变动。
本文将从 "为什么需要三色标记" 入手,拆解算法核心原理、标记流程、关键问题(漏标 / 错标)及解决方案,结合 CMS/G1 的实战场景,帮你彻底搞懂这一 JVM GC 的底层核心算法。
一、为什么需要三色标记算法?
在理解三色标记前,我们先回顾一个核心矛盾:传统的可达性分析算法需要暂停所有用户线程(STW) 才能保证标记结果准确 ------ 如果标记过程中用户线程修改对象引用,会导致标记结果错误(比如把可达对象标记为不可达,造成内存泄漏;或把不可达对象标记为可达,造成内存浪费)。
但 STW 会导致服务停顿,尤其在大堆内存场景下,长时间 STW 无法满足低延迟需求。因此,JVM 需要一种能在用户线程运行时(并发)完成标记 的算法,三色标记算法应运而生。
核心目标
在不全程 STW 的前提下,准确标记出堆中所有可达对象,为后续的垃圾回收提供可靠依据。
二、三色标记算法的核心定义
三色标记算法将堆中的对象分为三种 "颜色",分别代表不同的标记状态,通过颜色的转换完成并发标记:
表格
| 颜色 | 状态定义 | 核心特征 |
|---|---|---|
| 白色 | 未被标记的对象 | 初始状态所有对象都是白色;标记结束后,白色对象即为 "无用对象"(待回收) |
| 灰色 | 已被标记,但引用的子对象未完全标记 | 标记过程中的 "中间状态",GC 线程需要继续遍历其引用的子对象 |
| 黑色 | 已被标记,且所有引用的子对象都已标记 | 标记完成的 "最终状态",黑色对象是可达的(有用对象) |
核心规则
- 初始时,所有对象为白色;
- GC Roots 直接引用的对象标记为灰色,加入 "标记队列";
- GC 线程从标记队列中取出灰色对象,将其标记为黑色,同时将其引用的子对象标记为灰色(若子对象为白色),加入标记队列;
- 重复步骤 3,直到标记队列为空;
- 标记结束后,所有白色对象判定为不可达,可回收。
三、三色标记算法的基础执行流程(串行版)
为了便于理解,我们先看无用户线程干扰 的串行版三色标记流程(STW 场景),这是并发版的基础:
示例场景
假设存在对象引用链:GC Roots → A → B → C,且堆中还有一个孤立对象 D(无任何引用)。
执行步骤
- 初始状态:所有对象(A、B、C、D)均为白色;
- 标记 GC Roots 直接引用:将 GC Roots 引用的 A 标记为灰色,加入标记队列;
- 遍历灰色对象 :
- 取出 A,标记为黑色;将 A 引用的 B 标记为灰色,加入队列;
- 取出 B,标记为黑色;将 B 引用的 C 标记为灰色,加入队列;
- 取出 C,标记为黑色;C 无引用的子对象,队列清空;
- 标记结束:A、B、C 为黑色(可达),D 为白色(不可达),D 将被回收。

核心结论
串行版三色标记是 "可达性分析" 的可视化实现,逻辑简单且结果准确,但需要全程 STW------ 而我们真正需要的是并发版三色标记(允许用户线程运行时标记)。
四、并发版三色标记:核心问题与解决方案
当 GC 线程并发标记时,用户线程可能修改对象引用(比如 A 原本引用 B,现在改为引用 D),这会导致三色标记出现两种致命问题:
4.1 并发标记的核心问题
问题 1:漏标(丢失可达对象)
场景 :黑色对象 A 放弃引用白色对象 B,白色对象 C 引用 B,但 C 未被标记(灰色 / 白色)。后果:B 实际可达(被 C 引用),但标记结果为白色,会被错误回收,导致程序崩溃(内存泄漏 + 空指针)。
问题 2:错标(保留无用对象)
场景 :白色对象 B 原本无引用,用户线程让黑色对象 A 引用 B,但 B 未被标记。后果:B 实际可达,标记结果为白色,若未处理会被回收(漏标);或通过补救机制标记 B 为可达,但如果 B 本是无用对象,会被错误保留(内存浪费)。
注:漏标是 "致命错误"(会导致程序崩溃),错标是 "非致命错误"(仅内存浪费,下次 GC 可回收),因此三色标记的核心是解决漏标问题。
4.2 解决漏标:两种核心机制
JVM 通过两种机制解决并发标记的漏标问题,分别应用在 CMS/G1 和 ZGC 中:
机制 1:写屏障(Write Barrier)+ SATB(Snapshot At The Beginning)
核心思想 :以 "标记开始时的快照" 为基准,确保所有在标记开始时可达的对象最终都被标记为黑色。实现方式:
- 写屏障:拦截用户线程的 "引用修改操作"(如 A 放弃引用 B);
- 当黑色对象 A 放弃引用白色对象 B 时,写屏障将 B 记录到 "SATB 日志" 中;
- 并发标记结束后,GC 线程遍历 SATB 日志,将 B 重新标记为可达(灰色);
- 最终所有可达对象都会被标记为黑色,避免漏标。
适用场景 :CMS GC、G1 GC 的并发标记阶段。优点 :实现简单,能彻底解决漏标;缺点:可能标记部分已无用的对象(错标),产生 "浮动垃圾"(需下次 GC 回收)。
机制 2:写屏障 + 增量更新(Incremental Update)
核心思想 :关注 "引用的新增",确保新增的引用链被重新标记。实现方式:
- 写屏障:拦截用户线程的 "引用新增操作"(如黑色对象 A 引用白色对象 B);
- 当 A 引用 B 时,写屏障将 A 重新标记为灰色,加入标记队列;
- GC 线程重新遍历 A 的引用链,将 B 标记为可达;
- 确保新增引用的对象被标记,避免漏标。
适用场景 :部分版本的 G1 GC、早期的 CMS GC。优点 :仅处理新增引用,减少浮动垃圾;缺点:实现复杂,需要重新遍历已标记的黑色对象。
4.3 实战对比:SATB vs 增量更新
| 特性 | SATB(快照式) | 增量更新(增量式) |
|---|---|---|
| 关注对象 | 被删除的引用 | 新增的引用 |
| 处理方式 | 记录被放弃的白色对象,后续重新标记 | 把新增引用的黑色对象变回灰色,重新遍历 |
| 浮动垃圾 | 较多(记录了快照后无用的对象) | 较少(仅处理新增引用) |
| 实现复杂度 | 低 | 高 |
| 适用收集器 | CMS、G1(主流) | 部分 G1 版本、早期 CMS |
五、三色标记在主流 GC 中的实战应用
5.1 CMS GC 中的三色标记
CMS GC 的 "并发标记阶段" 完全基于三色标记算法,结合 SATB 机制:
- 初始标记(STW):标记 GC Roots 直接引用的对象(灰色),此时所有对象为白色 / 灰色;
- 并发标记(无 STW):GC 线程并发遍历灰色对象,标记为黑色,同时用户线程可修改引用;
- 写屏障拦截:用户线程修改引用时,SATB 日志记录被放弃的白色对象;
- 重新标记(STW):遍历 SATB 日志,修正漏标对象,将其标记为可达;
- 并发清除:回收白色对象(不可达)。
5.2 G1 GC 中的三色标记
G1 GC 的并发标记阶段同样基于三色标记 + SATB,且做了优化:
- 以 Region 为单位进行标记,而非全堆;
- 标记过程中计算每个 Region 的 "垃圾占比",为后续筛选回收做准备;
- 写屏障结合 "卡表(Card Table)" 和 "Remembered Set(RS)",仅记录跨 Region 的引用修改,减少日志量,提升效率。
5.3 ZGC 中的三色标记(进阶)
ZGC 采用 "染色指针" 技术,将对象的标记状态存储在指针的预留位中(而非对象头),实现了 "无 STW 的三色标记":
- 指针的不同位代表 "白色 / 灰色 / 黑色",标记时无需修改对象,直接修改指针;
- 结合读屏障 + 写屏障,全程无 STW,标记停顿时间控制在微秒级;
- 彻底解决漏标问题,且几乎无浮动垃圾。
六、三色标记算法的核心价值与局限性
6.1 核心价值
- 实现并发标记:打破了 "标记必须 STW" 的限制,大幅降低 GC 停顿时间;
- 保证标记准确性:通过写屏障 + SATB / 增量更新,避免漏标致命错误;
- 适配不同 GC 场景:从 CMS 到 G1 再到 ZGC,三色标记是并发 GC 的通用底层逻辑。
6.2 局限性
- 无法完全避免浮动垃圾:并发标记过程中产生的无用对象(白色)会被保留,需下次 GC 回收;
- 占用 CPU 资源:写屏障和日志记录会消耗额外 CPU,降低用户线程吞吐量;
- 依赖屏障技术:写屏障 / 读屏障的实现增加了 JVM 的复杂度,且不同平台(如 ARM/x86)的实现差异较大。
七、总结
三色标记算法是 JVM 并发 GC 的 "底层骨架",核心要点可总结为:
- 核心定义:通过白 / 灰 / 黑三色标记对象的可达状态,实现并发可达性分析;
- 核心问题:并发标记时用户线程修改引用会导致漏标(致命),通过写屏障 + SATB / 增量更新解决;
- 实战应用:CMS/G1 采用 SATB + 三色标记,ZGC 结合染色指针实现极致低延迟;
- 核心权衡:以少量浮动垃圾和 CPU 开销为代价,换取 STW 停顿时间的大幅降低。