专栏文章
专栏文章
GC 垃圾回收系列
1. GC 系列 #01:GC 总览与历史:自动内存管理的起源与演化 2. GC 系列 #02:GC 基础算法:标记-清除、标记-整理、复制收集、引用计数 3. GC 系列 #03:分代假说与分代收集:朝生夕死的工程哲学 4. GC 系列 #04:CMS 收集器:Java 第一个并发收集器的设计与缺陷 5. GC 系列 #05:G1 收集器:Region 化堆与可预测停顿 6. GC 系列 #06:ZGC 与 Shenandoah:亚毫秒级停顿的并发移动收集器 7. GC 系列 #07:Go 的 GC:并发三色标记与混合写屏障 8. GC 系列 #08:Python 的内存管理:引用计数与分代循环 GC 9. GC 系列 #09:GC 调优实践:JVM 日志解读与 G1/ZGC 参数策略

GC 系列 #07:Go 的 GC:并发三色标记与混合写屏障

发布于 2026-05-25 14:16 👁 15 次阅读
#GC#内存管理#理论#Go

Go 的 GC 设计体现了一种鲜明的工程哲学:用并发三色标记换取极低停顿,不用分代,混合写屏障保证并发正确性。本文梳理 Go GC 的演进历史、核心机制,以及与 JVM GC 的设计差异。


目录

章节 说明
Go GC 的设计哲学 为什么不用分代
三色标记算法 并发标记的理论基础
混合写屏障 Go 1.14 的关键改进
GC 触发机制 GOGC 与触发时机
Go GC 演进历史 从秒级停顿到亚毫秒
Go vs JVM GC 对比 设计差异与取舍

Go GC 的设计哲学

为什么 Go 不用分代

分代假说假设"大多数对象年轻时死亡",但 Go 的典型工作负载与 Java 不同:

// Go 的对象分配模式:
// 1. 大量小对象(goroutine 栈上)→ 栈上分配,GC 完全不管
// 2. 逃逸到堆的对象生命周期多样,分代收益不明显
// 3. Go 的值语义(value semantics)减少了堆分配

// 栈上分配(GC 不管)vs 堆上分配(GC 管)
func stackAllocated() {
    x := 42   // 在栈上,goroutine 退出时自动回收
    _ = x
}

func heapAllocated() *int {
    x := 42
    return &x  // x 逃逸到堆(escape analysis)
}

Go 通过逃逸分析让大量对象留在栈上,减少 GC 压力,这是 Go 不需要激进分代策略的重要原因。

核心目标:低延迟

Go 的主要应用场景(云原生服务、微服务)对延迟极其敏感,停顿 < 1ms 是核心约束,吞吐量是次要考量。


三色标记算法

Go 使用并发三色标记-清除算法:

三种颜色的含义

颜色 含义
白色(White) 未被访问,GC 结束时仍为白色 = 垃圾
灰色(Gray) 已被访问,但其引用的对象还未全部访问
黑色(Black) 已被访问,且所有直接引用的对象也已被访问(存活)

标记过程:

  1. GC 开始:所有对象白色,GC Roots 变灰
  2. 处理灰色对象:将其引用的白色对象变灰,自身变黑
  3. 标记结束:无灰色对象;黑色 = 存活,白色 = 垃圾

并发标记的问题

应用线程可能在并发标记时破坏三色不变式:

情景(对象被错误回收):

  1. A(黑)新增了对 C(白)的引用
  2. B(灰)删除了对 C(白)的引用

结果: GC 看到 C 只被已处理的黑色对象 A 引用,不会再扫描 A,C 没有灰色对象引用它,于是 C 被当作垃圾回收 → 悬空指针!

需要屏障(Barrier) 来维护三色不变式。


混合写屏障

写屏障的演进

Go 版本 屏障类型 问题
1.5 之前 无并发 GC STW 秒级
1.5 插入写屏障(Dijkstra) 需要 STW 重新扫描栈
1.8 混合写屏障 解决栈扫描问题

Go 1.8 混合写屏障(Hybrid Write Barrier)

结合 Dijkstra 插入屏障Yuasa 删除屏障

// 混合写屏障伪代码
func writeBarrier(ptr *unsafe.Pointer, new unsafe.Pointer) {
    old := *ptr

    // Yuasa 删除屏障:将旧值标灰(保护被删除的引用)
    shade(old)

    // Dijkstra 插入屏障:将新值标灰(保护新增的引用)
    shade(new)

    *ptr = new
}

关键效果: 堆上的引用修改都被屏障覆盖,栈上的修改不需要屏障(通过 GC 开始时的栈扫描处理)。

这使得重新标记阶段只需要 STW 一次扫描所有 goroutine 的栈(通常 < 0.5ms),而不是扫描整个堆。


GC 触发机制

GOGC 环境变量

GOGC=100   # 默认值,含义:当堆大小增长到上次 GC 后的 2 倍时触发
GOGC=200   # 堆大小增长到 3 倍时触发(GC 频率降低,内存占用更高)
GOGC=50    # 堆大小增长到 1.5 倍时触发(GC 更频繁,内存占用更低)
GOGC=off   # 禁用 GC(慎用)
// 程序中动态调整
import "runtime"
runtime.SetGCPercent(100)   // 等同于 GOGC=100

触发条件

  1. 堆大小触发:堆分配量超过上次 GC 后的 (1 + GOGC/100)
  2. 时间触发:2 分钟内没有发生 GC(避免长期空闲程序内存不回收)
  3. 手动触发runtime.GC()(显式调用,测试或特殊场景)
  4. 内存不足:申请内存时发现不足

Go 1.19 的 GOMEMLIMIT

GOMEMLIMIT=500MiB   # 软内存上限

当程序接近内存上限时,Go 运行时会自动更积极地触发 GC,防止 OOM。这解决了 GOGC 只能控制比例、不能控制绝对值的问题。


Go GC 演进历史

Go 1.0  (2012)  全 STW,停顿几百毫秒~数秒
Go 1.4  (2014)  部分并发化,停顿 50~100ms
Go 1.5  (2015)  ★ 并发三色标记,停顿降至 10ms 以下
                 插入写屏障(Dijkstra),但重新标记需要扫描栈 STW
Go 1.6  (2016)  停顿降至 < 5ms
Go 1.7  (2016)  停顿降至 < 1ms
Go 1.8  (2017)  ★ 混合写屏障,消除了栈重扫的 STW
                 停顿降至 < 0.5ms
Go 1.14 (2020)  异步抢占,GC 可以抢占运行中的 goroutine
                 停顿更稳定(之前长循环 goroutine 会延迟 GC)
Go 1.18 (2022)  GC CPU 利用率改进
Go 1.21 (2023)  引入弱引用支持(finalizer 改进)

Go vs JVM GC 对比

维度 Go GC JVM G1/ZGC
分代 有(核心设计)
停顿时间 < 1ms(稳定) G1: 50~200ms;ZGC: < 1ms
内存开销 低(无分代额外开销) 中(分代结构、卡表等)
吞吐量 略低(并发 GC 开销) G1 高;ZGC 略低
屏障类型 写屏障(堆)+ 不需要屏障(栈) 写屏障/读屏障(取决于 GC)
压缩整理 无(不移动对象,但有 tcmalloc 分配器减少碎片) G1/ZGC 并发移动整理
调优复杂度 低(主要靠 GOGC/GOMEMLIMIT) 高(数十个 JVM 参数)

参考资料

  • Go 官方博客:"Getting to Go: The Journey of Go's GC"(Rick Hudson)
  • Go GC 设计文档:golang.org/s/go15gcdesign
  • Rhys Hiltner:"An Introduction to go tool trace"
  • ZGC 与 Shenandoah
  • Python 的内存管理
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论