目录
Please enable Javascript to view the contents

Go GC 原理详解——三色标记与混合写屏障

 ·  ☕ 5 分钟

重要

Go 1.8+ GC 使用并发三色标记 + 混合写屏障,STW 降至微秒级。核心设计取舍:编译器逃逸分析将短生命周期对象留在栈上使分代回收收益小;TCMalloc 内存分配减少了标记-整理的碎片化需求。

1. 常见 GC 算法概览

算法思路优点缺点
引用计数对象头记录被引用次数,归零即回收实时回收,无 STW无法处理循环引用;每次赋值都要更新计数
标记-清除从根出发标记存活对象,清除未标记的无额外数据结构内存碎片;需要 STW 或写屏障保证并发正确性
标记-整理标记后移动存活对象,压缩空间无碎片移动开销大;需要更新所有指针
复制法存活对象复制到新区域,清空旧区域无碎片,分配极快内存利用率 50%
分代法按对象年龄分层,不同层用不同算法新生代回收快需要卡表维护跨代引用,额外写屏障

Go 不走引用计数(循环引用需额外处理),不走分代(逃逸分析削掉了新生代的量),不走复制/整理(TCMalloc 碎片低)。最终选择了并发标记-清除 + 混合写屏障——STW 最短、吞吐最高。

2. 三色标记法

三色标记将对象分为三类:

颜色含义
白色待标记对象,潜在的垃圾对象
灰色正在处理的根节点,待解析其引用
黑色已标记,确认存活的对象

标记过程:

  1. 初始状态所有对象 标记为 白色
  2. 根搜索:所有 根对象 标记为 灰色
根对象说明

根对象 = 初始节点,包括:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量
  2. 执行栈上的对象或指针:每个 goroutine 都包含自己的执行栈,这些栈上的对象包含栈上的变量及指向分配的堆内存区块的指针
  3. 寄存器中的变量:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块
3. 遍历标记:取 灰色 对象 → 将其 **引用的对象** 置 → 再将 **自身** 置 (自身没有应用的对象,就只将自身置黑) 4. 重复步骤 3,直到 灰色队列 为空 5. 清扫:回收所有 白色 对象
标记完成的标志

第3步,不论是否有引用,都会将自身(灰)。因此,灰色队列最终会变空,这也是标记完成的标志。

2.1 三色不变性

GC 出现错误回收必须同时满足两个条件:

  1. 存在 黑色对象白色对象 的引用(插入白色对象被挂在黑色对象下)
  2. 不能从任何 灰色对象 追踪到该 白色对象删除灰色对象白色对象 的引用遭到破坏)
黑色对象不会再被扫描,白色对象失去了被标记的机会 → 被错误回收。

两种屏障分别破坏其中一个条件:

屏障类型破坏条件策略
强三色不变式(插入写屏障)条件一不允许存在 黑色对象白色对象 的引用
弱三色不变式(删除写屏障)条件二保证存在 灰色对象白色对象 的引用

3. 写屏障

3.1 插入写屏障(强三色不变式)

1
规则: 插入时,将白色强制变

破坏条件一,不允许存在黑色对象白色对象的直接引用。

3.2 删除写屏障(弱三色不变式)

1
规则: 引用被删除时,将白色对象置

破坏条件二,保证始终存在灰色对象白色对象的引用路径。

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

1
2
3
4
5
6
7
┌─────┬─────────────┬─────────────┬─────────────┐
│ STW │   并发标记   │     STW     │  并发清扫     │
│ 开始 │ (应用+GC)   │     终止     │  (仅应用)    │
└─────┴─────────────┴─────────────┴─────────────┘
  ↑                 ↑               ↑
启用屏障           重新扫描栈       禁用屏障
扫描初始根                        完成标记
  • 第一次 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+ 的混合写屏障换了一种思路——不是"给栈加上屏障",而是"让栈不再需要屏障"

1
2
3
4
混合写屏障 = GC 开始前将栈上对象全部置黑
            + GC 期间栈上新对象也置黑
            + 堆上插入屏障
            + 堆上删除屏障

栈上对象已全部置黑 → 黑色对象不会重扫 → 不存在黑→白误标问题 → 不需要栈屏障。

混合写屏障不是「在栈上运行得更好的屏障」,而是 「通过预置黑消除栈在标记阶段对屏障的需求」。栈上的批量置黑是轻量操作(仅在 GC 开始时执行一次),远比每条栈写都触发屏障便宜。

5. 为什么不采用分代法和标记-整理法

方案不采用原因
分代法编译器静态逃逸分析将短生命周期对象分配在栈上,堆上新生代对象不多,分代收益小;且需要额外写屏障维护跨代引用
标记-整理/复制法运行时内存分配基于 TCMalloc,碎片化程度低;Thread Cache 规避了锁竞争

Go 的 GC 设计哲学:用编译期分析(逃逸 + 栈分配)减少 GC 压力,用并发标记减少停顿,用写屏障保证正确性。

6. 总结

  1. Go GC 从全 STW 演进到微秒级并发的关键是三色标记 + 混合写屏障;
  2. 插入屏障保证强三色(无),删除屏障保证弱三色(有路径);
  3. 混合写屏障的核心突破:预置黑消除栈屏障需求,而非在栈上加屏障;
  4. 逃逸分析 + TCMalloc 是 GC 设计取舍的基础——不需要分代法、不需要标记-整理。

7. 参考

分享

Hex
作者
Hex
CloudNative Developer