需要预先了解:


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(白)

用户代码执行了两步:

  1. A.ref = nil(删除 A 到 B 的引用);
  2. 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)
})
终结器被调用,释放: 文件句柄
主函数结束

不建议用它做资源释放,原因有三:

  1. 不保证及时:终结器在对象不可达后的某次 GC 才执行,可能很久以后,也可能永远不执行(程序退出时);
  2. 不保证顺序:多个对象的终结器执行顺序不确定;
  3. 会让对象"复活":带终结器的对象至少要经历两次 GC 才能真正回收,反而增加开销。

资源释放应该用 defer、Close() 这类显式方式。终结器只适合作为"忘记关闭时的兜底告警"。


1.9 练习

  1. 写一个持续分配内存的程序,分别用 GOGC=off、GOGC=100、GOGC=20 运行,用 GODEBUG=gctrace=1 观察 GC 频率与堆大小。
  2. 用 runtime.ReadMemStats 记录一次循环前后的 TotalAlloc 与 Alloc,判断循环里分配的对象是否被回收。
  3. 写一个持有大切片的小切片(big[:10]),用 heap profile 或 MemStats 观察它是否阻止了大数组被回收,再用 slices.Clone 修复。