三色标记与漏标:并发 GC 的核心难题
一句话结论(30s)
漏标的本质是并发标记期间应用线程”同时删旧引用、加新引用”破坏了”黑色对象不直接引用白色对象”这一不变量,因为黑对象已扫完不会回头,白对象就可能被误判为垃圾而被回收导致崩溃。关键设计是两条解法分别堵住一个条件:CMS 用写后屏障增量更新让黑不引白、G1 用写前屏障 SATB 保留旧引用快照。权衡在于 CMS 的 Remark 要重扫额外对象、STW 较长,G1 则产生浮动垃圾本轮收不掉。
核心原理(2min)
主流程:并发标记从 GC Roots 出发按三色(白=未扫描、灰=已扫但子对象未扫完、黑=已扫完)遍历,正常结束时白色即垃圾;漏标发生在”黑对象新增对白对象的引用”与”灰→白引用被删除”两个条件同时成立时。关键机制:CMS 在写后屏障把新引用的对象重新标灰、Remark 阶段重扫;G1 在写前屏障把旧引用对象压入 satb_mark_queue、Remark 只处理队列,用浮动垃圾换取更短 STW。
底层深入(5-10min)
问题:标记阶段能和应用程序并发吗?
如果 GC 标记时 Stop-The-World(STW),标记逻辑很简单——从 GC Roots 出发,DFS/BFS 遍历所有可达对象。但这意味着 STW 时间随堆大小增长。
并发标记的挑战:标记过程中应用程序在同时修改引用关系——新的引用建立、旧的引用断开。如何保证标记的正确性?
💭 思考:为什么并发标记不能”裸跑”,必须配写屏障?——标记遍历默认”引用关系不动”,从 GC Roots 一路扫到底就能得到正确结果。但并发标记时应用线程同时在改引用,相当于”边扫边有人搬动”。裸跑的话,一个活对象可能因为引用被搬走、又没被扫到,被误判成垃圾回收掉,直接崩溃。写屏障就是在这个”引用被修改”的瞬间做额外记录,把被并发破坏掉的信息补回来——写前记录旧值、写后记录新值,两种补法对应后面 CMS 和 G1 两条路线。
三色标记算法
将对象分为三种颜色:
- 白色:尚未被 GC 扫描到(潜在的垃圾)
- 灰色:已被扫描,但其引用的子对象尚未全部扫描
- 黑色:自身及所有子对象都已扫描完毕
并发标记的不变量:黑色对象绝不直接引用白色对象(否则白色对象可能被漏掉)
正确流程:
GC Roots (灰色)
→ 扫描 → 标记子对象为灰色 → 自身变黑
→ 继续扫描灰色对象
→ ...直到灰色集合为空
→ 白色对象 = 不可达 = 垃圾
💭 思考:为什么三色标记要立”黑色对象绝不直接引用白色对象”这条不变量?——三色标记靠”灰色”充当待处理队列:灰对象被逐个扫描、把子对象标灰、自己转黑。黑对象扫完就退出队列、不再回头。一旦黑对象”直接”引用一个白对象,那个白对象既不在队列里(不是灰)、又没被扫过,算法结束时它还是白——被当成垃圾删掉。所以这条不变量本质是在保证”凡可达对象,结束时一定被扫过”,漏标的两个条件正是这条不变量被并发写破坏的两种具体形态。
漏标的充要条件
在并发标记期间,应用线程同时执行了两个动作:
条件①: 黑色对象新增了对白色对象 X 的引用
(黑色已扫完,不会再回头扫描)
条件②: 白色对象 X 原有的灰色→白色引用关系被删除
(X 从可达变为不可达)
两个条件同时满足 → X 被漏标 → 存活对象被误判为垃圾 → 被 GC 回收 → 程序崩溃。
缺任意一个条件,X 要么仍被灰色对象引用(能扫到),要么没有被黑色对象新增引用(不存在漏标风险)。
这里先自己推一遍:为什么说”三色标记会漏标”?漏标究竟是谁先动手的? 关键在”黑对象已扫完、不会再回头”这个前提——黑色对象扫描完就把自己从”待处理”名单里摘出去了。一旦它之后又新增了对某个白色对象的引用,而这个白色对象原本唯一指向它的灰色引用又被删掉,这个白色对象就从”没人记得”变成了”明明还活着却没人再扫它”。两个条件必须同时成立才漏,缺一个都不行——这正是后面 CMS 和 G1 各自只堵一个条件的依据。
CMS 的解法:增量更新(Incremental Update)
当黑色对象新增对白色对象的引用时,通过写后屏障(Post-write Barrier)将被引用的白色对象重新标记为灰色,保证它会被重新扫描。
黑色 objA.field = 白色 objB;
→ 写后屏障: 记录 objB 到待扫描队列(重新变灰)
→ Remark 阶段(仍然是 STW)重扫队列中的对象
代价:Remark 阶段仍需 STW,且队列中的对象可能只是一个”候选”(有些其实已经正确标记了),有少许冗余工作。但 WriteBarrier 本身只是一个队列插入,开销极小。
再想一层:CMS 的增量更新为什么堵的是”条件①”,而不是条件②? 因为它选择在”黑色对象新增白引用”这一瞬间动手——写后屏障把被新引用的对象重新标灰,直接切断”黑引白”这条链路,让”黑不引白”的不变量重新成立,条件①就永远不成立了。
G1 的解法:SATB(Snapshot-At-The-Beginning)
SATB 换个思路——保留并发标记开始时的”引用快照”。当引用被覆盖/删除时,用写前屏障(Pre-write Barrier)将旧引用指向的对象记录到 satb_mark_queue。
G1 的 SATB 写前屏障真实实现位于 gc/g1/g1BarrierSet.inline.hpp,核心是 write_ref_field_pre + enqueue:
// 引用字段写入前被调用:把旧引用指向的对象压入 SATB 队列
template <DecoratorSet decorators, typename T>
inline void G1BarrierSet::write_ref_field_pre(T* field) {
// 目标未初始化 / 明确不 keepalive 的写,直接跳过屏障
if (HasDecorator<decorators, IS_DEST_UNINITIALIZED>::value ||
HasDecorator<decorators, AS_NO_KEEPALIVE>::value) {
return;
}
enqueue(field);
}
template <class T>
inline void G1BarrierSet::enqueue(T* dst) {
G1SATBMarkQueueSet& queue_set = G1BarrierSet::satb_mark_queue_set();
// 只有并发标记激活时 SATB 才生效,否则直接返回
if (!queue_set.is_active()) return;
T heap_oop = RawAccess<MO_RELAXED>::oop_load(dst);
if (!CompressedOops::is_null(heap_oop)) {
// 取出旧引用指向的对象,压入当前线程的 SATB 标记队列
SATBMarkQueue& queue = G1ThreadLocalData::satb_mark_queue(Thread::current());
queue_set.enqueue_known_active(queue, CompressedOops::decode_not_null(heap_oop));
}
}
写前屏障跑在 *field = newValue 之前:它先读 *field 拿到旧引用,把旧引用指向的对象压入线程本地的 satb_mark_queue。因此,即使并发标记期间这条”灰→白”引用随后被删除,旧对象依然留在队列里,按快照当作存活对象保留。
satb_mark_queue 中记录的所有对象都被认为是”存活”的——即使在并发标记期间对它们的引用被删除了。Remark 阶段处理 satb_mark_queue,所有引用链在快照中存活的对象都保留。
代价:产生浮动垃圾——快照时还存活、Remark 时已死亡的对象,本轮 GC 无法回收,要等到下一轮。这是 SATB 的固有代价。
对比着再想:G1 的 SATB 为什么反过来堵”条件②”? 因为它不去管”黑是否引白”,而是保住”灰→白”这条旧引用链——写前屏障在旧引用被删除前先把它压进 satb_mark_queue,于是即使引用被删,旧对象仍被当作存活保留下来,条件②(灰→白引用被删导致白失联)被直接消解。代价就是它宁可多留点浮动垃圾,也不冒漏标的风险。
CMS vs G1 写屏障对比
| CMS 增量更新 | G1 SATB | |
|---|---|---|
| 写屏障时机 | 写后(Post-write) | 写前(Pre-write) |
| 记录什么 | 被新引用的对象 | 旧引用指向的对象 |
| Remark 做什么 | 重新扫描新引用的对象 | 处理 satb 队列中保留的引用 |
| 主要代价 | Remark 扫描额外对象 | 浮动垃圾(本轮不可回收) |
| Remark STW | 较长(需重扫) | 较短(只处理队列) |
总结
并发 GC 的核心矛盾:“在火车行驶中更换轮子”——标记过程中引用关系在变化。三色标记提供了分析框架,漏标的充要条件精确描述了什么情况下会出错。CMS 和 G1 分别用不同的写屏障策略”修好”条件①(CMS 增量更新——不让黑引用白)或”修好”条件②(G1 SATB——保留旧引用链),二者在浮动垃圾和 Remark STW 之间各有权衡。
章末提问
- “什么是三色标记?三色分别代表什么?” 结论先行:三色标记是并发标记用颜色给对象分类的算法,白=未扫描(潜在垃圾)、灰=已扫但子对象未扫完、黑=自身及子对象都扫完。因为并发标记需要一套能跟踪”扫描进度”的状态机,三色正好把”扫没扫、扫到哪”表达清楚。
- “漏标的充要条件是什么?为什么满足就一定会漏?” 结论先行:黑对象新增对白对象的引用,且该白对象原有的灰→白引用被删除,两个条件同时成立就漏标。因为黑对象已扫完不会回头,白对象又失去了唯一指向它的灰色引用,于是活对象无人扫描、被误判为垃圾回收。
- “CMS 和 G1 分别怎么解决漏标?核心区别?” 结论先行:CMS 用写后屏障增量更新堵条件①(黑不引白),G1 用写前屏障 SATB 堵条件②(保留旧引用链)。因为增量更新让黑对象新增的白对象重新变灰重扫,SATB 让被删的旧引用对象仍按快照存活。
- “G1 的 SATB 为什么会产生浮动垃圾?” 结论先行:因为快照时被当作存活保留的对象,到 Remark 时可能已经真死了,但本轮仍按快照保留。因为 SATB 保的是”标记开始时的引用快照”,快照之后才死掉的对象本轮收不掉,只能等下一轮。
- “为什么 G1 的 Remark 停顿比 CMS 短?” 结论先行:因为 G1 的 Remark 只处理 satb 队列里记录的对象,而 CMS 要重新扫描所有增量更新过的对象。因为 SATB 把”要补扫什么”的边界收得足够小,CMS 的增量更新则可能引入额外对象需要重扫。