Go三色标记法本质是并发可达性分析协议,白色表“未确认可达”,灰色表“已确认可达但子引用未扫完”,黑色表“本轮GC中已完全纳入闭包”;状态由gcWork与mbitmap协同推演,非对象内存属性;混合写屏障(shade(old)+shade(ptr))强制保障强三色不变式,STW仅限根标记与标记终止两处,压至百微秒级。

Go 的三色标记不是给对象“染色”,混合写屏障也不是可选优化——它们是并发 GC 能压到百微秒级 STW 的硬性前提,缺一不可。
三色状态到底代表什么
白色不是“垃圾”,只是“还没查过”;灰色不是“正在扫”,而是“已确认存活,但子引用还没扫完”;黑色也不是“永远安全”,而是“本轮 GC 中,它和它所有可达对象都已进闭包”。这些状态不存于对象内存里,是 gcWork 队列和 mbitmap 位图协同推演出来的运行时视图。
常见误解:以为 runtime.GC() 会把堆上所有对象过一遍颜色。实际只从根对象(goroutine 栈、全局变量、寄存器)出发推进灰色边界,中间大量对象根本不会被访问到——只要没被根路径连上,它始终是白色,直到被回收。
- 新分配对象默认为白色,哪怕刚被栈变量引用
- 栈上对象在 GC 开始时被并发扫描并标黑,之后不再重扫(这是混合写屏障的关键前提)
- 黑色对象的字段指针在本轮 GC 中不再被重新扫描,所以必须靠写屏障堵住“黑→白”新路径
混合写屏障为什么必须同时 shade(old) 和 shade(ptr)
它解决的是并发下最致命的竞态:Mutator 把一个黑色对象 A 的字段从指向白色对象 C 改为指向白色对象 B,而 GC 已经扫完 A,B 就再也没机会被标记——漏标,误回收。
混合写屏障在每次 *slot = ptr 前自动插入,只对堆上指针写入生效(结构体字段、切片元素、map value 是指针类型、接口赋值),伪逻辑等价于:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
if gcphase == _GCmark {
if old != nil { shade(old) } // 删除语义:保旧对象不死
if ptr != nil { shade(ptr) } // 插入语义:拉新对象入队
}
- 旧对象标灰,防止它被提前回收(比如 C 还被其他路径引用)
- 新对象标灰,确保它不会漏标(比如 B 必须被后续扫描到)
- 栈上变量之间赋值、
int/string底层数据、unsafe.Pointer绕过类型系统,都不触发该屏障
STW 真实发生在哪里
很多人看 go tool trace 里“GC pause”曲线很短,就以为 GC 没停顿——其实 STW 只发生在两个原子切片,但必须发生:
-
root marking:暂停所有 goroutine,遍历当前所有栈帧和全局变量,把根对象标灰(通常 -
mark termination:标记主循环结束后,再停一次,清空灰色队列残余 + 重扫所有 goroutine 栈(因栈小且变更快,重扫比插屏障更高效)
中间整个标记推进、清扫阶段全部与用户代码并发执行。所谓“几乎无 STW”,是指它不发生在标记过程中,而仅锁这两个精确入口点。
你写的代码怎么影响 GC 行为
GC 不管你逻辑上要不要这个对象,只认可达性。一个 make([]byte, 0, 1024*1024) 如果逃逸到堆上,就会变成 GC 必须追踪的存活对象——哪怕它只活几毫秒。
- 高频创建大缓冲区(如 HTTP handler 中解码图片)会抬高 GC 频率和标记开销
- 把对象塞进全局
map或未缓冲 channel,等于人为延长其生命周期,让 GC 无法回收 -
defer file.Close()不写,time.AfterFunc不Stop(),GC 不会帮你关 fd 或 cancel timer - 真正该信的指标是
runtime.ReadMemStats,不是 trace 里的“pause”时间——后者只反映 STW,而瓶颈常在标记 CPU 占用或辅助 GC(mutator assist)延迟
写屏障本身带来约 3%~8% 的指针写入开销,但它换来的是 STW 从毫秒级降到百微秒级——这个权衡由运行时硬编码,你无法禁用,也无需手动调用,但必须理解它如何塑造你的内存行为。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










