去年年底我负责的一个 Go 消息网关在压测时出现了很诡异的 P99 上涨CPU 和内存都还算正常但接口响应就是时不时抖一下。我打开GODEBUGgctrace1抓日志才意识到问题出在 Go 垃圾回收GC的周期性停顿上——那几行gc 34 18.9s日志里STW 的 clock 时间一度接近 30ms。说实话在那之前我对 Go GC 的了解仅限于“自动回收挺好的不用管”日志里的数字让我彻底意识到一件事如果连 GC 在干什么都不懂遇到这类问题就只能瞎猜。所以我做了一件很笨但很有用的事用 Go 语言从零手写了一个带有“三色标记法”的简易 GC——不是去改 runtime 源码那对普通应用开发者没有意义而是在用户态维护一个迷你堆自己管理对象分配、对象引用、根集合、标记阶段和清扫阶段。这篇文章就是那次实验的完整记录。它适合三种人看被 GC 调参困扰的人、准备 Go 相关面试的人、以及单纯想知道“对象到底是怎么被回收”的纯好奇开发者。文章会拆解三色标记法的核心数据结构、完整可运行的代码、并发下漏标问题与写屏障为何存在、以及真实 Go GC 日志怎么看、参数怎么配。1. 从一个 30ms 的 STW 说起GC 到底在忙什么先看那条让我破防的日志。用GODEBUGgctrace1跑一个分配密集型 Go 程序标准输出里会出现类似这样的行gc 34 18.931s 5%: 0.222.40.12 ms clock, 0.310.42/0.84/0.320.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.222.40.12 ms clock这是最关键的一段。它表示一次 GC 的三个阶段耗时第一个数0.22ms是开始前的“清扫终止”阶段需要 STW第二个数2.4ms是并发标记阶段大部分和业务代码并行跑第三个数0.12ms是“标记终止”阶段又需要 STW。所以真正让业务停顿的是0.22 0.12 0.34ms左右。如果你发现这两个数变成几十毫秒那线上一定会有感知。26-26-15 MBGC 开始前的堆内存是 26MBGC 结束后的堆内存是 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.go3.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 引用 BB 引用 C。第二组D 引用 EE 引用 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 同时扫对象图。一并发问题就来了标记线程看的是某个时刻的快照但业务线程还在时刻改写引用关系。假设这样一个场景标记线程已经把对象 A 扫完A 变成黑色。此刻业务 goroutine 执行了A.Fields append(A.Fields, C)让 A 新引用了一个白色对象 C。C 之前没有任何其他引用它是从根出发不可达的所以一直是白色。标记线程不会再扫描 A黑色对象已经完成扫描于是 C 永远保持白色。清扫阶段看到 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.222.40.12 ms clock那段 cpu 字段会出现0.42/0.84/0.32——分别对应标记期间的辅助 GC、后台标记和空闲标记工作的真实 CPU 消耗。5. 把玩具模型映射回真实 Gogctrace 日志、GOGC 与 GOMEMLIMIT写完玩具 GC再回头看gctrace那行日志你会觉得每个数字都有了归宿。我建议你亲自跑一跑GODEBUGgctrace1 GOGC100 go run main.go如果只调GOGC不设GOMEMLIMITGo 的触发目标堆大小大约是next_goal live * (1 GOGC/100)默认GOGC100意味着上一轮 GC 结束后存活对象是 15MB目标堆就是 30MB当程序分配出约 15MB 的新对象堆总占用到 30MB 时就触发下一次 GC。这个机制本质上给 GC 设了一个“垃圾容忍度”——GC 愿意放多少垃圾在堆里等攒够再一次清。从 Go 1.21 开始官方强烈建议配合GOMEMLIMIT使用例如GODEBUGgctrace1 GOGC100 GOMEMLIMIT80MiB go run main.goGOMEMLIMIT给 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 的自动内存管理我强烈建议你花一个下午把这篇代码自己敲一遍再改几个对象图跑一跑——读懂源码永远不如亲手写一遍来得踏实。