Go 的垃圾回收是怎样的?三色标记和写屏障是什么?

深入原理性能优化约 9 分钟读完

一句话回答

Go 的 GC 是并发的三色标记清除:不分代、不移动对象,标记工作大部分和用户代码同时进行,只在阶段切换时有很短的 STW(暂停所有 goroutine)。三色标记用白、灰、黑区分"还没访问到""已发现但没扫描完""已经扫描完"的对象,标记结束时仍是白色的就是垃圾。标记期间用户代码还在修改指针,可能导致存活对象被漏标、误回收,所以要用写屏障在写指针时把相关对象标灰;Go 1.8 起使用混合写屏障,不再需要 STW 重新扫描栈。调优主要靠 GOGC 和 Go 1.19 引入的 GOMEMLIMIT,以及借助逃逸分析、对象复用减少堆分配。

详细解析

整体特点

  • 标记清除,不移动对象:先标记所有可达对象,再回收没有标记的对象,不做整理。内存碎片主要靠按大小分级分配内存的分配器(思路类似 tcmalloc)来缓解
  • 不分代:没有新生代、老年代之分。分代回收需要一直记录老对象指向新对象的引用,有额外开销;而 Go 的逃逸分析让很多短命对象直接分配在栈上,函数返回就释放,分代的收益没有 Java、JS 那么大
  • 并发:标记和清扫与用户代码同时进行。后台标记大约占用四分之一的 CPU;某个 goroutine 分配内存太快时,会被要求先帮忙做一部分标记工作(mark assist),避免标记速度追不上分配速度

对比 V8:V8 是分代的,新生代用复制算法,老生代会做标记整理,对象会被移动,见 JS 的垃圾回收。

三色标记

  1. 开始时所有对象都是白色
  2. 从根出发(全局变量、每个 goroutine 栈上的变量、寄存器),把它们直接引用的对象标成灰色
  3. 取出一个灰色对象,扫描它包含的指针,把引用到的白色对象标灰,然后把它自己标黑
  4. 重复第 3 步,直到没有灰色对象。这时黑色是存活对象,白色是垃圾,进入清扫阶段

灰色对象相当于"待处理队列",标记过程可以随时暂停、继续,这是标记能和用户代码并发执行的基础。

为什么需要写屏障

标记和用户代码同时运行,用户代码随时可能修改指针:

文本
标记进行到一半:A 已扫描完(黑),B 等待扫描(灰),C 还没访问到(白)
    A(黑)        B(灰) ──> C(白)

用户代码执行 A.ref = C,然后 B.ref = nil
    A(黑) ──> C(白)        B(灰)

A 已经扫描过,不会再扫;B 也不再指向 C。C 一直是白色,会被当成垃圾回收,
但 A 还在用它:这就是漏标,后果是程序访问到已经被回收的内存

漏标要同时满足两个条件:黑色对象指向了白色对象,并且从灰色对象出发能到达这个白色对象的路径都断了。写屏障是编译器在写指针的地方插入的一小段代码,只在 GC 标记期间生效,用来破坏其中一个条件:

  • 插入写屏障(Dijkstra):写入指针时把新指向的对象标灰,黑色对象就不会直接指向白色对象
  • 删除写屏障(Yuasa):覆盖或删除指针时把原来指向的对象标灰,被断开的对象不会悄悄丢失

栈上的指针读写非常频繁,为了性能,Go 不对栈上的写入加写屏障。Go 1.5 到 1.7 只用插入写屏障,栈上的修改没有保护,标记结束时必须 STW 重新扫描所有 goroutine 的栈,goroutine 很多时停顿明显。

Go 1.8 起改用混合写屏障:写堆上的指针时,把旧对象和新对象都标灰;每个 goroutine 的栈只在标记开始后扫描一次,扫描时只暂停这一个 goroutine;标记期间新分配的对象直接标黑。这样标记结束时不再需要重新扫描栈,STW 时间大幅缩短。

GC 的阶段

  1. 清扫终止(STW):完成上一轮遗留的清扫,开启写屏障
  2. 并发标记:扫描根和堆,用户代码照常运行
  3. 标记终止(STW):完成标记的收尾工作,关闭写屏障
  4. 并发清扫:在后台和分配内存时,逐步回收白色对象占用的空间

Go 1.25 以实验特性引入、Go 1.26 起默认启用的 Green Tea 优化了标记阶段扫描小对象的方式(以内存页为单位批量扫描,对 CPU 缓存更友好),但并发三色标记、写屏障、不分代不移动的整体框架没有变。

调优:GOGC 和 GOMEMLIMIT

  • GOGC(默认 100):上次 GC 后新分配的堆内存达到存活堆大小的 GOGC% 时,触发下一次 GC。假设上次 GC 后存活 100MB,GOGC=100 时堆涨到约 200MB 触发下一次。调大则 GC 次数少、CPU 开销低、内存占用高,调小相反;GOGC=off 关闭 GC。Go 1.18 起计算目标时还会把栈和全局变量考虑进去
  • GOMEMLIMIT(Go 1.19 引入):软内存上限。Go 运行时管理的内存总量接近上限时,即使没到 GOGC 的目标也会触发 GC。在容器中把它设成略低于容器内存限制的值,可以减少 OOM。存活对象本身就超过上限时,运行时会限制 GC 占用的 CPU 比例,宁可超出上限,也不会陷入不停 GC 的死循环
  • 两者配合:GOGC=off 加上 GOMEMLIMIT 表示"内存没到上限就不 GC",适合独占资源、追求吞吐的服务;但存活堆接近上限时 GC 会非常频繁
  • 代码中也可以设置:debug.SetGCPercent(200)、debug.SetMemoryLimit(4 << 30)

逃逸分析

编译器会分析每个变量的生命周期:只在函数内部使用的分配在栈上,函数返回就自动释放,不给 GC 增加负担;函数返回后还可能被引用的,"逃逸"到堆上,由 GC 管理。

Go
type User struct{ Name string }

func newUser(name string) *User {
	u := User{Name: name}
	return &u // 返回了局部变量的地址,u 必须分配在堆上
}

用 go build -gcflags=-m 查看,输出里会有 moved to heap: u,-gcflags='-m -m' 会给出更详细的原因。常见的逃逸原因:

  • 返回局部变量的指针,或者把它存进全局变量、堆上的对象
  • 被闭包捕获,而闭包的生命周期超出了当前函数
  • 赋值给接口后,编译器无法确定它会被怎么使用,比如作为参数传给 fmt.Println
  • 对象太大,或者大小要到运行时才能确定。Go 1.25 起,不逃逸的变长 make 会先尝试使用栈上一小块固定大小的缓冲区,放不下才分配到堆上

减少 GC 压力

  • 预分配:make([]T, 0, n)、make(map[K]V, n),避免反复扩容产生垃圾
  • 复用对象:用 sync.Pool 缓存临时对象,比如序列化时用的 buffer
  • 减少指针:不含指针的对象(如 []int、[]byte,或只有数值字段的结构体)GC 不需要扫描其内部;大量小对象可以改成值类型的切片,减少对象和指针的数量
  • 少做无谓的转换:避免在热点路径上反复做 []byte 和 string 的转换,拼接字符串用 strings.Builder
  • 先测量再优化:GODEBUG=gctrace=1 会在每次 GC 时打印一行统计,pprof 的 heap、allocs 能找到分配热点
Go
var bufPool = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

func greet(name string) string {
	buf := bufPool.Get().(*bytes.Buffer)
	buf.Reset() // 取出的对象可能带着上次的数据,用之前先重置
	defer bufPool.Put(buf)
	buf.WriteString("hello, ")
	buf.WriteString(name)
	return buf.String() // String 返回的是一份拷贝,buf 放回池里也不影响结果
}

面试官可能追问

GC 什么时候会触发?

主要有三种情况:堆大小达到 GOGC 算出的目标值(设置了 GOMEMLIMIT 时,接近内存上限也会触发);后台监控发现超过 2 分钟没有 GC,强制触发一次;代码中手动调用 runtime.GC(),一般只在测试中使用。

sync.Pool 里的对象什么时候会被清理?

池里的对象随时可能被 GC 清掉。Go 1.13 起,每次 GC 时池里的对象先移到一个"受害者缓存",到下一次 GC 时还没被取走才真正释放,避免每次 GC 后池子一下子清空、所有请求同时重新分配。所以 sync.Pool 只适合缓存丢了也没关系的临时对象,不能当连接池或者数据缓存用。

为什么 GC 之后,进程占用的内存(RSS)没有降下来?

回收的内存会先留在 Go 运行时里,供之后的分配复用,再由后台的 scavenger 逐步归还给操作系统,所以 RSS 下降有延迟。如果 RSS 一直不降、存活堆也一直在涨,就要怀疑是真的泄漏了,比如全局缓存只增不减、大数组被小切片引用着,或者 goroutine 泄漏。debug.FreeOSMemory() 可以强制归还内存,一般用不上。

易错点

  • Go 的 GC 不分代、不整理,不要套用 JVM 新生代、老年代那一套说法
  • 写屏障只在标记阶段生效,其他时候写指针只多一次标志判断
  • GOMEMLIMIT 是软限制,存活数据本身超过上限时,内存照样会继续增长,它防不住真正的内存泄漏
  • 从 sync.Pool 取出的对象要先重置再用,放回池里之后就不要再使用它

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。