重要
Go 1.8+ GC 使用并发三色标记 + 混合写屏障,STW 降至微秒级。核心设计取舍:编译器逃逸分析将短生命周期对象留在栈上使分代回收收益小;TCMalloc 内存分配减少了标记-整理的碎片化需求。
1. 常见 GC 算法概览
| 算法 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 引用计数 | 对象头记录被引用次数,归零即回收 | 实时回收,无 STW | 无法处理循环引用;每次赋值都要更新计数 |
| 标记-清除 | 从根出发标记存活对象,清除未标记的 | 无额外数据结构 | 内存碎片;需要 STW 或写屏障保证并发正确性 |
| 标记-整理 | 标记后移动存活对象,压缩空间 | 无碎片 | 移动开销大;需要更新所有指针 |
| 复制法 | 存活对象复制到新区域,清空旧区域 | 无碎片,分配极快 | 内存利用率 50% |
| 分代法 | 按对象年龄分层,不同层用不同算法 | 新生代回收快 | 需要卡表维护跨代引用,额外写屏障 |
Go 不走引用计数(循环引用需额外处理),不走分代(逃逸分析削掉了新生代的量),不走复制/整理(TCMalloc 碎片低)。最终选择了并发标记-清除 + 混合写屏障——STW 最短、吞吐最高。
2. 三色标记法
三色标记将对象分为三类:
| 颜色 | 含义 |
|---|---|
| 白色 | 待标记对象,潜在的垃圾对象 |
| 灰色 | 正在处理的根节点,待解析其引用 |
| 黑色 | 已标记,确认存活的对象 |
标记过程:
- 初始状态:所有对象 标记为 白色
- 根搜索:所有 根对象 标记为 灰色
根对象说明
根对象 = 初始节点,包括:
- 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量
- 执行栈上的对象或指针:每个 goroutine 都包含自己的执行栈,这些栈上的对象包含栈上的变量及指向分配的堆内存区块的指针
- 寄存器中的变量:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块
标记完成的标志
第3步,不论是否有引用,都会将自身(灰) 置 黑。因此,灰色队列最终会变空,这也是标记完成的标志。
2.1 三色不变性
GC 出现错误回收必须同时满足两个条件:
- 存在 黑色对象 对 白色对象 的引用(插入:白色对象被挂在黑色对象下)
- 不能从任何 灰色对象 追踪到该 白色对象(删除:灰色对象 对 白色对象 的引用遭到破坏)
黑色对象不会再被扫描,白色对象失去了被标记的机会 → 被错误回收。
两种屏障分别破坏其中一个条件:
| 屏障类型 | 破坏条件 | 策略 |
|---|---|---|
| 强三色不变式(插入写屏障) | 条件一 | 不允许存在 黑色对象 对 白色对象 的引用 |
| 弱三色不变式(删除写屏障) | 条件二 | 保证存在 灰色对象 对 白色对象 的引用 |
3. 写屏障
3.1 插入写屏障(强三色不变式)
| |
破坏条件一,不允许存在黑色对象对白色对象的直接引用。
3.2 删除写屏障(弱三色不变式)
| |
破坏条件二,保证始终存在灰色对象对白色对象的引用路径。
3.3 混合写屏障(1.8+)
结合两种屏障,消除栈重扫描:
| 机制 | 来源 |
|---|---|
| 栈上对象全部置 黑 | 消除栈扫描需求 |
| GC 期间栈上新对象也置 黑 | 防止新对象漏标 |
| 堆上插入屏障(新引用 → 对象置 灰) | 插入屏障 |
| 堆上删除屏障(被删除引用 → 对象置 灰) | 删除屏障 |
4. 演进:从全 STW 到混合屏障
4.1 版本演进
| 版本 | 算法 | STW 时长 |
|---|---|---|
| Go 1.4 及以前 | 标记-清除(全程 STW) | 100ms+ |
| Go 1.5 ~ 1.7 | 三色并发标记 + 插入写屏障 + 栈重扫描 | 1-3ms(两次 STW) |
| Go 1.8+ | 三色并发标记 + 混合写屏障 | <100μs(一次 STW) |
4.2 1.5-1.7 为什么有两次 STW
| |
- 第一次 STW(准备阶段):确保根对象在一致状态下被扫描,100-500μs
- 第二次 STW(终止阶段):栈上没有写屏障,必须重新扫描所有栈来捕获变化,1-3ms
4.3 为什么插入屏障不能加在栈上
1.5-1.7 版本只在堆上启用插入写屏障,栈上没有。原因有三个层面:
| 层面 | 插入屏障在栈上的问题 |
|---|---|
| 性能 | 栈写入频率远高于堆——每个函数调用、局部变量赋值、参数传递都是栈写。每条栈写入都触发屏障检查,开销不可接受 |
| 强度 | 插入屏障是 强三色不变式——只要发生黑→白写入就得把白变灰。栈上对象频繁更新,屏障触发次数极高 |
| 硬件 | 1.5-1.7 时代的 CPU 无法承担栈屏障的性能损耗,只能折中用 STW 重扫替代 |
于是 1.5-1.7 选择了折中方案:堆上插入屏障 + 标记结束 STW 重扫所有栈。这样用一次 1-3ms 的 STW 换取了堆并发的安全性。
4.4 1.8+ 引入的原因
| 因素 | 1.5-1.7 时代 | 1.8+ |
|---|---|---|
| 硬件 | CPU 无法承担栈屏障开销 | 可承担更高屏障开销 |
| 场景 | 大量 Goroutine → 栈重扫时间长 | 微服务普及 → 对 ms 级暂停敏感 |
| 算法 | 插入屏障(强三色)成本高 | 混合屏障 = 栈全黑(零屏障)+ 堆弱三色 |
4.5 为什么混合屏障能在栈上工作
1.8+ 的混合写屏障换了一种思路——不是"给栈加上屏障",而是"让栈不再需要屏障":
| |
栈上对象已全部置黑 → 黑色对象不会重扫 → 不存在黑→白误标问题 → 不需要栈屏障。
混合写屏障不是「在栈上运行得更好的屏障」,而是 「通过预置黑消除栈在标记阶段对屏障的需求」。栈上的批量置黑是轻量操作(仅在 GC 开始时执行一次),远比每条栈写都触发屏障便宜。
5. 为什么不采用分代法和标记-整理法
| 方案 | 不采用原因 |
|---|---|
| 分代法 | 编译器静态逃逸分析将短生命周期对象分配在栈上,堆上新生代对象不多,分代收益小;且需要额外写屏障维护跨代引用 |
| 标记-整理/复制法 | 运行时内存分配基于 TCMalloc,碎片化程度低;Thread Cache 规避了锁竞争 |
Go 的 GC 设计哲学:用编译期分析(逃逸 + 栈分配)减少 GC 压力,用并发标记减少停顿,用写屏障保证正确性。
6. 总结
- Go GC 从全 STW 演进到微秒级并发的关键是三色标记 + 混合写屏障;
- 插入屏障保证强三色(无黑→白),删除屏障保证弱三色(有灰→白路径);
- 混合写屏障的核心突破:预置黑消除栈屏障需求,而非在栈上加屏障;
- 逃逸分析 + TCMalloc 是 GC 设计取舍的基础——不需要分代法、不需要标记-整理。