runtime.GC()触发后不重扫整个堆,仅从当前根对象出发执行标准三色标记周期;白色对象若不可达则本就不该存活,未被扫描是正常现象,非漏标。

runtime.GC 触发后,三色标记不从所有对象开始
调用 runtime.GC() 不会“重扫整个堆”,它只启动一次标准 GC 周期:从当前根对象(goroutine 栈、全局变量、寄存器中存活指针)出发,按三色标记流程推进。大量未被根路径连上的对象始终是白色,根本不会进入灰色队列——它们不是“漏标”,而是本就不该活。
常见错误现象:runtime.GC() 返回后内存没明显下降,误以为标记失败。实际是堆里存在大量“不可达但未被本轮扫描到”的白色对象,它们会在下一轮 GC 或内存压力触发时才被回收。
- 栈上局部变量在 STW 的 root marking 阶段被一次性标灰,之后不再重扫(靠混合写屏障保护)
- 新分配的堆对象默认为白色,哪怕立即被栈变量引用,也要等下次 GC 的根扫描或写屏障介入才可能变灰
-
runtime.GC()是同步阻塞调用者 goroutine,但标记过程本身是并发的(多个gcWorker协程并行处理灰色队列)
灰色对象不是“正在被扫描”,而是“已确认存活但子引用未扫完”
灰色状态不是运行时给对象内存打的标签,而是 gcWork 队列 + mbitmap 位图协同维护的逻辑视图。一个对象进灰色队列,只代表“它已被发现存活,且它的字段还没全看过”——它可能被多个 goroutine 同时从队列中取出、扫描、标黑。
容易踩的坑:以为把对象标灰就等于“保住了”。其实灰色对象若在被扫描前就被 mutator 修改字段指向新白色对象,而写屏障又没生效(比如写入的是栈变量或非指针字段),仍可能漏标。
- 结构体字段、切片元素、map value 是指针类型、接口赋值 → 触发混合写屏障
- 栈上变量之间赋值、
int/string底层数据、unsafe.Pointer→ 不触发写屏障 - 黑色对象的字段在本轮 GC 中不再被重新扫描,所以写屏障必须在 *slot = ptr 时立刻介入
混合写屏障的 shade(old) 和 shade(ptr) 缺一不可
混合写屏障伪逻辑中的 shade(old) 和 shade(ptr) 是应对同一竞态的两个必要动作,不是可选优化。删掉任一个,都会在高并发写场景下导致漏标。
典型漏标路径:A(黑)→ C(白),mutator 执行 A.field = B(B 是新分配的白对象),此时若只 shade(ptr) 而不 shade(old),C 就彻底失联;若只 shade(old) 而不 shade(ptr),B 就永远不会被标记,最终被误回收。
- shade(old):把原指针指向的对象(C)标灰,防止它因失去最后引用而提前被回收
- shade(ptr):把新指针指向的对象(B)标灰,确保它被后续扫描纳入闭包
- 该屏障仅对堆上指针写入生效,栈变量赋值不走此路径,所以 GC 必须在标记终止阶段重扫所有 goroutine 栈
STW 实际只发生在 root marking 和 mark termination 两处
很多人看 go tool trace 里“GC pause”很短,就以为 GC 没停顿——但 STW 是硬性必需的原子切片,不是可规避的设计。
root marking 阶段 STW:暂停所有 goroutine,遍历当前所有栈帧和全局变量,把根对象标灰;mark termination 阶段 STW:等所有 gcWorker 清空灰色队列后,再暂停一次,重扫所有 goroutine 栈(因为栈小、变更快,重扫比插屏障更高效)。
- 两次 STW 总时长压在百微秒级,但它们是并发标记能成立的前提,不是“尽量减少”的目标,而是不可省略的同步点
- 中间的并发标记阶段,用户代码和 gcWorker 完全并行,但写屏障开销真实存在,高频指针写会拖慢 mutator
- 不要试图用
runtime.GC()来“强制清理某块内存”,它不认对象地址,也不提供精确控制入口
真正难处理的从来不是标记逻辑本身,而是写屏障生效边界的模糊性:哪些写入算“堆上指针写入”,哪些被编译器优化掉,哪些绕过类型系统——这些地方一旦出错,漏标就是静默发生的。











