Skip to content
Go back

JVM三色标记与漏标问题

三色标记与漏标:并发 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 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 之间各有权衡。

章末提问


Share this post on:

Previous Post
JVM运行时数据区——每个字节码在哪里执行?
Next Post
JVM指针压缩:32位地址访问32GB堆的魔法