三色标记算法:垃圾回收中的并发标记与写屏障技术解析
2026/8/3 1:33:52 网站建设 项目流程

1. 三色标记算法:垃圾回收世界的“交通信号灯”

如果你写过Java、Go或者用过一些现代的内存管理库,大概率听说过“垃圾回收”(Garbage Collection, GC)这个词。GC就像程序世界的清洁工,自动帮我们回收那些不再使用的内存,防止内存泄漏。但清洁工怎么知道哪些东西是垃圾,哪些东西还在用呢?这就引出了一个核心问题:如何高效、准确地标记出所有存活对象。

三色标记算法(Tri-color marking)就是解决这个问题的“黄金标准”之一。它不是一个具体的垃圾回收器实现,而是一种抽象的状态模型和算法思想,被广泛应用于追踪式垃圾回收器(如HotSpot JVM的CMS、G1,Go的GC等)的标记阶段。我第一次深入接触它是在排查一个Go服务的GC停顿时间过长的问题时,发现其根源就在于标记阶段的“误标”与“漏标”,而三色模型是理解这一切的钥匙。

简单来说,它把内存中的对象想象成路上的车辆,并用三种颜色给它们贴标签:

  • 白色:表示“候选垃圾”。初始状态,所有对象都是白色,意味着GC尚未检查到它们。
  • 灰色:表示“待扫描”。对象本身被标记为存活,但它引用的其他对象(它的“子节点”)还没有被检查。它是标记过程的“前沿”。
  • 黑色:表示“已扫描”。对象被标记为存活,并且它引用的所有对象也都已经被检查过了(即它的所有引用都已经被递归追踪过了)。

这个算法的目标,就是把所有从“根对象”(如全局变量、活动线程的栈上的变量等)出发,能直接或间接访问到的对象,从白色,经过灰色,最终全部染成黑色。剩下的、无法触及的白色对象,就是真正的垃圾,可以在后续的清除阶段被回收。

理解三色标记,不仅仅是知道三种颜色,更要明白其背后的强三色不变式弱三色不变式,以及为了在并发标记时维持这些不变式所引入的“写屏障”技术。这是保证GC正确性的基石,也是导致GC出现“Stop-The-World”停顿或产生浮动垃圾的根源。接下来,我们就深入这个色彩斑斓的标记世界,看看它到底是如何工作的。

2. 算法核心过程与状态迁移拆解

三色标记的过程,本质上是一次对对象图的广度优先搜索(BFS)或深度优先搜索(DFS)的遍历。我们以广度优先的思路来拆解,这个过程非常直观。

2.1 初始标记阶段:确立起点

垃圾回收器启动标记阶段时,它首先需要找到所有的“根对象”。这些根对象是程序正常运行的基石,是追踪的起点,通常包括:

  • 全局变量:存储在静态数据区的对象引用。
  • 栈上的局部变量:所有活动线程的调用栈帧中的局部变量和参数。
  • 寄存器中的引用:当前正在被CPU使用的对象指针。

初始标记(Initial Mark)这一步通常需要短暂的“Stop-The-World”(STW),即暂停所有应用线程,以确保根集合的快照是一致的。因为应用线程随时可能在修改栈和寄存器,不停下来就无法获得一个稳定的起点视图。

注意:这个STW停顿是必须的,但现代GC(如G1、ZGC)都致力于将其时间压缩到极短(亚毫秒级)。优化手段包括使用线程本地栈扫描、利用上次GC的信息等。

初始标记完成后,所有根对象被直接标记为黑色吗?不,这里有一个关键设计点。通常,根对象会被标记为灰色,并放入一个称为“灰色对象集合”或“标记栈/队列”的数据结构中。为什么是灰色而不是黑色?因为虽然我们找到了根对象,但还没有去扫描它们内部引用了哪些其他对象。将它们设为灰色,意味着“已知存活,但下属待查”,这符合灰色的定义。

实操心得:在调优GC时,关注日志中“初始标记”阶段的耗时。如果这个阶段异常长,可能意味着应用有非常多的活跃线程(栈空间大)或庞大的全局数据结构,需要审视代码结构。

2.2 并发标记/追踪阶段:主体着色过程

这是三色标记算法的主循环,也是最能体现其价值的部分。理想情况下,这个过程可以与用户程序并发执行,以减少STW时间。

过程如下:

  1. 弹出灰色对象:从灰色对象集合(通常是一个队列)中取出一个灰色对象。
  2. 扫描引用:遍历这个灰色对象所引用的所有其他对象(例如,一个Java对象的成员变量,一个Go结构体的指针字段)。
  3. 着色子对象:对于每一个被引用到的、颜色为白色的子对象,将其颜色标记为灰色,并放入灰色对象集合。这相当于发现了新的“待侦查区域”。
  4. 自身变黑:当这个灰色对象的所有引用都被扫描完毕后,将其颜色标记为黑色。这意味着这个对象及其引用的子图都已经被处理过了(尽管子对象可能还是灰色,但已经在待处理队列中)。

这个循环(while灰色集合非空)一直持续,直到灰色对象集合为空。此时,所有从根对象可达的对象都已经被标记为黑色,而剩余的白色对象就是从根集合不可达的,即垃圾。

状态迁移的直观展示

初始: 根对象 -> 灰色集合 其他所有对象 -> 白色 循环开始: 从灰色集合取出对象O for (每个被O引用的对象C) { if (C是白色) { 将C标记为灰色,并加入灰色集合 } } 将O标记为黑色 循环结束条件:灰色集合为空 最终:所有可达对象 -> 黑色 所有不可达对象 -> 白色 (待回收)

核心难点:上述描述是一个“串行”的、世界静止的理想过程。但在现实中,为了减少停顿,我们希望标记工作(GC线程)和应用线程(Mutator)同时运行。这就带来了一个经典问题:在标记过程中,用户程序正在修改对象间的引用关系。如果处理不当,就会导致两种致命错误:

  1. 漏标(Missed Mark):一个存活对象被错误地回收。这是严重Bug,会导致程序崩溃。
  2. 误标(Floating Garbage):一个本应死亡的对象被错误地标记为存活。这会导致内存无法立即回收(成为“浮动垃圾”),但程序功能正确,影响的是GC的效率。

为了解决并发修改带来的问题,就必须引入“不变式”和“写屏障”。

3. 不变式与写屏障:并发标记的守护神

要让标记线程和用户线程安全地共舞,必须遵守一些基本的规则,这些规则被称为三色不变式。它们是保证算法正确性的数学约束。

3.1 强三色不变式与弱三色不变式

  • 强三色不变式(Strong Tri-color Invariant)不允许黑色对象直接指向白色对象。

    • 直观理解:黑色对象是“已处理完毕”的。如果它突然引用了一个白色对象(候选垃圾),而这个引用是在黑色对象被标记之后才建立的,那么由于黑色对象不会再被扫描,这个白色对象将永远没有机会被标记为灰色,从而导致漏标
    • 重要性:这是防止漏标的绝对红线。任何GC算法如果要支持并发标记,都必须以某种方式保证强三色不变式不被破坏。
  • 弱三色不变式(Weak Tri-color Invariant):所有被黑色对象引用的白色对象,必须能从灰色对象出发可达。

    • 直观理解:它放宽了要求,允许黑色引用白色,但给白色对象留了一条“生路”——必须存在一条从某个灰色对象到这个白色对象的引用路径。这样,通过扫描灰色对象,最终还能找到这个白色对象并将其标灰。
    • 与强不变式的关系:满足强不变式,必然满足弱不变式(因为根本不允许黑指白)。但弱不变式是一个更宽松的条件,为实现某些优化(如“增量更新”式写屏障)提供了理论可能。

3.2 写屏障:在代码中嵌入的监控器

为了保证在并发修改时上述不变式依然成立,我们需要在用户程序写入对象引用的地方插入一段额外的代码。这段代码就是写屏障。它不是内存屏障,而是一小段在对象引用赋值操作前后执行的逻辑。

现代GC主要使用两种风格的写屏障,对应不同的不变式维护策略:

1. 插入写屏障(Dijkstra屏障)这是最经典、最常用的一种,Go语言的GC在1.8版本后主要使用它(混合了插入和删除屏障)。

  • 核心逻辑:当执行obj.field = newRef时(即插入一个新引用),无论obj是什么颜色,都会将newRef所指向的对象(如果它是白色)立即标记为灰色
  • 伪代码示意
    // 写屏障逻辑(在赋值发生时同步执行) func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) { shade(ptr) // 将被引用的新对象标灰 *slot = ptr // 执行实际的指针赋值 }
  • 维护的不变式强三色不变式。通过将新引用的目标直接标灰,彻底杜绝了“黑色对象引用白色对象”的可能性。
  • 优点:实现相对简单,能强力保证不漏标。
  • 缺点:会产生“冗余标记”。因为即使引用来自一个即将被回收的白色对象(即这个引用很快会消失),目标对象也会被标灰,导致本轮GC无法回收它(成为浮动垃圾)。此外,栈上引用的赋值通常不启用写屏障(代价太高),因此标记结束时需要对栈进行一次重新扫描(STW)。

2. 删除写屏障(Yuasa屏障)

  • 核心逻辑:当执行oldRef = obj.field; obj.field = newRef时(即删除一个旧引用,插入一个新引用),会将被删除的旧引用oldRef所指向的对象标记为灰色。
  • 维护的不变式弱三色不变式。它保护的是被删除引用路径上的白色对象。通过将旧引用目标标灰,确保了即使有黑色对象通过其他路径引用了该白色对象,该白色对象也能通过这个新产生的灰色对象被扫描到。
  • 优点:可以容忍“黑色对象引用白色对象”的情况发生,因为屏障保护了被删的引用。这允许在标记开始后,黑色对象可以自由地写入新引用(指向白色对象),只要不破坏弱不变式即可。对栈的写入可以不特殊处理。
  • 缺点:会产生更多的浮动垃圾。因为被删除引用的对象(可能已经是垃圾)会被复活(标灰),必须等到下一轮GC才能回收。逻辑上也更复杂。

3. 增量更新与SATB在JVM的G1垃圾回收器中,我们常听到“SATB”(Snapshot-At-The-Beginning)这个词。这本质上是另一种风格的写屏障策略。

  • 增量更新(Incremental Update):关注插入操作。当黑色对象插入一个对白色对象的引用时,通过写屏障将黑色对象重新标记为灰色。这破坏了“黑色是已完成”的约定,但通过将其重新加入灰色集合,使得后续可以扫描到它新引用的白色对象。它维护的是弱不变式。
  • SATB:关注删除操作。它在标记开始时对对象图做一个逻辑快照。写屏障会记录下所有被覆盖的旧引用(即被删除的引用)。在标记结束时,GC会认为这些旧引用指向的对象在快照时是活的,因此会以这些旧引用为根,重新扫描一遍,确保没有对象因为引用被删除而漏标。Go的删除写屏障可以看作是SATB的一种实现方式。

实操心得:选择哪种写屏障是垃圾回收器设计时的核心权衡。Go选择了以插入写屏障为主,因为它实现简单且停顿时间可控(通过混合写屏障和栈重扫描优化)。JVM的CMS使用增量更新,而G1使用SATB。理解你所用语言的GC采用了哪种屏障,有助于你理解其GC日志和行为。例如,知道Go使用了混合屏障,就能明白为什么它需要周期性的栈扫描小停顿。

4. 完整并发标记流程与问题排查实录

结合不变式和写屏障,一个完整的、支持并发的三色标记流程如下:

4.1 全流程串联

  1. STW阶段:根扫描

    • 暂停所有应用线程。
    • 扫描所有的根对象(栈、全局变量等),将它们放入灰色集合。(此时,所有根对象是灰色,其他全是白色)
    • 启动写屏障。(从此之后,所有引用赋值操作都会受到屏障逻辑的监控)
    • 恢复应用线程。(标记进入并发阶段)
  2. 并发阶段:标记与修改共存

    • GC标记线程开始循环:从灰色集合弹出对象,扫描其引用,将白色子对象标灰并入队,将自己标黑。
    • 与此同时,应用线程正常运行,不断创建新对象(初始为白色)和修改现有对象间的引用。
    • 所有的引用修改(写操作)都会触发写屏障代码。以插入屏障为例,这确保了任何被新引用的白色对象会立即变灰,从而永远不会出现“黑指白”。
  3. STW阶段:标记终止

    • 当灰色集合快空时,需要再次STW。因为标记线程和应用线程在赛跑,可能永远结束不了(应用线程不断产生新的灰色对象)。
    • 这次STW的目的是处理“最后的”灰色对象,并完成一些收尾工作,例如:
      • 重新扫描栈:对于使用插入屏障的GC(如Go),栈上的赋值通常没有屏障,所以需要重新扫描所有协程的栈,将栈上引用的白色对象标灰。
      • 处理屏障缓冲区:写屏障为了性能,通常只是将需要标记的对象地址记录到一个缓冲区。此时需要清空缓冲区,处理其中所有记录。
      • 再次检查灰色集合是否为空。确保标记工作真正完成。
  4. 并发/并行阶段:清扫

    • 标记阶段结束后,所有黑色对象是存活的,白色对象是垃圾。
    • 清扫阶段可以并发进行:回收白色对象所占用的内存,将其加入空闲链表。
    • 此时,新分配的对象通常会直接标记为黑色(在本轮GC中存活)或采用其他策略(如Go的“黑色分配”),避免被错误清扫。

4.2 典型问题与排查技巧

在实际开发和运维中,与三色标记相关的问题往往表现为GC停顿时间长、内存回收不及时或内存泄漏。下面是一个排查思路的实录:

问题场景:一个Go服务,在晚高峰时,GC的mark termination阶段STW时间偶尔会飙升到几百毫秒,导致服务毛刺。

排查步骤与可能原因

  1. 检查GC日志:首先开启Go的GC日志GODEBUG=gctrace=1。关注mark termination阶段的耗时。同时关注scvg相关的行,看内存是否被有效释放。

  2. 分析mark termination长的原因

    • 栈重扫描负担重:这是使用插入写屏障的GC的常见瓶颈。如果服务有成千上万个活跃的Goroutine,每个栈都需要在标记终止时被扫描,耗时必然增加。
      • 线索:Goroutine数量极多(可通过pprof查看)。
      • 对策:优化程序,避免创建海量长期存活的Goroutine;考虑使用工作池模式;检查是否有Goroutine泄漏。
    • 写屏障缓冲区溢出:如果应用并发写入极其频繁,写屏障记录引用变化的缓冲区可能很快被填满,触发提前处理或更频繁的同步操作,拖慢标记速度,间接导致终止阶段要处理的数据变多。
      • 线索:应用存在大量指针赋值操作的热点代码。
      • 对策:使用pprofmutexblockprofile分析锁竞争,看是否与GC相关。优化数据结构,减少不必要的指针间接引用。
    • 对象图过于复杂:如果堆中存在大量小对象且引用关系复杂(例如,复杂的链表、图结构),标记阶段遍历本身就很慢。虽然主要是并发标记阶段慢,但终止阶段最后的处理也会受影响。
      • 线索:堆内存不大,但对象数量极多(go tool pprof --alloc_objects)。
      • 对策:考虑对象合并,使用更紧凑的数据结构(如切片替代链表),避免过度设计。
  3. 分析内存不释放(浮动垃圾多)

    • 现象:GC后,inuse内存下降不明显,但idle内存增多。
    • 原因:这正是写屏障的副作用。插入屏障会导致所有被新引用的对象(即使引用很快消失)在本轮存活;删除屏障会导致所有被删除引用的对象在本轮存活。它们都成了“浮动垃圾”。
    • 理解:这不是内存泄漏,而是GC设计上的权衡。用一部分即时的内存效率,换取更短的STW停顿。
    • 对策:通常无需特别处理。如果确实影响严重,可以尝试调整GC频率(如Go的GOGC比例),或者审视业务代码,避免在GC标记阶段爆发式地创建和丢弃大量临时对象引用。

一个实用的检查清单

问题现象可能关联的三色标记环节排查工具/方法优化思路
GC总停顿时间长初始标记、标记终止GC日志 (gctrace),pprofCPU减少根对象数量(如精简全局变量),优化减少Goroutine数量,降低对象图复杂度
标记终止阶段特别长栈重扫描、屏障处理pprofGoroutine数量,分析代码写入模式控制Goroutine生命周期,合并小对象,减少指针写入频率
内存使用率居高不下,但GC频繁浮动垃圾产生GC日志观察回收率,pprof看对象分配调整GC触发阈值,优化对象复用,避免在标记阶段产生大量短命引用
程序崩溃(罕见)漏标(写屏障bug或未覆盖所有写操作)核心转储,使用更严格的竞态检测(-race)升级运行时版本,检查是否使用了非安全指针操作绕过了屏障

理解了三色标记的原理和这些常见问题的根因,你就能从“面向GC编程”的角度去思考代码结构,写出对垃圾回收更友好的程序。这不仅仅是调几个参数,而是深入到数据结构和并发模型的设计层面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询