"The best GC is the one you don't notice." ------ Go GC 的演进史,就是一部与 STW (Stop The World) 的抗争史。
在 C/C++ 中,程序员拥有对内存的绝对控制权,但也背负了手动管理的沉重枷锁。Go 选择了一条不同的路:自动化垃圾回收(Garbage Collection)。
提到 GC,很多人的第一反应是:"会卡顿"、"STW(世界暂停)"。 确实,在 Go 1.0 时代,GC 极其粗糙,STW 时间甚至高达几百毫秒,简直是线上服务的噩梦。但到了 Go 1.8 之后,STW 通常被控制在 0.5ms 以下。
它是怎么做到的? 从简单的标记-清除 ,到复杂的三色标记 ,再到精妙的混合写屏障。
1. 为什么需要"三色"?
最原始的垃圾回收算法是 标记-清除 (Mark and Sweep)。
-
暂停整个程序 (STW):如果不暂停,程序一边运行一边改引用,GC 根本算不准谁是垃圾。
-
标记:从根对象(全局变量、栈变量)出发,找出所有引用的对象,打上标记。
-
清除:遍历堆,回收没标记的对象。
-
恢复程序。
这个逻辑很简单,但致命弱点是 STW 时间太长。随着内存变大,暂停时间线性增长。
为了缩短 STW,Go 引入了 并发标记 。即:程序在跑,GC 也在跑 。 为了实现并发,必须引入一种逻辑严密的标记机制,这就是 三色标记法。
2. 三色标记法 (Tricolor Marking)
三色标记法将对象逻辑上分为三种颜色:
-
⬜️ 白色 (White):潜在的垃圾。GC 开始时,所有对象都是白色。
-
🔘 灰色 (Grey):活跃对象,但它的子对象(引用)还没扫描完。
-
⚫️ 黑色 (Black):活跃对象,且它的所有子对象都已经扫描完毕。
2.1 正常流程
-
初始状态:所有对象都是白色。
-
扫描根节点:将从 Root Set(栈、全局变量)直接引用的对象标记为灰色,放入队列。
-
遍历灰色队列:
-
取出一个灰色对象,将其引用的白色子对象标记为灰色。
-
将该对象自身标记为黑色。
-
-
重复步骤 3,直到灰色队列为空。
-
清除:此时剩下的白色对象就是不可达的垃圾,进行回收。
3. 并发标记的 Bug:对象丢失
如果 GC 在标记的同时,用户代码(Mutator)也在修改引用关系,会发生什么?
灾难场景:
假设 A(黑) -> B(灰) -> C(白)。
-
GC 扫描了 A,A 变黑。B 还是灰。
-
用户代码执行 :
A.ref = C(黑色 A 指向了白色 C)。 -
用户代码执行 :
B.ref = nil(灰色 B 删除了对 C 的引用)。 -
GC 继续扫描 B:B 没有子对象了,B 变黑。
-
扫描结束。
结果 :C 依然是白色(垃圾),会被清除。但实际上 A 引用了 C! 后果 :悬垂指针,程序崩溃。
4. 屏障技术
为了防止上述 Bug,Go 引入了 写屏障 。 Go 1.8 之前使用的是 Dijkstra 插入屏障 和 Yuasa 删除屏障 ,但都有缺点(需要 STW 重新扫描栈)。 Go 1.8 引入了 混合写屏障,结合了两者的优点。
核心规则:
-
GC 开始时,将栈上的所有可达对象全部标记为黑色(不需要 STW 再次扫描栈)。
-
GC 期间,任何在栈上创建的新对象,均为黑色。
-
堆上被删除的对象标记为灰色。
-
堆上新添加的对象标记为灰色。
屏障逻辑伪代码:
// 伪代码:在执行 *slot = ptr 时触发
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // 删除屏障:保护旧值,将其标灰
shade(ptr) // 插入屏障:保护新值,将其标灰
*slot = ptr // 真正的赋值操作
}
效果:
-
精度高:结合了插入和删除屏障。
-
STW 短 :因为栈是黑色的,且栈操作无屏障,GC 结束时不需要重新扫描栈。
5. 观察 GC (GODEBUG)
5.1 模拟高频分配代码
package main
import (
"fmt"
"time"
)
// 制造垃圾的函数
func allocate() {
_ = make([]byte, 1<<20) // 分配 1MB 的内存
}
func main() {
fmt.Println("Start Allocating...")
for i := 0; i < 10; i++ {
allocate()
time.Sleep(time.Millisecond * 100)
}
fmt.Println("Done.")
}
5.2 运行与分析
使用 GODEBUG=gctrace=1 运行程序:
$ GODEBUG=gctrace=1 go run main.go
输出解读(示例):
gc 1 @0.005s 3%: 0.019+0.38+0.016 ms clock, 0.15+0.12/0.066/0.14+0.13 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
gc 2 @0.112s 0%: 0.046+0.16+0.024 ms clock, 0.37+0.076/0.047/0.015+0.19 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
...
核心字段解析:
-
gc 1:第 1 次 GC。 -
@0.005s:程序启动后 0.005s 时发生。 -
3%:自上次 GC 以来,GC 占用了 3% 的 CPU 时间。 -
4->4->0 MB:-
GC 开始时的堆大小 (4MB)。
-
GC 结束时的堆大小 (4MB,因为是并发的,可能还会涨)。
-
存活的堆大小 (0MB) -> 这里因为
allocate函数结束后切片就没用了,所以都被回收了。
-
-
5 MB goal:下一次触发 GC 的目标堆大小。
6. GC 调优 (Ballast & GOGC)
6.1 GOGC 原理
Go 默认的 GC 触发策略是:当堆内存达到上次 GC 后存活内存的 200% 时触发 。 这个 200% 就是由 GOGC 环境变量控制的,默认值 100。
公式:NextGC_Goal = LiveHeap + (LiveHeap + GC_Roots) * GOGC / 100
6.2 内存压舱石 (Memory Ballast)
在 Go 1.19 之前(Soft Memory Limit 引入前),像 Twitch 这样的大厂广泛使用 Ballast 技术来降低 GC 频率。
场景 :你的服务需要处理大量短连接,产生海量小对象。GC 频繁触发,CPU 飙升。 方案:初始化一个巨大的、永远不被释放的数组。
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// 1. 初始化一个 100MB 的压舱石
// 注意:这只是虚拟内存,只要不读写它,就不会占用物理内存(RSS)
ballast := make([]byte, 100*1024*1024)
// 保持引用,防止被 GC
runtime.KeepAlive(ballast)
fmt.Println("Ballast initialized. GC will be less frequent.")
// 模拟业务逻辑
for {
_ = make([]byte, 1<<20) // 分配 1MB
time.Sleep(time.Millisecond * 10)
}
}
原理:
-
程序启动,
LiveHeap瞬间变成 100MB。 -
根据 GOGC=100,下一次 GC 的目标是
100MB + 100MB = 200MB。 -
你的业务逻辑产生的小对象(垃圾)必须填满这额外的 100MB 空间,才会触发 GC。
-
结果:GC 频率大幅降低,虽然峰值虚拟内存变大了,但 CPU 得到了解放。
注意 :Go 1.19 引入了
debug.SetMemoryLimit,这是更官方的解决方案,但在理解 GC 原理上,Ballast 依然是一个绝佳的案例。