★ Go GC 的三色标记与写屏障是什么?
结论先行
Go 的 GC 是并发标记-清扫(mark & sweep)回收器,用三色抽象描述可达性标记:白色 = 尚未标记(垃圾候选)、灰色 = 已标记但引用还没扫完、黑色 = 已标记且引用扫完。标记从根对象出发把白色变灰、扫描灰对象引用再变黑,最终剩下的白色对象就是垃圾。并发标记期间为防“黑对象引用新白对象”导致误回收,需要写屏障维持不变式,Go 用的是混合写屏障。
要点
- 三色过程:开始时所有对象白色;根(全局变量、goroutine 栈、寄存器)可达对象变灰入队;扫描灰对象的引用,把它们变灰、自己变黑;灰色清空后,白色即不可达垃圾。
- 并发难点:标记与用户程序并行,用户可能让“已扫过的黑对象”重新指向“还没标记的白对象”,不保护就会误回收活对象。
- 屏障思想:在指针写发生时插入检查代码,维护“黑不直接引用白”的强三色不变式(或其弱化版本),从机制上堵住漏标。
- 混合写屏障:插入屏障(Dijkstra:黑引白时把白置灰)加删除屏障(Yuasa:删除对白的引用时把它置灰)组合,覆盖并发写场景。
- 栈的处理:栈上的指针写不便频繁插屏障,Go 让各 goroutine 在安全点被扫描、并配合屏障,把全停顿压到极短。
- 阶段节奏:标记准备(短暂 STW:开启写屏障、收集根)→ 并发标记(mark workers 与业务并行)→ 标记终止(短暂 STW:关屏障、收尾)→ 并发清扫(内存随分配惰性归还分配器)。
颜色速查
| 颜色 | 含义 | 后续处理 |
|---|---|---|
| 白 | 尚未标记,垃圾候选 | 标记结束后回收 |
| 灰 | 已标记,引用待扫描 | 入队等待扫描 |
| 黑 | 已标记,引用扫描完毕 | 无需再处理 |
主动干预示例
正常线上不手动开关 GC,主要靠环境变量调节奏;下面代码仅供了解调试入口:
import "runtime/debug"
func main() {
// 返回并修改 GC 目标百分比;-1 表示临时关闭自动触发(仅调试)
prev := debug.SetGCPercent(-1)
defer debug.SetGCPercent(prev)
}
实际调优优先使用 GOGC 与 GOMEMLIMIT(Go 1.19+),先看监控里 GC 周期与
堆增长曲线,再决定是否干预,而不是在代码里到处 SetGCPercent。
和 Java G1 的粗略对照
| 维度 | Go GC | Java G1 |
|---|---|---|
| 分代 | 不分代,整堆标记 | 分代 + Region 局部回收 |
| 停顿目标 | 并发标记 + 并发清扫,毫秒级 STW | 可配置停顿时间目标 |
| 调参入口 | GOGC / GOMEMLIMIT | -Xmx、-XX:MaxGCPauseMillis 等 |
| 适用 | 服务端高并发程序 | 大堆企业应用 |
对比的价值不在“谁更好”,而是说明:不同运行时的 GC 都在“标记-清扫”框架里 按自己的目标(延迟/吞吐)做取舍,原理相通。
常见追问/记忆点
- 追问:为什么不全 STW 标记?答:STW 停顿与业务规模强相关,屏障让绝大多数标记与业务并行,停顿降到毫秒量级甚至更低。
- 追问:插入屏障与删除屏障区别?答:插入管“新增引用”(黑→白置灰),删除管“被删引用”(被断开的白置灰);Go 混用两者覆盖堆上并发写。
- 追问:三色标记会造成漏标还是多标?答:只会“多标”(浮动垃圾留到下一轮),配合屏障不会“漏标”活对象。
- 记忆:白灰黑三步走;写屏障守“黑不指白”;混合屏障 = 插入 + 删除,换来近乎无 STW 的并发标记。