Go GC 垃圾回收机制全解析:三色标记、混合写屏障与 STW 优化演进
引言
「Go 的 GC 是什么算法?」「三色标记是哪三色?」「写屏障解决了什么问题?」「怎么排查 GC 卡顿?」—— GC 是 Go 面试中承上启下的大题:它往上承接第 11 期 《GMP 调度器模型》(标记工作由谁执行),往下连接第 12 期 《逃逸分析》(对象是否逃逸决定了 GC 的工作量)。本期从算法原理讲到混合写屏障,再到 STW 优化与调优手段,把这条链路补完整。
一、三色标记法:Go GC 的核心算法
Go 从 1.5 版本起采用**并发三色标记-清除(Tri-Color Mark & Sweep)**算法。核心思想:把堆中的对象分成三种颜色——
| 颜色 | 含义 |
|---|---|
| 白色 | 尚未访问到,是潜在的垃圾回收对象 |
| 灰色 | 已访问到,但它引用的其他对象还没扫描完 |
| 黑色 | 已访问到,且它引用的对象全部扫描完成(确定存活) |
标记过程从根对象(goroutine 栈、全局变量、寄存器)出发:
- 将根对象直接引用的对象全部染成灰色;
- 从灰色集合中取出一个对象,扫描它的所有指针字段:把指向的白色对象染灰,然后把自己染黑;
- 重复第 2 步,直到灰色集合为空;
- 清除阶段回收所有白色对象。
算法本身很简单,难点在「并发」——标记是和用户代码同时跑的,用户程序随时可能修改引用关系。
二、并发标记的漏标问题与写屏障
并发标记必须维护三色不变式:黑色对象不允许直接引用白色对象(强三色不变式)。否则会出现经典的「漏标」场景:
黑色对象 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
它的三条关键约定(面试要答全):
- GC 开始时,栈上所有可达对象直接标记为黑色,不需要再 STW 重扫栈;
- GC 期间,栈上新创建的对象一律视为黑色(本轮不回收);
- 堆上的指针写入走混合写屏障,旧值、新值都保护。
效果是:彻底消除了标记终止阶段对全部 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 触发条件与调优实战
触发条件(三条满足其一):
- 堆增长触发:堆大小达到上次 GC 存活堆的
GOGC百分比(默认 100,即翻倍); - 定时触发:距离上次 GC 超过 2 分钟,强制触发;
- 手动触发:
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+ 题) · 面试题系列总目录
- 《Go 程序设计语言》(Go 语言圣经,机械工业出版社) —— Go 官方团队出品,公认入门第一书
- 《Go 语言实战》(异步图书,人民邮电出版社) —— 实战导向,面试前巩固工程手感
- 《Go 语言编程之旅:一起用 Go 做项目》(博文视点,电子工业出版社) —— 项目驱动入门,从零写出完整应用
- Keychron K1Max 矮轴机械键盘(Mac/Win 双系统,QMK/VIA 开源改键) —— 轻薄矮轴 + 开源固件,程序员桌面常客
- 罗技 MX Master 3S 无线鼠标(Flow 跨屏,静音电磁滚轮) —— 人体工学设计,写代码一整天手不酸
以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。