Go GC 垃圾回收机制全解析:三色标记、混合写屏障与 STW 优化演进


引言

「Go 的 GC 是什么算法?」「三色标记是哪三色?」「写屏障解决了什么问题?」「怎么排查 GC 卡顿?」—— GC 是 Go 面试中承上启下的大题:它往上承接第 11 期 《GMP 调度器模型》(标记工作由谁执行),往下连接第 12 期 《逃逸分析》(对象是否逃逸决定了 GC 的工作量)。本期从算法原理讲到混合写屏障,再到 STW 优化与调优手段,把这条链路补完整。

一、三色标记法:Go GC 的核心算法

Go 从 1.5 版本起采用**并发三色标记-清除(Tri-Color Mark & Sweep)**算法。核心思想:把堆中的对象分成三种颜色——

颜色 含义
白色 尚未访问到,是潜在的垃圾回收对象
灰色 已访问到,但它引用的其他对象还没扫描完
黑色 已访问到,且它引用的对象全部扫描完成(确定存活)

标记过程从根对象(goroutine 栈、全局变量、寄存器)出发:

  1. 将根对象直接引用的对象全部染成灰色;
  2. 从灰色集合中取出一个对象,扫描它的所有指针字段:把指向的白色对象染灰,然后把自己染黑;
  3. 重复第 2 步,直到灰色集合为空;
  4. 清除阶段回收所有白色对象。

算法本身很简单,难点在「并发」——标记是和用户代码同时跑的,用户程序随时可能修改引用关系。

二、并发标记的漏标问题与写屏障

并发标记必须维护三色不变式:黑色对象不允许直接引用白色对象(强三色不变式)。否则会出现经典的「漏标」场景:

黑色对象 A 已扫描完 → 用户代码让 A 引用白色对象 C → 同时 C 唯一的灰色引用路径被删掉 → 下一轮扫描永远到不了 C → 存活的 C 被误回收,程序直接崩溃。

两种经典解法对应两种写屏障:

  • Dijkstra 插入屏障:指针写入时,把新指向的对象染灰(保护新值)。Go 1.5~1.7 用它,但栈上的写不经过屏障(性能考虑),所以标记结束前必须 STW 把所有 goroutine 栈重新扫一遍,STW 长达几十毫秒。
  • Yuasa 删除屏障:指针写入时,先把被覆盖的旧引用染灰(保护旧值),相当于维护 GC 开始时刻的引用快照。

三、混合写屏障:Go 1.8 的破局

Go 1.8 引入混合写屏障(Hybrid Write Barrier),逻辑等价于「插入 + 删除」二合一:

// 堆指针写入时由编译器插入,伪代码:
shade(*slot)   // Yuasa 删除屏障:旧引用染灰
shade(new)     // Dijkstra 插入屏障:新引用染灰
*slot = new

它的三条关键约定(面试要答全):

  1. GC 开始时,栈上所有可达对象直接标记为黑色,不需要再 STW 重扫栈;
  2. GC 期间,栈上新创建的对象一律视为黑色(本轮不回收);
  3. 堆上的指针写入走混合写屏障,旧值、新值都保护。

效果是:彻底消除了标记终止阶段对全部 goroutine 栈的 STW 重扫,Go 的 STW 时间从几十毫秒降到亚毫秒级,通常只有 10~100μs 量级。代价是每次堆指针写入多了几个分支判断的微小 CPU 开销。

四、STW 优化演进史(面试速查表)

版本 机制 关键改进 STW 量级
Go 1.3 之前 串行标记-清除 全程 STW,简单但停顿长 百毫秒~秒级
Go 1.5 并发三色标记 + 插入写屏障 标记与用户代码并发,但需 STW 重扫栈 毫秒级
Go 1.8 混合写屏障 消除栈重扫,停顿大幅缩短 亚毫秒级
Go 1.25 Green Tea GC(实验性,GOEXPERIMENT=greenteagc) 基于 span 的标记算法,缓存局部性更好 停顿非主要目标
Go 1.26 Green Tea GC 默认开启 GC 开销降低约 10%~40%,可用 GOEXPERIMENT=nogreenteagc 关闭 —

Go 的 GC 演进史,本质上就是一部 STW 缩减史。另外值得一提的是调度侧的配套优化:Go 1.14 引入基于信号的抢占式调度后,长时间不调用函数的死循环 goroutine 也能被打断,不会再无限期阻塞 GC 的 STW 收口(这一点与第 19 期 《goroutine vs 线程》 中讲的协作式调度演进呼应)。

五、GC 触发条件与调优实战

触发条件(三条满足其一):

  1. 堆增长触发:堆大小达到上次 GC 存活堆的 GOGC 百分比(默认 100,即翻倍);
  2. 定时触发:距离上次 GC 超过 2 分钟,强制触发;
  3. 手动触发:runtime.GC()。

GC 频率的决定因素是分配速度,不是对象存活率——这就是为什么高频创建临时对象的服务 GC 压力大,而第 25 期 《sync.Pool》 能有效缓解。

调优三板斧:

# 1. 观察:开启 GC 跟踪日志,每次 GC 打印一行(线上可临时开启)
GODEBUG=gctrace=1 ./your-app

# 2. 调阈值:降低 GC 频率,换取更多内存(容器环境常用)
GOGC=200 ./your-app        # 堆翻倍再翻倍才触发,GC 更少、内存更多
GOGC=off ./your-app        # 彻底关闭(仅特殊场景,慎用)

# 3. 代码侧:用 runtime/metrics 在程序内读取 GC 指标
package main

import (
	"fmt"
	"runtime/metrics"
)

func main() {
	names := []string{
		"/gc/cycles/total:gc-cycles",       // GC 总轮次
		"/gc/heap/allocs:bytes",            // 累计分配字节数
		"/memory/classes/heap/objects:bytes", // 当前堆中存活对象占用
	}
	samples := make([]metrics.Sample, len(names))
	for i, name := range names {
		samples[i].Name = name
	}
	metrics.Read(samples)
	for _, s := range samples {
		fmt.Printf("%s: %v\n", s.Name, s.Value)
	}
}

gctrace 输出中重点看三个数:STW 两端的停顿时间(应稳定在百微秒级)、CPU 列的 GC 占用比例、MB 列的堆水位。如果 GC CPU 占比超过 20%,就该考虑降低分配速率或用 sync.Pool 复用对象了。

六、展望:Green Tea GC

Go 1.25 引入了代号为 Green Tea 的实验性垃圾回收器(构建时加 GOEXPERIMENT=greenteagc 开启),Go 1.26 起成为默认。它把标记的遍历单位从单个对象换成 8 KiB 的内存页(span),按地址顺序做广度优先扫描,大幅改善缓存局部性,官方数据是 GC 开销降低约 10%~40%。它不改变三色标记的语义,改的是性能,面试答出「对象级 DFS → 页级 BFS、缓存友好」就赢了大部分人。

结语

回答「Go GC 怎么实现的」时,标准骨架是:并发三色标记 + 混合写屏障:三色标记保证能找出所有存活对象,写屏障保证并发修改引用时不漏标,混合写屏障(Go 1.8)靠「栈黑化 + 双 shade」干掉了栈重扫的 STW,把停顿压到亚毫秒级。再补一句触发条件(GOGC 翻倍 + 2 分钟兜底)和调优手段(gctrace / GOGC / sync.Pool),这道题就是满分答案。


本文是「Go语言面试题」系列第 30 篇。 相关阅读:Go 逃逸分析:对象什么时候会逃到堆上 · Go 内存模型与 happens-before · Go 调度器 GMP 模型详解 · Go语言面试题库(150+ 题) · 面试题系列总目录

🛒 站长推荐(京东联盟)

以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo · 隐私政策