三色标记法是并发垃圾回收的核心算法,通过白(未访问)、灰(已访问未扫完引用)、黑(已访问且引用全扫完)三色标识对象可达性进度;漏标需同时满足黑色对象新增指向白色对象、灰色对象断开指向同一白色对象两个条件,CMS用增量更新(重标黑为灰)应对,G1用SATB原始快照兜底。
三色标记法不是颜色游戏,而是并发环境下对“对象是否存活”这一判断的工程化拆解。它的核心矛盾在于:gc线程在动,用户线程也在动;你刚标完一个对象为黑色,它引用的对象可能已被断开,而另一个黑色对象又悄悄指向了它——这种时间差,就是漏标隐患的根源。
先搞懂三色到底在标记什么
白色、灰色、黑色不是状态标签,而是**可达性分析进度的快照**:
- 白色:尚未被 GC 线程“看见”,不代表一定是垃圾,只代表还没轮到它
- 灰色:GC 线程已访问该对象本身,但它的所有引用(字段)还没扫完——它是“正在处理中”的临界点
- 黑色:该对象及其直接引用的对象都已确认扫描完毕,按规则,它不该再指向白色对象
关键逻辑是:一旦对象变成黑色,GC 就默认“它和它所有孩子都已安全检查过”。这个假设在单线程下成立,在并发下却极易被打破。
漏标不是偶然,而是两个并发动作的精准咬合
漏标发生必须同时满足:
- 一个黑色对象新增了对白色对象的引用(比如 A.setChild(d),而 d 是白色)
- 一个灰色对象断开了对同一白色对象的引用(比如 B.setChild(null),而 B 原本持有 d)
为什么缺一不可?因为如果只有新增引用(A→d),而灰色对象 B 仍连着 d,那 GC 后续从 B 出发还能扫到 d,不会漏;如果只有断开引用(B→null),而没人新连 d,那 d 本来就是该回收的——真正危险的是:d 原本靠 B 存活,B 断了,A 又接上,但 A 已是黑色,GC 不会回头再扫它。
漏标在生产环境里长什么样
它不报错,也不抛异常,而是悄无声息地让一个本该存活的对象被回收,后果往往是:
- 某次接口返回
NullPointerException,日志里找不到明显空指针位置,查半天发现是某个缓存对象被提前回收 - 定时任务突然失败,堆栈显示某个 Spring Bean 为 null,但该 Bean 明明是单例且未被手动销毁
- G1 或 CMS 日志里频繁出现 “
Concurrent mode failure” 或 “Remark pause too long”,背后常是漏标导致重新标记阶段要补大量对象
这些都不是代码 bug,而是 GC 在高并发读写场景下,因写屏障未生效或 STW 时间被压缩过度引发的系统级脆弱点。
不同回收器怎么防漏标:本质是“谁来负责兜底”
解决方案不是消灭并发,而是把漏标风险控制在可管理范围内:
- CMS 用增量更新(Incremental Update):一旦检测到黑色对象新增引用(如 A→d),立刻把 A 重新刷成灰色,等它下次被处理时再把 d 扫进来。代价是并发标记阶段可能反复入队,CPU 开销略增
- G1 用原始快照(SATB, Snapshot-At-The-Beginning):在并发标记开始前,先记下所有当前存活对象的引用关系;后续即使灰色对象断开引用(B→null),G1 也假装它还连着,把 d 当作“可能存活”保到下次 GC 再判——这会多留点浮动垃圾,但避免了漏标
- ZGC/C4 则走另一条路:读屏障 + 指针着色:不依赖三色标记流程本身,而是在每次对象读取时动态检查并重定向,彻底绕过漏标前提
选哪种方案,本质是在“内存冗余”“CPU 开销”“STW 时长”之间做取舍——没有银弹,只有权衡。

















