Go 的垃圾回收器对大多数业务开发者来说是一个“黑盒”。平时无感,直到线上服务 GC 停顿上升、CPU 被标记线程吃满,才会有人去翻 runtime 日志。而一旦开始读代码,第一个让人头疼的概念往往就是三色标记法。尤其是并发标记阶段,程序还在跑,对象引用随时在变,GC 凭什么敢说“我标记的就是正确的存活集合”?这篇文章把这个问题拆开讲清楚。

三色标记法到底在标记什么
先回到基础。Go 的 GC 是标记-清除式的并发回收器。标记阶段需要遍历从根集合出发能到达的所有对象,把存活的标出来,清除阶段再把未标记的内存回收。单线程的标记算法很容易理解:从一个队列出发,不断扩展。但并发标记意味着标记线程和业务线程同时运行,对象图不是一张静止的快照,而是每秒都在被改写的动态地图。
三色标记法本质上是给标记过程建立了一套状态机。每个对象在标记过程中只会出现在三种状态里:白色,表示尚未进入标记流程,默认当作垃圾;灰色,表示已经被标记为可达,但其子引用还没有扫描完;黑色,表示该对象及其直接子引用都已经被扫描确认。标记开始时所有对象都是白色,根集合直接引用的对象被涂灰。随后 GC 不断从灰色队列取出对象,扫描它的字段,把白色子对象涂灰,最后把自己涂黑。灰色队列为空,标记结束。
这个状态机的关键价值在于:它把“标记”变成一个可以增量推进的过程,灰色集合就是待办列表。并发场景下的一切麻烦,都是因为这个待办列表和业务代码的写入发生了冲突。
并发标记真正难在哪:引用关系在移动
假设 GC 已经扫描完对象 A,A 被涂成黑色。此时业务 goroutine 执行了一次指针赋值,把 A 的某个字段指向对象 B,而 B 之前没有被标记,是白色。如果没有任何干预,A 不会再被扫描,B 又只被 A 引用,标记结束时 B 就是白色,然后被当作垃圾回收。B 明明是存活的,却被回收了——这是任何 GC 都不能接受的事故。
反过来还有另一种情况。GC 的灰色队列里有个对象 C,C 原本指向白色对象 D。业务线程在 GC 扫描 C 之前删掉了这个引用。如果 D 已经失去了所有其他引用,GC 顺着 C 的旧引用找到 D,把 D 涂灰,会导致 D 在本轮 GC 中变成浮动垃圾,占用内存直到下一轮回收。浮动垃圾不会造成程序错误,但会让 GC 的实际回收率下降,堆增长更快。
所以并发标记的核心约束是:不能漏掉任何一个真正可达的对象,同时尽量少制造浮动垃圾。前者是正确性底线,后者是性能问题。这句话值得反复读。
写屏障:把引用变化重新放回标记流程
解决漏标问题的标准手段是写屏障。所谓写屏障,是指编译器在每次指针写入操作前后插入的一段检查代码。它不阻止业务线程做任何事,只是让 GC 在写入发生时有机会检查这个改动会不会破坏标记状态。
用伪代码看一个插入型写屏障的思路:
// 将新指针 val 写入目标对象 dst 的某个字段
func writePointer(dst *Object, val *Object) {
// 如果 dst 已经是黑色,且新引用的对象还是白色,就把它涂灰
if isBlack(dst) && isWhite(val) {
shade(val)
}
dst.field = val
}
shade 操作就是把对象放进灰色队列,让它进入后续扫描。这样即使黑色对象新增了一个指向白色对象的引用,这个白色对象也会立刻变成灰色,不会被漏掉。这个思路被称为插入写屏障,它维护的是强三色不变式:黑色对象永远不能直接引用白色对象。
另一种思路是删除写屏障。它在写入发生前,检查旧指针指向的对象是否还是白色,如果是就把它涂灰。这样即使某个对象即将失去对白色对象的唯一引用,这个白色对象也会先被保留一轮。删除屏障维护的是弱三色不变式:黑色对象可以指向白色对象,但每个这样的白色对象都必然还有其他灰色路径可达。
两种屏障各有各的代价。插入屏障实现直接,但会把许多实际已经变成垃圾的对象重新拉进灰色队列,增加标记负担;删除屏障减少了这种多余标记,但对旧值的检测需要额外读取原字段,在某些场景下开销反而更高。具体怎么选,取决于 GC 是更在乎停顿还是更在乎吞吐。
| 屏障类型 | 检查时机 | 核心动作 | 主要优势 | 主要代价 |
|---|---|---|---|---|
| 插入写屏障 | 指针赋值后 | 新指针指向白色对象时涂灰 | 实现直观,防止黑色指向白色 | 浮动垃圾多,灰色队列容易膨胀 |
| 删除写屏障 | 指针赋值前 | 旧指针指向白色对象时涂灰 | 减少浮动垃圾,适合并发淘汰场景 | 多一次旧值读取,机制更复杂 |
| Go 混合写屏障 | 赋值前后都检查 | 新旧指针为白色时都涂灰 | 兼顾两种场景,减少标记终止压力 | 每次赋值开销略高,但可接受 |
Go 的混合写屏障:新旧指针都查
Go 在 1.8 版本引入了混合写屏障。它的核心逻辑可以用一段更接近实现思路的伪代码来描述:
func writePointer(dst *Object, val *Object) {
if !gcMarking {
// 不在标记阶段,直接写入
dst.field = val
return
}
old := dst.field
// 新指针如果指向白色对象,涂灰
if val != nil && isWhite(val) {
shade(val)
}
// 旧指针如果指向白色对象,涂灰
if old != nil && isWhite(old) {
shade(old)
}
dst.field = val
}
可以看到,Go 的写屏障同时检查新旧指针。这样设计不是为了“更严格”,而是想同时获得插入屏障和删除屏障在不同场景下的优势。最典型的场景是:如果一个对象在这一轮标记中失去了唯一引用路径,删除型检查可以在它变白之前把它涂灰,让本轮 GC 仍然能看到它。这样做确实会保留一部分浮动垃圾,但大大降低了“对象在标记过程中从可达变为不可达,然后被误回收”的风险。
另一个容易被忽略的事实是:Go 的写屏障并不是一直开着的。它在 GC 进入标记阶段时才生效,标记结束后会完全关闭。因此普通指针赋值的性能损耗只集中在 GC 窗口内。此外,标记阶段新分配的对象会被直接视为黑色,避免业务线程在 GC 期间不断创建短生命周期对象造成误回收。这也是为什么 GC 期间的短期分配并不能“轻飘飘”地忽略。
Go 里的三色状态是怎么存的
很多人以为 Go 在对象头上存了一个颜色字段,其实不是。Go 的堆里维护了独立的 gc mark bitmap,每个指针大小的内存单元对应一个 mark bit。对象的“白色”是 mark bit 为 0,灰色和黑色都是 mark bit 为 1。两者的区别只在于是否还在灰色队列中等待扫描。这个设计避免了在每个对象上写入颜色信息,也方便并发更新。
灰色队列本身也不是一个无界链表。Go 使用独立的内存管理队列,业务线程和 GC 线程都会往里推对象。如果队列满了,会触发辅助标记,把一部分标记工作分摊给 mutator。这也是为什么在高分配压力下,业务 goroutine 的 CPU 时间会突然上升。理解这一点,很多 GC 相关的 CPU 问题都能解释通。
标记终止阶段:不可避免的短暂停顿
并发标记虽然大部分时间与业务线程并行,但并不意味着完全没有 STW。根集合扫描和标记终止都必须在 STW 下完成。根集合包括全局变量和所有 goroutine 的栈,它们在标记开始时需要被扫描一次并置灰;在标记结束前,还需要确保所有因栈写入而新出现的可达对象都被纳入标记队列。混合写屏障和分阶段的栈扫描策略,把这段 STW 时间压到了很低的水平,但并没有变成零。
举个例子。一个在线列表服务在晚高峰出现了明显的 GC 停顿,日志里 gc pause 只有几毫秒,但业务延迟却增加了不少。排查后发现,goroutine 数量在高峰期达到数万,每次标记终止阶段扫描栈的时间被拉长。后来通过限制并发任务数量、减少无谓的 goroutine,停顿时长立刻降了下来。这个优化跟堆内存几乎没有关系。
几个容易踩的误区
- “并发标记阶段完全没有 STW”。实际上根扫描和标记终止都需要 STW,只是窗口很短。流量高峰期这些 STW 会叠加在调度延迟上。
- “三色标记会把存活对象全部找出来”。它只能保证标记结束时的可达对象不被回收,标记过程中已经变成垃圾的对象很可能被保留到本轮结束,这就是浮动垃圾。
- “写屏障只属于 GC 线程”。写屏障的代码插入在每个业务 goroutine 的指针赋值路径上,本质上是让 mutator 为自身的内存安全付出一部分 CPU。
- “调大 GOGC 就能降低 GC 停顿”。GOGC 改变的是触发频率,标记阶段的写屏障成本和标记扫描量并不会因此消失,堆的增速和指针密度才是主要因素。
工程上的观察与调优建议
回到现实。如果线上服务 GC 频繁或者标记阶段 CPU 高,通常不是三色算法本身的问题,而是分配模式的问题。先看对象逃逸:能分配到栈上的对象就不要逃逸到堆。再看指针密度:一个对象里指针越多,标记时需要扫描的字段越多,灰色队列的波动也越大。
一个值得养成的习惯是区分 GC 的两种代价:STW 时间由根扫描和标记终止决定,标记负载由存活对象的数量和指针密度决定。调优的时候先定位是哪种代价占主导。如果 STW 高,优先检查 goroutine 数量和栈大小;如果标记 CPU 高,优先检查堆大小和指针密度。
有些团队会把 GOGC 调大来减少 GC 频率,但副作用是堆内存水涨船高,标记阶段的扫描对象更多,反而可能增加单次 GC 的标记 CPU。调小 GOGC 则会让 GC 更频繁,但每次堆增长少,标记压力小,停顿可能更短。它是用频率换单次成本,没有免费的午餐。真正的优化空间,往往还是在业务代码里。
三色标记法其实没有多神秘,它就是一套支持并发推进的标记状态机。真正让 Go 跑在生产环境的原因,是写屏障把并发写操作纳入了标记流程,同时用 bitmap、mark queue 和分阶段扫描把代价控制到可接受范围。理解了对象引用变化怎么被处理,再去看 runtime 日志和 CPU profile,会清楚很多。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/600/