垃圾回收
需要预先了解:
1. 垃圾回收
Go 的垃圾回收器(GC,Garbage Collector)负责回收不再被引用的堆对象。它的设计目标很明确:把停顿时间压到亚毫秒级,让服务端程序不必为 GC 停顿做特殊设计。这一篇讲清楚三色标记、写屏障、触发时机和调优手段。
1.1 为什么需要 GC
堆上的内存生命周期无法在编译期确定,只能运行时判断。手动管理会带来两类问题:
- 忘记释放:内存越用越多,最终耗尽;
- 释放过早:对象还在被引用就被回收,后续访问到已失效的内存。
垃圾回收器把"什么时候释放"这件事交给运行时:它周期性地从根对象(栈上的变量、全局变量、寄存器)出发,找出所有还能访问到的对象,其余的就是垃圾。
1.2 GC 演进时间线
Go 的 GC 从"全程暂停"演进到"几乎不停顿",每一步都是被上一步的问题推动的:
| 阶段 | 做法 | 问题 |
|---|---|---|
| STW 标记清扫 | 暂停所有 goroutine,标记垃圾并清扫 | 停顿与堆大小成正比,堆大了没法用 |
| 并发标记清扫 | 标记阶段与用户代码并发执行 | 并发时对象引用会变化,可能漏标或误标 |
| 三色标记 + 插入写屏障 | 用颜色表示标记状态,写操作加屏障 | 插入屏障需要 STW 重新扫描栈 |
| 混合写屏障(1.8) | 插入 + 删除屏障的组合 | 不需要重扫栈,停顿进一步缩短 |
Go 1.8 引入的混合写屏障是当前的基础,STW 时间降到与栈大小无关,通常在几十微秒以内。
1.3 三色标记
三色标记用颜色表示对象的扫描状态:
| 颜色 | 含义 |
|---|---|
| 白色 | 尚未被扫描到,可能成为垃圾 |
| 灰色 | 已被扫描到,但它引用的对象还没扫完 |
| 黑色 | 自身和它引用的对象都已扫完 |
流程:
1. 初始:所有对象都是白色,根对象置为灰色
2. 循环:取出一个灰色对象
- 把它引用的白色对象涂成灰色
- 把它自己涂成黑色
3. 直到没有灰色对象
4. 剩下的白色对象就是垃圾,回收它们
根对象 引用关系
[灰] ──────> [灰] ──────> [白]
│
└────────> [白]
扫描一轮后:
[黑] ──────> [灰] ──────> [白]
│
└────────> [灰]
全部扫完:
[黑] ──────> [黑] ──────> [黑]
│
└────────> [黑]
1.4 并发标记的正确性问题
标记阶段与用户代码并发执行时,引用关系可能在标记过程中变化。看一个"丢对象"的经典场景:
初始:A(黑)──> B(灰)──> C(白)
用户代码执行了两步:
A.ref = nil(删除 A 到 B 的引用);B.ref = C(B 引用 C)——但 B 已经是灰色,之后不会再被扫描它的引用。
结果:C 既是白色,又没有从任何黑色对象可达,于是被错误回收。可实际上它仍然被某个灰色对象引用着。
要避免这种情况,需要保证三色不变式之一:
- 强三色不变式:黑色对象不能指向白色对象;
- 弱三色不变式:黑色对象指向的白色对象,必须存在一条从灰色对象到它的路径。
**写屏障(write barrier)**就是在指针赋值时插入的检查代码,用来维持不变式:
- 插入写屏障:当
*slot = ptr时,如果slot是黑色,就把ptr涂灰(保证强三色不变式); - 删除写屏障:当删除引用时,把被删除指向的对象涂灰(保证弱三色不变式)。
两种屏障各有代价,Go 1.8 的混合写屏障把它们组合起来:新建对象直接涂黑、被覆盖和被写入的对象涂灰。这样既不需要在标记结束时重新扫描所有栈,也避免了遗漏。
注:写屏障只作用于堆上的指针写入,栈上的写入不需要屏障。这也是为什么混合写屏障能把 STW 时间与栈大小解耦。
1.5 触发时机
GC 在三种情况下被触发:
| 触发条件 | 说明 |
|---|---|
| 堆增长达到目标 | 堆内存相比上次 GC 后增长超过 GOGC 设定的比例(默认 100,即翻倍) |
| 定时触发(2 分钟) | runtime.forcegcperiod,兜底机制,防止长期不分配内存的程序不回收 |
手动 runtime.GC() |
强制一次 GC,主要用于测试和调试 |
GOGC=100 的含义是:上次 GC 后存活堆为 4MB 时,堆增长到 8MB 左右就会触发下一次 GC。GOGC=off 完全关闭自动 GC。
1.6 调优:GOGC 与 GOMEMLIMIT
用环境变量观察不同设置下的堆行为。同一段分配 5 次 10MB 切片的程序:
GOGC=off go run .
分配 50MB 后 HeapAlloc: 50.43 MB
手动 GC 后 HeapAlloc: 0.47 MB
GOGC=20 go run .
分配 50MB 后 HeapAlloc: 10.47 MB
手动 GC 后 HeapAlloc: 0.48 MB
GOGC=off 时自动 GC 完全不跑,50MB 全部留在堆上;GOGC=20 时 GC 频繁触发,堆稳定在 10MB 左右。
两个调优旋钮:
| 参数 | 作用 | 适用场景 |
|---|---|---|
GOGC |
控制 GC 触发的堆增长比例,值越小 GC 越频繁 | 想要降低内存占用时调小;想要降低 GC 频率时调大 |
GOMEMLIMIT(Go 1.19+) |
软内存上限,接近上限时提前触发 GC | 容器环境设置内存 limit 后,让 Go 主动贴合限制 |
GOMEMLIMIT 是软限制:运行时不会因为超过它就 panic,而是会更积极地 GC;如果堆里全是存活对象,仍然可能超过。
注:调
GOGC只能改变 GC 的频率,不能改变"有多少对象是垃圾"这个事实。减少对象数量比调参数更有效:复用缓冲区(sync.Pool)、预分配容量、避免不必要的指针和接口装箱,往往能带来数量级的改善。
1.7 观察手段
GODEBUG=gctrace=1
GODEBUG=gctrace=1 go run .
每次 GC 输出一行:
gc 1 @0.086s 0%: 0+0.52+0 ms clock, 0+0/0/0+0 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 24 P
| 字段 | 含义 |
|---|---|
gc 1 |
第 1 次 GC |
@0.086s |
程序启动后 0.086 秒 |
0% |
GC 占用的 CPU 百分比 |
0+0.52+0 ms clock |
STW 扫描终止 + 并发标记 + STW 标记终止 的墙钟时间 |
3->4->1 MB |
GC 开始时堆大小 → GC 结束时堆大小 → 标记存活堆大小 |
4 MB goal |
下次触发的堆目标 |
0 MB stacks, 0 MB globals |
栈内存与全局变量占用 |
24 P |
GOMAXPROCS |
关心的是 3->4->1 MB 这组数字:如果 存活堆 持续增长,说明有对象泄漏;如果 GC 开始堆 远大于 存活堆,说明垃圾产生速度很快。
runtime.ReadMemStats
var m runtime.MemStats
runtime.ReadMemStats(&m)
| 字段 | 含义 |
|---|---|
Alloc |
当前存活对象占用的堆内存 |
TotalAlloc |
累计分配过的堆内存 |
Sys |
从操作系统申请的总内存 |
NumGC |
已完成的 GC 次数 |
HeapObjects |
当前存活对象数 |
PauseTotalNs |
累计 STW 时间 |
实测输出:
启动时
Alloc = 0.42 MB
TotalAlloc = 0.42 MB
Sys = 6.02 MB
NumGC = 0
HeapObjects = 260
PauseTotalNs = 0.0 µs
分配约 10MB 后
Alloc = 10.62 MB
TotalAlloc = 10.63 MB
Sys = 19.64 MB
NumGC = 2
PauseTotalNs = 0.0 µs
GC 后
Alloc = 0.47 MB
TotalAlloc = 10.64 MB
NumGC = 3
注意 Alloc 和 TotalAlloc 的区别:前者是"现在活着多少",后者是"一共分配过多少"。PauseTotalNs 在现代 Go 上常常小到显示为 0 微秒。
更细致的分析用 pprof 的 heap profile,见 性能分析与调优。
1.8 终结器
runtime.SetFinalizer 可以在对象被回收前执行一个函数:
r := &Resource{Name: "文件句柄"}
runtime.SetFinalizer(r, func(x *Resource) {
fmt.Println("终结器被调用,释放:", x.Name)
})
终结器被调用,释放: 文件句柄
主函数结束
不建议用它做资源释放,原因有三:
- 不保证及时:终结器在对象不可达后的某次 GC 才执行,可能很久以后,也可能永远不执行(程序退出时);
- 不保证顺序:多个对象的终结器执行顺序不确定;
- 会让对象"复活":带终结器的对象至少要经历两次 GC 才能真正回收,反而增加开销。
资源释放应该用 defer、Close() 这类显式方式。终结器只适合作为"忘记关闭时的兜底告警"。
1.9 练习
- 写一个持续分配内存的程序,分别用
GOGC=off、GOGC=100、GOGC=20运行,用GODEBUG=gctrace=1观察 GC 频率与堆大小。 - 用
runtime.ReadMemStats记录一次循环前后的TotalAlloc与Alloc,判断循环里分配的对象是否被回收。 - 写一个持有大切片的小切片(
big[:10]),用 heap profile 或MemStats观察它是否阻止了大数组被回收,再用slices.Clone修复。



