☰
手写三色标记法:从STW到Go GC的原理与调优实践
2026/9/29 23:53:38 网站建设 项目流程

去年年底我负责的一个 Go 消息网关在压测时出现了很诡异的 P99 上涨,CPU 和内存都还算正常,但接口响应就是时不时抖一下。我打开GODEBUG=gctrace=1抓日志,才意识到问题出在 Go 垃圾回收(GC)的周期性停顿上——那几行gc 34 @18.9s日志里,STW 的 clock 时间一度接近 30ms。说实话,在那之前我对 Go GC 的了解仅限于“自动回收挺好的,不用管”,日志里的数字让我彻底意识到一件事:如果连 GC 在干什么都不懂,遇到这类问题就只能瞎猜。

所以我做了一件很笨但很有用的事:用 Go 语言从零手写了一个带有“三色标记法”的简易 GC——不是去改 runtime 源码(那对普通应用开发者没有意义),而是在用户态维护一个迷你堆,自己管理对象分配、对象引用、根集合、标记阶段和清扫阶段。这篇文章就是那次实验的完整记录。它适合三种人看:被 GC 调参困扰的人、准备 Go 相关面试的人、以及单纯想知道“对象到底是怎么被回收”的纯好奇开发者。文章会拆解三色标记法的核心数据结构、完整可运行的代码、并发下漏标问题与写屏障为何存在、以及真实 Go GC 日志怎么看、参数怎么配。

1. 从一个 30ms 的 STW 说起:GC 到底在忙什么

先看那条让我破防的日志。用GODEBUG=gctrace=1跑一个分配密集型 Go 程序,标准输出里会出现类似这样的行:

gc 34 @18.931s 5%: 0.22+2.4+0.12 ms clock, 0.31+0.42/0.84/0.32+0.11 ms cpu, 26->26->15 MB, 27 MB goal, 0 MB stacks, 0 MB globals, 9 MB scan, 9 MB scannable, 0% CPU (0/0), 0s wallclock, ...

大多数人第一次看到这行日志是懵的。我给你翻译一下重点:

  • gc 34:这是程序启动后第 34 次 GC。
  • @18.931s:程序跑了 18.9 秒才触发这次 GC。
  • 5%:到目前为止,GC 累计消耗的 CPU 时间占比约为 5%。
  • 0.22+2.4+0.12 ms clock:这是最关键的一段。它表示一次 GC 的三个阶段耗时:第一个数0.22ms是开始前的“清扫终止”阶段,需要 STW;第二个数2.4ms是并发标记阶段,大部分和业务代码并行跑;第三个数0.12ms是“标记终止”阶段,又需要 STW。

所以真正让业务停顿的,是0.22 + 0.12 = 0.34ms左右。如果你发现这两个数变成几十毫秒,那线上一定会有感知。

  • 26->26->15 MB:GC 开始前的堆内存是 26MB,GC 结束后的堆内存是 26MB(因为分配还在继续),但真正存活的对象只有 15MB。也就是说,有大约 11MB 的垃圾被这次 GC 清掉了。
  • 27 MB goal:这是 Go 给下次 GC 定的“触发目标”——堆内存到了这个值附近,就会自动开启新一轮 GC。

这行日志背后,就是标记-清除(Mark-Sweep)算法在运转。Go 的 runtime 用“三色标记法”把整个内存对象图遍历一遍,找出哪些对象还活着,剩下的统统回收。我当时盯着这行日志看了半天,最后决定:不查资料了,直接自己写一个迷你 GC,把三色标记的全过程跑一遍。只有手写过一遍,日志里的每个数字才能真正和算法步骤对应上。

2. 三色标记的地基:对象模型、根集合与状态机

在写代码之前,先要把问题抽象出来。垃圾回收本质上是回答一个问题:在一堆对象构成的引用图里,哪些对象从根出发仍然可达?

把内存想象成一张有向图:每个对象是一个节点,每个指针字段是一条边,指向另一个对象。一个对象只要还能从“根集合”出发找到它,它就必须活着;反过来,如果从根出发怎么都摸不到它,它就可以被回收。

这里“根集合”不是某一个具体对象,而是一组“程序的起点引用”,包括:

  • 栈上正在使用的局部变量和参数;
  • 全局变量;
  • 寄存器里暂时被编译器和 runtime 使用的指针;
  • 以及特殊对象(比如runtime内部持有的某些引用)。

如果你学过数据结构,马上会意识到:这不就是从一个源点集合出发,在图上做一次遍历嘛。问题在于,遍历过程中怎么高效区分“已经处理完”“正在处理”“还没处理”三种状态?这就引入了三色。

三个颜色各司其职:

颜色含义当前阶段标记结束时的命运
白色还没被发现,或者没有任何引用指向它正在等待检查被回收
灰色已确认可达,但它引用的子对象还没全部处理完在待处理队列中等待扫描不存在
黑色已确认可达,并且它的所有直接引用都已处理完毕扫描完成被保留

整个标记过程是一条严格的状态机路径:白色 -> 灰色 -> 黑色。不会出现灰色变回白色,更不会出现黑色变回灰色(除非有写屏障破坏规则,这个我们第五节专门讲)。

为什么一定要三种颜色?如果只是简单遍历,二色(黑/白)就够用了:访问过的标记为黑,没访问过的就是白,扫完把白的回收。但二色标记的问题在于,它必须一口气跑完整个遍历,否则一旦中途停下来,再想继续时你根本不知道“之前扫到哪儿了”。真实 GC 不能接受一个超长 STW,它必须把标记工作打散到多个时间片里,和业务代码并发执行。灰色对象就是“工作进度存档”——只要队列里还有灰色对象,下一次 GC 就知道从哪里接着扫,不用从头再来。

这也是三色标记法能成为现代 GC 基石的根本原因:它不是一种单纯的可达性遍历,而是为“增量式 + 并发式”回收提供了精确的中间状态。

有了状态机,下一步就是把对象和堆的结构定下来。我在玩具 GC 里用 slice 作为堆,用*Obj表示对象,用Fields []int保存引用关系。这里有个关键设计:真实 Go 用指针表示引用,但指针一旦进入我们的迷你堆,处理“对象是否存活”就很麻烦,所以我用“对象下标”代替真实指针。就好比你在纸上画了一张表格记录每个人住在几号房间,而不是直接拿着门牌号跑来跑去。

3. 手写标记-清除的完整实现:从迷你堆到能跑的 main.go

3.1 堆对象模型与分配器

先定义一个足够支撑三色标记的数据结构:

package main import "fmt" type Color uint8 const ( White Color = iota // 白色:未发现,默认就是垃圾候选 Gray // 灰色:已发现,等待扫描 Black // 黑色:扫描完成,绝对存活 ) type Obj struct { Color Color Fields []int // 引用的其他对象下标 Name string Bytes int // 模拟这个对象占用的内存大小 } type MiniHeap struct { Objs []*Obj Roots []int // 根集合:直接引用的对象下标 pending []int // 灰色对象队列:待扫描的对象下标 }

分配一个新对象其实就是往Objs尾部追加一个白色对象,并返回它的下标。这个下标就是我们在迷你堆里的“指针”:

func (h *MiniHeap) Alloc(name string, bytes int, fields ...int) int { idx := len(h.Objs) h.Objs = append(h.Objs, &Obj{ Color: White, Fields: fields, Name: name, Bytes: bytes, }) return idx }

根集合的方法更简单,直接塞几个下标进去:

func (h *MiniHeap) AddRoot(idxs ...int) { h.Roots = append(h.Roots, idxs...) }

3.2 标记循环:用队列完成图遍历

标记阶段是核心中的核心。我先把所有对象无条件重置为白色,然后从根集合出发,把根对象入队为灰色。之后进入循环:弹出一个灰色对象,把它涂成黑色,再把它的所有白色子对象涂成灰色并入队。循环结束的条件是队列为空——这代表所有可达对象都被扫完了。

func (h *MiniHeap) enqueue(idx int) { if h.Objs[idx] == nil || h.Objs[idx].Color != White { return } h.Objs[idx].Color = Gray h.pending = append(h.pending, idx) } func (h *MiniHeap) Mark() { for _, o := range h.Objs { if o != nil { o.Color = White } } h.pending = h.pending[:0] for _, r := range h.Roots { h.enqueue(r) } for len(h.pending) > 0 { idx := h.pending[0] h.pending = h.pending[1:] obj := h.Objs[idx] obj.Color = Black for _, child := range obj.Fields { h.enqueue(child) } } }

这段代码看起来很简单,但有三处细节非常考验理解:

第一,为什么初始要把所有对象重置为白色?因为上一轮 GC 结束后,存活对象是黑色的。如果不清白,下一轮标记时这些已经变黑的对象就永远不会被重新扫描,哪怕它们其实已经不可达了,也会被当成“永久存活”。真实 Go 的 GC 里,每次周期开始时也要通过类似机制保证清扫状态干净。

第二,为什么用pending[:0]而不是重新make一个队列?因为 object 分配在 slice 里本来就很频繁,每次 GC 都新建队列会额外制造一堆堆内存压力,复用底层数组是一个很实用的小优化。

第三,enqueue里为什么要判断Color != White才入队?因为同一对象可能被多个父对象引用,第一个父对象把它变灰后,第二个父对象再遇到它就不应该重复入队;否则就是一个无限循环。这本质上防止了重复遍历,也让每个对象最多入队一次。

3.3 清扫阶段与循环引用实验

标记做完之后,白色对象就是垃圾。清扫阶段直接遍历整个小堆,把所有仍为白色的对象置空,并统计回收掉的模拟内存字节数:

func (h *MiniHeap) Sweep() (reclaimed int, collected []string) { for i, o := range h.Objs { if o != nil && o.Color == White { collected = append(collected, o.Name) reclaimed += o.Bytes h.Objs[i] = nil } } return }

再写一个打印堆状态的辅助方法,方便我们观察每个对象的颜色:

func (h *MiniHeap) ShowHeap() { for i, o := range h.Objs { if o == nil { fmt.Printf("[%2d] <nil>\n", i) continue } color := byte('W') switch o.Color { case Gray: color = 'G' case Black: color = 'B' } fmt.Printf("[%2d] name=%-4s color=%c bytes=%d fields=%v\n", i, o.Name, color, o.Bytes, o.Fields) } }

下面这个main函数,是整篇博文里我觉得最有意思的实验。我构造了五对象的两组图:

  • 第一组:A 是根,A 引用 B,B 引用 C。
  • 第二组:D 引用 E,E 引用 D,互相引用形成环,但没有任何根指向它们。
func main() { h := &MiniHeap{} a := h.Alloc("A", 40) b := h.Alloc("B", 40) c := h.Alloc("C", 30) d := h.Alloc("D", 64) e := h.Alloc("E", 64) h.Objs[a].Fields = []int{b} h.Objs[b].Fields = []int{c} h.Objs[d].Fields = []int{e} h.Objs[e].Fields = []int{d} h.AddRoot(a) fmt.Println("--- before GC ---") h.ShowHeap() h.Mark() fmt.Println("--- after mark ---") h.ShowHeap() reclaimed, collected := h.Sweep() fmt.Printf("--- sweep: reclaimed=%d bytes, collected=%v ---\n", reclaimed, collected) h.ShowHeap() }

运行完,你会看到标记之后 A/B/C 全是黑色,D/E 保持白色;清扫阶段 D/E 被回收,回收了 128 个模拟字节。

这个实验意义重大。很多刚接触 GC 的人会想:能不能用引用计数?给每个对象记一个“被谁引用”的计数器,归零就回收?引用计数确实简单,但它有一个天然缺陷——循环引用。D 和 E 互相持有对方,引用计数永远不为 0,谁也回收不掉。三色标记法则完全不受影响,因为它判断的是“从根出发的可达性”,而不是“被引用的次数”。只要你从根摸不到 D 和 E,它们就必死。这个特点,是标记-清除算法最硬核的价值所在。

4. 并发标记的漏标问题:为什么真实 Go GC 需要写屏障

我只给 MiniHeap 实现了串行 GC,但真实世界的程序不可能为了 GC 暂停一切。Go 的 GC 是并发标记的:业务 goroutine 继续跑,后台的 mark worker 同时扫对象图。一并发,问题就来了:标记线程看的是某个时刻的快照,但业务线程还在时刻改写引用关系。

假设这样一个场景:

  1. 标记线程已经把对象 A 扫完,A 变成黑色。
  2. 此刻业务 goroutine 执行了A.Fields = append(A.Fields, C),让 A 新引用了一个白色对象 C。
  3. C 之前没有任何其他引用,它是从根出发不可达的,所以一直是白色。
  4. 标记线程不会再扫描 A(黑色对象已经完成扫描),于是 C 永远保持白色。
  5. 清扫阶段看到 C 是白色,直接回收。

问题就来了:C 明明已经被 A 引用了,是可达对象,却被当成垃圾回收了。等业务代码真正访问 C 时,C 已经被置空——这就是传说中的“漏标”,在真实运行时里会导致内存被错误释放后继续使用,属于非常严重的内存安全问题。

漏标的核心,是黑色对象重新指向了白色对象,破坏了之前我们强调的那个不变量:标记过程中,黑色对象不能持有指向白色对象的引用。

怎么修复?答案是写屏障(Write Barrier)。所谓“写屏障”,就是当业务代码要往一个指针字段写入新引用时,编译器会自动插入一段拦截逻辑,让运行时有机会纠正颜色状态。

我用代码演示一下往 MiniHeap 里加插入写屏障的姿势。假设要给某个对象设置引用字段:

func (h *MiniHeap) SetField(from, fieldIdx, to int) { obj := h.Objs[from] // 插入写屏障:如果 from 是黑色,并且 to 是白色, // 立刻把 to 涂成灰色,丢进待扫描队列。 if obj.Color == Black && h.Objs[to] != nil && h.Objs[to].Color == White { h.enqueue(to) } obj.Fields[fieldIdx] = to }

这段代码的意义在于:黑对象只要往白色对象上写引用,就被“抓现行”,白色对象立刻变灰,进入待扫描队列,之后再被并发标记器扫成黑色。漏标就被堵住了。

真实 Go runtime 用的是混合写屏障(Hybrid Write Barrier),它同时结合了插入写屏障和删除写屏障两种思路,从 Go 1.8 开始稳定使用。具体实现比这复杂得多,写屏障的机器码会由编译器在每次指针写入的地方自动生成,运行时还配合栈扫描来保证所有 goroutine 的根引用都在正确的颜色状态。但无论实现怎么复杂,你要理解的核心就一句话:并发标记不能靠“运气”,必须靠写屏障保证黑对象不会直接指向白对象。

那为什么 Go 还是需要一小段 STW?因为即使有写屏障,GC 开始和结束时也需要让所有 goroutine 停在某个安全点(Safe Point),统一建立一致的根集快照,然后才能精确扫描栈和寄存器。Go 已经努力把这两段 STW 压缩到亚毫秒级别,代价是让并发标记期间业务 goroutine 也要参与一部分 GC 工作(称为 GC 辅助/Assist)。这就是为什么日志里0.22+2.4+0.12 ms clock那段 cpu 字段会出现0.42/0.84/0.32——分别对应标记期间的辅助 GC、后台标记和空闲标记工作的真实 CPU 消耗。

5. 把玩具模型映射回真实 Go:gctrace 日志、GOGC 与 GOMEMLIMIT

写完玩具 GC,再回头看gctrace那行日志,你会觉得每个数字都有了归宿。我建议你亲自跑一跑:

GODEBUG=gctrace=1 GOGC=100 go run main.go

如果只调GOGC,不设GOMEMLIMIT,Go 的触发目标堆大小大约是:

next_goal = live * (1 + GOGC/100)

默认GOGC=100意味着:上一轮 GC 结束后存活对象是 15MB,目标堆就是 30MB;当程序分配出约 15MB 的新对象,堆总占用到 30MB 时就触发下一次 GC。这个机制本质上给 GC 设了一个“垃圾容忍度”——GC 愿意放多少垃圾在堆里等攒够再一次清。

从 Go 1.21 开始,官方强烈建议配合GOMEMLIMIT使用,例如:

GODEBUG=gctrace=1 GOGC=100 GOMEMLIMIT=80MiB go run main.go

GOMEMLIMIT给 Go 一个“堆内存软上限”,当上面的公式计算出的目标堆大于这个限制时,Go 会提前触发 GC,尽量把堆占用压到限制以内。它在容器场景下很有用,因为你往往希望线上服务不要超过容器内存上限。

但这里有一个很多人踩过的大坑:如果把 GOMEMLIMIT 调得比真实存活对象还小,或者比实际工作集还要紧,GC 就会疯狂触发,CPU 反而暴涨。想象你给仓库定的最大容量比日常货物量还小,那仓库管理员只能每隔几分钟就盘点一次,所有时间都花在盘点上。所以它叫“软限制”而不是硬限制——设置时不看程序真实内存画像,等于给自己挖坑。

我在压测那个网关时,最后发现的真正问题是:服务里有一个路径频繁创建大型结构体切片,每秒钟分配出好几 MB 的临时数据,导致 GC 每 1~2 秒就来一次。优化方案其实不是疯狂调参数,而是把大对象改成对象池复用,把分配速率降下来。改造完后,GC 频率从每秒几次降到每 10 秒一次,STW 时间也回到了亚毫秒级,P99 自然恢复正常。调参之前先看分配率,这是我在这次实战里最想强调的一点。

如果你想要更细粒度的观测数据,我建议用runtime/metrics里的指标,在程序里定期采样/gc/heap/goals:bytes、/gc/cycles/total:gc-cycles和/gc/pauses:seconds这几个指标,它们会给你比gctrace更结构化的信息,也比较适合接进监控系统。

6. 手写了这一遍之后:几个常见问题、踩过的坑,以及它给我留下的改变

最后聊几个手写过程中绕不开的问题,和一些我觉得值得分享的经验。

很多人会问:Go 为什么不用分代 GC?分代 GC(年轻代/老年代)在 Java 虚拟机里很成功,能有效提升吞吐。我的理解是,分代 GC 的前提是对象有明显的“朝生夕死”特征,并且需要记录跨代引用(Remembered Set)来做写屏障,维护开销不低。Go 的对象分配大多走栈上分配和逃逸分析,编译器已经把很大一部分短期对象消灭在了栈上,能走到堆里的对象生命周期相对复杂;加上 Go 拥抱并发 GC 的确定性低延迟,原生并发标记-清除方案在工程上更简单、更可控。所以你在 Go 里的 GC 调优,基本不会看到“调年轻代大小”这种操作,而是看 GOGC、GOMEMLIMIT 和分配热点。

还有人问,为什么 Go 的 GC 不整理内存碎片?因为三色标记-清除是非移动式回收——回收时不搬移存活对象。要搬移对象,就必须把指向它的所有指针全部找出来改一遍,这对 Go 这种到处是指针、栈上还有大量指针的语言来说,成本极高。Go 选择用 TCMalloc 风格的空闲列表管理 span,配合 bitma 标记来降低碎片和管理开销。

手写这个玩具 GC 时我踩过两个记忆深刻的坑:

第一个是忘了每个周期重置颜色。我第二版代码在一开始没有把所有对象重置为白色,导致上一轮已经变黑的对象在新一轮 GC 里永远是黑色,哪怕它早就不可达了。调试时看到一堆“回收不了的内存”,才反应过来原理书里写的“把每个对象刷白”不是一句废话。

第二个是用下标引用带来的越界隐患。真实 Go 里指针可以空、可以指向任意对象,但你不会用一个 int 下标访问一个不存在的对象。在迷你堆里我用下标模拟指针,一旦手工构造的图里字段下标写错,就会index out of range或者 nil 引用。后面我加了一个SanityCheck函数,在标记前把所有 Fields 的下标范围和 nil 都检查一遍,节约了大量调试时间。

至于扩展方向,如果你也想自己试试,我列几个特别有意思的玩法:

  • 增量标记:限制每次 GC 最大处理多少个灰色对象,模拟“GC 和业务交替运行”的真实场景。
  • 弱引用:给字段加一个Weak bool,清扫时如果弱引用指向的对象被判死,就把字段置为 -1。
  • 短生命周期分析:统计每个对象从分配到回收经历的 GC 轮数,亲手感受一下“对象晋升”这个分代概念到底在说什么。

那次手写最大的改变,是我从此看函数调用链时有了一种“图视角”:一个对象从函数入参传到另一个函数,本质上就是在对象图的边上游走。线上遇到内存或者 GC 相关问题时,我不再只会盯着top和pprof的火焰图看,还会下意识地问一句:这批对象到底被谁持有?它们是不是根?如果根都无法到达,那它们迟早会被回收。这个思维方式,比记住任何 GC 参数都更有用。如果你也想真正理解 Go 的自动内存管理,我强烈建议你花一个下午,把这篇代码自己敲一遍,再改几个对象图跑一跑——读懂源码永远不如亲手写一遍来得踏实。

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

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

立即咨询