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. 核心概念:三色标记模型
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 ≠ nil | shade(oldPtr) |
*slot = ptr(堆) | GC 标记阶段 + ptr ≠ nil | shade(ptr) |
new(T)(栈) | GC 标记阶段 | 直接染黑 |
| GC 开始时 | 栈扫描 | 栈上所有可达对象直接染黑 |
7.4 核心源码文件
| 文件 | 内容 |
|---|---|
src/runtime/mgc.go | GC 主流程、阶段控制 |
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 历史上最重要的性能里程碑之一。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 133 天
部分内容可能已过时