3699 字
18 分钟
Go GC 三色标记法与混合写屏障

Go GC 采用并发三色标记-清除(Concurrent Tri-color Mark-Sweep)算法,结合 Hybrid Write Barrier(混合写屏障) 保证并发安全。Go 1.8 引入的混合写屏障使 STW 停顿迈入亚毫秒级,并实现了”栈上不开启写屏障”的关键突破。

[!tip] 面试回答 Go GC 的三色标记法把对象分成白、灰、黑三类:白色表示还没有被发现,灰色表示已经确认可达但它引用的对象还没有扫描完,黑色表示对象及其引用都已经扫描完成。标记时,GC 从 Roots 出发,把可达对象加入灰色集合,再不断扫描灰色对象,将它引用的白色对象标记为灰色,自己转为黑色;标记结束后仍为白色的对象就是不可达垃圾,会在清扫阶段被回收。由于 Go 的标记过程会与用户程序并发执行,业务代码修改引用时可能造成存活对象漏标,因此 Go 1.8 引入混合写屏障:在堆指针被修改时保护被覆盖的旧引用,并在需要时保护新写入的引用,把相关对象加入标记队列,从而维持三色不变性。这样大部分标记和清扫工作都能并发完成,只在关键阶段进行短暂 STW,在保证回收正确性的同时降低停顿时间。


目录#

  1. 核心概念:三色标记模型
  2. 标准标记流程
  3. 并发 GC 的漏标问题
  4. 混合写屏障:解决并发漏标
  5. 为什么栈上不开启写屏障?
  6. 伪代码实现
  7. 总结

1. 核心概念:三色标记模型#

GC 的核心任务是区分存活对象和垃圾对象。三色标记法将内存中的对象按扫描状态分为三种颜色集合:

1.1 三种颜色#

颜色含义GC 结束后的命运
白色(White)潜在的垃圾对象。GC 尚未扫描到它被回收(Sweep)
灰色(Grey)已发现存活,但子节点(引用)尚未被扫描继续被扫描
黑色(Black)确认存活,且所有子节点已扫描完毕存活保留

1.2 各颜色详解#

白色集合:

  • GC 启动时,所有对象默认均为白色
  • 标记阶段结束后仍为白色的对象 = 不可达垃圾,将被清扫

灰色集合:

  • 本质是一个工作队列(Work Queue / Wavefront)
  • GC 找到该对象是存活的,但还未”顺藤摸瓜”检查它所引用的其他对象
  • 从灰色集合取对象 → 扫描其指针 → 子对象变灰 → 自身变黑

黑色集合:

  • 该对象的全部下级引用已被扫描完毕
  • 铁律:黑色对象永远不可能直接指向白色对象(这是保证 GC 正确性的前提)

1.3 集合转换示意#

┌──────────────────────┐
│ 白色 (White) │ ← 所有对象初始状态
│ "潜在的垃圾" │
└──────────┬───────────┘
│ GC 从 Roots 发现它可达
↓
┌──────────────────────┐
│ 灰色 (Grey) │ ← 工作队列 / 波面
│ "存活但未扫子节点" │
└──────────┬───────────┘
│ 扫描完子节点(所有引用)
↓
┌──────────────────────┐
│ 黑色 (Black) │ ← 确认存活
│ "全部扫描完毕" │
└──────────────────────┘

2. 标准标记流程#

标准的三色标记法(STW 停顿环境下)分为以下步骤:

2.1 流程图#

步骤 1:初始化
┌──────────────────┐
│ 所有对象 = 白色 │
└────────┬─────────┘
↓
步骤 2:根节点扫描(Root Scanning)
┌──────────────────────────────┐
│ 从 GC Roots 出发: │
│ - 全局变量 │
│ - 当前 Goroutine 栈上变量 │
│ - 寄存器 │
│ 将 Roots 直接引用的对象 → 灰色 │
└────────┬─────────────────────┘
↓
步骤 3:循环遍历(Tracing)──┐
┌──────────────────┐ │
│ 从灰色集合取一对象 │ │
│ 扫描其所有指针引用 │ │
│ 白色子对象 → 灰色 │ │
│ 自身 → 黑色 │────┘ 重复直到灰色集合为空
└──────────────────┘
↓
步骤 4:清扫(Sweep)
┌──────────────────┐
│ 仍为白色 → 回收 │
│ 黑色 → 重置为白色 │
└──────────────────┘

2.2 关键步骤详解#

① 初始状态:GC 开始时,堆上所有对象默认标记为白色。

② 根节点扫描(Root Scanning):

  • GC Roots 包括:
    • 全局变量(.data / .bss 段)
    • 各 Goroutine 栈上的局部变量
    • 寄存器中的指针
  • 把 Roots 直接引用的对象从白色移到灰色集合

③ 遍历追踪(Tracing):

  • 从灰色集合取一个对象
  • 顺着该对象的指针,将其引用的所有白色对象置灰
  • 该对象自身置黑
  • 重复直到灰色集合为空

④ 清扫(Sweep):

  • 灰色集合为空时,所有可达对象全变为黑色
  • 仍为白色的对象 = 无人引用的垃圾,直接回收
  • 将所有黑色对象重置回白色,准备下一轮 GC

3. 并发 GC 的漏标问题#

3.1 问题根源#

Go 是并发 GC,意味着 GC 标记线程和用户代码(Mutator)同时运行。用户代码在标记期间可以修改对象间的引用关系,这可能导致存活对象被误杀。

3.2 漏标的两个必要条件#

在并发标记期间,同时满足以下两个条件,就会发生漏标:

条件描述
条件一一个白色对象被一个黑色对象引用了(黑色本不该指向白色)
条件二该白色对象同时失去了所有来自灰色对象的引用

3.3 漏标过程图解#

时刻 T1:正常状态
[黑色对象 A] ────指──→ [白色对象 C]
↑
[灰色对象 B] ──────指─────┘
时刻 T2:用户代码修改引用(同时满足两个条件)
[黑色对象 A] ────指──→ [白色对象 C] ← 条件一:黑色指向了白色
[灰色对象 B] (删除了指向 C 的指针) ← 条件二:丢失了灰色引用
时刻 T3:GC 继续标记
[黑色对象 A](已扫描过,不会再扫描)
↓
[白色对象 C] ← 彻底被"遗忘"!
结果:清扫阶段 → C 被回收 → 程序崩溃(A 还持有 C 的指针)

后果:白色对象 C 明明被黑色对象 A 需要,但因为 A 已经扫描过(不会重扫),而唯一的灰色上游 B 又断了联系,导致 C 永远无法变灰,最终被误杀。

3.4 三色不变性(Tri-color Invariant)#

为保护 GC 正确性,需要维持以下两种不变性之一:

不变性规则破坏方式
强三色不变性黑色对象永远不能指向白色对象允许黑色指向灰色,但禁止指向白色
弱三色不变性黑色对象可以指向白色对象,但必须存在一条灰色对象到该白色对象的路径切断灰色通路 + 黑色指向白色

4. 混合写屏障:解决并发漏标#

Go 1.8 引入的混合写屏障(Hybrid Write Barrier) 结合了插入写屏障和删除写屏障的优点,是 Go GC 走向亚毫秒 STW 的关键里程碑。

4.1 核心设计原则#

混合写屏障在用户代码修改指针时,拦截并执行两条硬性操作:

写屏障拦截:*slot = ptr
操作 1(删除写屏障思路)→ shade(oldPtr):被删除的下游对象,标记为灰色
操作 2(插入写屏障思路)→ shade(ptr):被添加的下游对象,标记为灰色

此外,Go 团队做出一个关键决策:栈上新创建的对象,一律直接标记为黑色。

4.2 四个核心行为#

行为场景描述
堆对象断开引用heapObj.slot = nil写屏障将被断开的旧对象染灰
堆对象建立新引用heapObj.slot = obj写屏障将新引用的对象染灰
栈上新建对象subObj := &Struct{}直接标记为黑色
GC 开始时栈扫描标记准备阶段一次性将栈上所有可达对象染黑

4.3 屏障如何破坏漏标条件#

回顾漏标的两个必要条件,混合写屏障通过以下方式破坏它们:

条件一(黑色指向白色):
→ 插入写屏障:当黑色对象指向白色时,立即将白色染灰 → 条件一被破坏
条件二(灰色引用被删除):
→ 删除写屏障:当灰色对象的引用被删除时,将被删除的对象染灰 → 条件二被破坏

两者同时生效,无论哪个条件发生,白色对象都会被保护起来。


5. 为什么栈上不开启写屏障?#

这是混合写屏障最核心的突破——栈上代码完全不需要写屏障,极大提升性能,同时避免了标记结束时的第二次 STW 重扫栈。

5.1 历史背景#

在 Go 1.7 及之前,早期的插入写屏障只在堆上开启,栈上不开启。原因是:

  • 栈操作极其频繁(每秒百万次)
  • 如果给栈也加写屏障,性能损耗无法接受

但这也导致一个问题:标记结束时,GC 不敢保证栈上没有悄悄指向白色对象的引用,必须 STW 暂停全局,重新扫描所有 Goroutine 的栈。当 Goroutine 数量很多时,这个停顿可能长达数百毫秒。

5.2 混合写屏障的解决方案#

混合写屏障通过三个机制消除栈上重扫:

机制 1:开局全变黑
GC 开始时,一次性将当前所有 Goroutine 栈上的可达对象全部染成黑色
机制 2:新生命即黑色
GC 期间,Goroutine 内部新创建的对象(如 subObj := &Struct{})直接标记为黑色
机制 3:堆上严防死守
所有堆上指针修改操作由写屏障拦截:
- 断开引用 → 旧对象染灰
- 建立引用 → 新对象染灰

5.3 安全性论证#

白色对象能否在不惊动 GC 的情况下隐藏到黑色栈对象背后?
栈上对象:
- 要么是 GC 开始时已扫描完的黑色对象(没有白色子对象)
- 要么是 GC 期间新创建的黑色对象(新对象无历史引用)
堆上操作:
- 任何堆指针的修改都被写屏障拦截
- 被丢弃的旧引用 → 染灰(防止残留的白对象失去灰色上游)
- 新加入的引用 → 染灰(防止被黑色对象直接指向)
结论:白色对象不可能"潜入"黑色栈对象的引用链中,因为所有通往堆的入口都被写屏障监控。

6. 伪代码实现#

6.1 核心屏障逻辑#

在编译阶段,Go 编译器会在所有堆指针修改处自动插入以下写屏障逻辑:

// src/runtime/mbarrier.go(简化表示)
// 假设用户代码要执行:*slot = ptr
// 编译器自动插入写屏障调用
func writeBarrier(slot *unsafe.Pointer, ptr unsafe.Pointer) {
// 1. 获取当前 slot 中存储的旧指针值
oldPtr := *slot
// 2. 仅在 GC 标记阶段启用
if gcphase == _GCmark {
// shade(oldPtr): 将被覆盖的旧指针指向的对象染灰(删除写屏障思想)
if oldPtr != nil {
shade(oldPtr)
}
// shade(ptr): 将新指针指向的对象染灰(插入写屏障思想)
if ptr != nil {
shade(ptr)
}
}
// 3. 执行真正的内存写入
*slot = ptr
}

6.2 shade 函数#

// src/runtime/mgcmark.go(逻辑示意)
// shade 将一个对象从白色或黑色标记为灰色
// 如果对象已经是灰色或黑色,则跳过
func shade(obj uintptr) {
if obj == 0 {
return
}
// 获取对象的 mark bits
mbits := getMarkBits(obj)
// 如果已经是黑色或灰色,跳过
if mbits.isMarked() {
return
}
// 标记为灰色并放入工作队列
mbits.setMarked()
gcw.put(obj) // 放入灰色工作队列,等待后续扫描
}

6.3 栈上对象创建#

// 栈上新创建对象的处理(编译期行为)
// 用户代码:subObj := &Struct{Field: value}
//
// 编译器生成代码(逻辑示意):
// subObj = stackalloc(sizeof(Struct))
// subObj.Field = value
// gcMarkBlack(subObj) ← 直接标记为黑色,无屏障开销

6.4 完整生命周期#

┌─────────────────────────────────────────────────────────────────────┐
│ GC 标记阶段完整流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. STW 准备阶段(极短) │
│ ├── 开启写屏障 (gcphase = _GCmark) │
│ ├── 统计 GC Roots │
│ └── 扫描所有 Goroutine 栈 → 栈上对象全部染黑 │
│ │
│ 2. 并发标记阶段(用户代码继续运行) │
│ ├── GC Worker 从灰色队列取对象,扫描其指针 │
│ ├── 用户的堆指针操作被写屏障拦截 │
│ │ ├── *slot = ptr → shade(oldPtr) + shade(ptr) │
│ │ └── 栈上新建对象 → 直接染黑 │
│ └── 灰色队列为空时标记结束 │
│ │
│ 3. STW 标记终止(极短) │
│ ├── 关闭写屏障 │
│ └── 检查灰色队列(确保完全为空) │
│ │
│ 4. 并发清扫(无需 STW) │
│ ├── 白色对象 → 回收 │
│ └── 黑色对象 → 重置为白色(为下一轮 GC 准备) │
│ │
└─────────────────────────────────────────────────────────────────────┘

7. 总结#

7.1 GC 算法演进#

三色标记法(基础)
│ 问题:并发环境下的漏标
↓
插入写屏障(Go 早期)
│ 问题:堆上启效,栈不启效
│ 代价:标记结束需 STW 重扫栈(数百 ms)
↓
混合写屏障(Go 1.8+)
│ 核心:shade(被删旧引用) + shade(新加引用)
│ 关键优化:栈上对象直接黑色
│ 效果:标记结束无需 STW 重扫栈
│ 结果:STW 迈入亚毫秒级
↓
持续优化(Go 1.19+ GOMEMLIMIT 等)

7.2 关键设计原则#

原则说明
三色不变性黑色对象不能指向白色对象(强三色);或灰色路径保护(弱三色)
双重保险插入屏障 + 删除屏障同时作用,任一条件被触发即保护
栈黑堆灰栈上对象直接黑色,堆上指针操作由屏障兜底
零栈屏障栈上代码无需写屏障,性能零损耗
避开重扫标记结束后直接并发清扫,无需第二次 STW

7.3 混合写屏障操作速查#

操作触发条件屏障动作
*slot = ptr(堆)GC 标记阶段 + oldPtr ≠ nilshade(oldPtr)
*slot = ptr(堆)GC 标记阶段 + ptr ≠ nilshade(ptr)
new(T)(栈)GC 标记阶段直接染黑
GC 开始时栈扫描栈上所有可达对象直接染黑

7.4 核心源码文件#

文件内容
src/runtime/mgc.goGC 主流程、阶段控制
src/runtime/mgcmark.go标记逻辑、shade 实现
src/runtime/mbarrier.go写屏障实现
src/runtime/mgcsweep.go清扫逻辑
src/runtime/mheap.go堆管理与 mark bits
src/runtime/mbitmap.go对象标记位图操作

7.5 进一步阅读#


本文档基于 Go 1.8+ 源码编写。混合写屏障在 Go 1.8(2017 年 2 月)引入,是 Go GC 历史上最重要的性能里程碑之一。

Go GC 三色标记法与混合写屏障
https://shirone.mysqil.com/posts/go/go-gc-三色标记法与混合写屏障/
作者
HikariTish
发布于
2026-06-04
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取

最后更新于 ,距今已过 133 天

部分内容可能已过时