Go GC 垃圾回收机制

Posted on Nov 18, 2023

Go GC 目标是低延迟:STW 控制在毫秒级内。


演进历史

版本算法STW 时间
1.3 之前标记-清扫(STW)几百毫秒到秒级
1.5三色标记 + 并发清扫大幅缩短
1.8混合写屏障亚毫秒级

当前 Go runtime 的 GC 是并发三色标记-清扫,搭配混合写屏障

三色标记法

初始:所有对象都是白色
扫描根对象(全局变量、栈上的指针)→ 标记为灰色
遍历灰色对象,将其引用的白色对象标记为灰色
重复直到没有灰色对象
剩余的白色对象 → 垃圾,回收
颜色含义
⚪ 白色未扫描,可能是垃圾
🔘 灰色已扫描自身,但引用的对象还没扫完
⚫ 黑色已扫描自身及其引用,确定存活
步骤示意:

1. 初始
   A(灰)  B(白)  C(白)

2. 扫描 A 的引用
   A(黑)  B(灰)  C(白)

3. 扫描 B 的引用
   A(黑)  B(黑)  C(灰)

4. 扫描 C 的引用(C 无引用其他对象)
   A(黑)  B(黑)  C(黑)

5. 清扫白色对象 → 无,本轮无垃圾

写屏障

并发标记期间,mutator(用户代码)还在跑,可能产生新的引用。写屏障保证强/弱三色不变性

插入屏障(1.5-1.7):

新创建的对象直接标为灰色。栈上对象无法被写屏障保护,需要 STW 重新扫描栈。

混合写屏障(1.8+):

结合插入屏障和删除屏障,栈不用 STW。满足「黑色对象不能引用白色对象」。

// Go 编译期自动插入写屏障指令
// 伪代码示意
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(ptr)                          // 混合屏障:新引用标记灰色
    if currentStackIsGrey() {
        shade(*slot)                    // 某些情况下旧引用也标记
    }
    *slot = ptr                         // 实际写入
}

GC 触发时机

  1. 内存分配达到阈值 — GOGC 默认 100(堆增长 100% 触发)— 主要触发方式
  2. 定时触发 — 上次 GC 后 2 分钟还没有 GC 则强制触发
  3. 手动触发runtime.GC()
// 调试 GC
import "runtime"
import "runtime/debug"

debug.SetGCPercent(200)  // 堆增长 200% 才触发,降低 GC 频率
runtime.GC()              // 手动触发一次
debug.FreeOSMemory()      // 归还内存给 OS

GC 四个阶段

1. 标记准备(STW 短暂)     ← 启动写屏障
2. 并发标记(并发)          ← 主要耗时
3. 标记终止(STW 短暂)      ← 关闭写屏障,计算清扫工作
4. 并发清扫(并发)          ← 回收白色对象

每个阶段的 STW 通常在微秒到亚毫秒级别。

GC 调优

// 查看 GC 情况
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("GC 次数: %d\n", m.NumGC)
fmt.Printf("累计 STW 暂停: %d ns\n", m.PauseTotalNs)

// 在测试中追踪 GC
GODEBUG=gctrace=1 go run main.go
// 输出示例:gc 1 @0.012s 2%: 0.012+0.68+0.012 ms
//         gc 次数 时间     STW+并发标记+STW

调优方向:

  • 减少堆分配 — 用 sync.Pool 复用对象、避免不必要的 []bytestring
  • 调整 GOGC — 内存充足的场景调大,减少 GC 频率
  • 预分配容量make([]int, 0, 1000) 避免扩容导致频繁分配
  • 少用指针 — 指针多的结构体扫描成本高

总结

  • Go GC 是并发三色标记 + 混合写屏障
  • STW 只在标记准备和标记终止阶段,控制在微秒级
  • 写屏障保证并发标记期间的正确性
  • GOGC 控制 GC 触发频率
  • 通过 sync.Pool、预分配等手段减少 GC 压力