跳到正文

09 垃圾回收 ​

一句话:Go 用并发标记清扫(concurrent mark-sweep)把大部分标记工作放到业务运行期间,只留两段很短的 STW;代价是标记要和业务抢 CPU、堆不压缩会留碎片,回收目标还要靠 GOGC/GOMEMLIMIT 自己调。

适用 Go 1.22+(GOMEMLIMIT 从 1.19 开始提供;Go 1.25 的 Green Tea GC 是实验特性,是否默认启用及具体行为以对应版本 Release Notes 为准)。堆上的对象从哪来,见 08 内存逃逸。

为什么需要 ​

手动 free 容易漏释放和重复释放;引用计数(reference counting)又要为每次指针赋值维护计数,且处理不了循环引用。Go 选的是追踪式 GC(tracing GC):从根出发找可达对象,剩下的就是垃圾。这对指针密集、并发写的程序更简单。

它同时做了两个反直觉的取舍:不分代、不压缩。不分代意味着每轮都要重扫整个存活堆,存活集越大单轮越贵;不压缩意味着没有对象搬移,分配器简单、指针稳定,但长期运行后会产生碎片。

核心机制 ​

三色标记与两段 STW ​

  • 白色:还没被访问,可能是垃圾。
  • 灰色:已发现,但引用还没扫描完。
  • 黑色:自身和引用都已扫描完。

根包括 goroutine 栈、全局变量和运行时内部结构。标记从灰色队列出发:取一个灰对象,把它引用的白对象染灰,自己染黑,直到灰色队列为空。必须始终满足「黑对象不能指向白对象」,否则白对象会被漏扫并错误回收。

一轮 GC 的停顿只出现在首尾:

gctrace 里的三段 clock 正好对应:首段 STW、中段并发标记、末段 STW。并发标记默认最多占用约 25% 的 GOMAXPROCS;如果业务分配速度超过标记速度,分配对象的 goroutine 会被拉去做标记协助(mark assist),表现为业务延迟升高。

写屏障:并发标记不漏标的代价 ​

并发标记时,业务可能把「黑对象」的指针改指向一个白对象。如果不管,这个白对象永远不会进灰色队列。Go 1.8 起使用混合写屏障(hybrid write barrier):一次指针写同时把旧指针和新指针都染灰,因此不需要在标记结束时重新扫描所有栈。

代价是每次指针写多几条指令。栈上的对象在扫描时按黑色处理,新创建的栈对象也视为黑色,这样栈不需要二次扫描。

触发条件 ​

常见说法「系统调用或网络阻塞会触发 GC」是错的。实际触发只有这几类:

条件说明
堆增长到目标nextGC = 存活堆 × (1 + GOGC/100),GOGC=100(默认)表示目标约为存活堆的 2 倍;堆下限约 4 MiB
接近 GOMEMLIMITGo 1.19+,默认关闭(值为 math.MaxInt64);它是软限制,提前触发 GC 而不是硬性阻止分配
显式调用runtime.GC()、debug.SetGCPercent() 调整后
长时间没 GCsysmon 每 2 分钟强制触发一次(forcegcperiod)

GOGC=off 只关闭「堆增长」这一条,内存限制、显式调用和 2 分钟强制 GC 仍然有效。

清扫与内存返还 ​

标记结束后清扫是并发的,回收的白对象进入分配器的空闲结构。内存不会立刻还给操作系统,而是由后台 scavenger 逐步返还;debug.FreeOSMemory() 可以强制返还,但它本身会触发 GC,不适合放在请求路径。

实战场景 ​

一、GC 频率翻倍,P99 里全是 mark assist ​

现象:网关压测时 QPS 到 8 万后 P99 从 12ms 涨到 200ms,GODEBUG=gctrace=1 显示 GC 间隔从 1.2s 缩到 180ms,每次并发标记耗时接近间隔本身;pprof 火焰图里 runtime.gcAssistMark 占比接近 20%。

排查:并发标记默认最多占用约 25% 的 GOMAXPROCS,标记追不上分配时,分配对象的 goroutine 会被拉去协助标记。

定位层取证据判定
GC 间隔与耗时gctrace 的 gc # @…% … (ms) clock 三段首尾是两次 STW,中间是并发标记
分配速率/debug/pprof/heap 的 alloc_objects、runtime/metrics 的 gc/heap/allocs:bytes 增速分配速率决定 GC 频率
assist 时间runtime/metrics 的 cpu/gc/assist、pprof 的 GC 视图assist 高即标记追不上
堆增长曲线heap_sys、heap_alloc存活集大→标记成本高
GOGC 设置环境变量/代码默认 100 表示“翻倍触发”,大堆下就是巨大峰值

对症做法:

go
// 1. 先减分配:复用大 buffer,避免每请求 new
var bufPool = sync.Pool{New: func() any { b := make([]byte, 0, 32*1024); return &b }}

func handle(w http.ResponseWriter, r *http.Request) {
    bp := bufPool.Get().(*[]byte)
    defer bufPool.Put(bp)
    b := (*bp)[:0]                 // 复用容量而不是重新分配
    b = append(b, payload...)
    _, _ = w.Write(b)
}

// 2. 减少“装箱 + 接口值”在热路径上的分配:
//    固定类型容器([]int 而非 []any)、避免 fmt.Sprintf 拼接日志
//    日志热路径用 slog 的 kv 形式或提前 level 判断
bash
# 3. 确认是分配而不是存活集:对比 GC 前后的 heap_alloc
GODEBUG=gctrace=1 ./gateway 2>&1 | grep gc
go tool pprof -http=: http://127.0.0.1:6060/debug/pprof/heap?sample_type=alloc_objects
text
4. 调 GOGC 是第二步:
   降低到 60 会减少堆峰值、增加 GC 次数,对延迟敏感的服务往往更差;
   提高吞吐的正确顺序是“减分配 → 复用 → 再谈参数”。
5. 标记成本跟存活对象数相关:
   把常驻大 map 拆分片、避免无界缓存,比调 GOGC 有效。

复盘要点:判据是“GC 时间占比”(gctrace 累计 GC 时间 / 运行时间)低于预算,以及“assist 时间在 pprof 中不可见”。分配速率是这条链的源头,runtime/metrics 把它做成常驻指标,比事后看 GC 日志快得多。写屏障(Go 1.8 起的混合写屏障:一次指针写同时把旧指针与新指针染灰)本身只多几条指令,真正的成本来自标记跟不上分配。

二、一次“返回局部变量”让对象逃逸到堆,热路径分配翻倍 ​

现象:序列化函数里用一个 bytes.Buffer 局部变量并返回它的 Bytes(),压测时分配量比预期高一倍。加 -gcflags=-m 后看到 moved to heap: buf 与多处 escapes to heap。

排查:栈分配的前提是编译器能证明对象不会逃出函数。

常见逃逸机制
返回局部变量的指针/切片生命周期超出栈帧
把指针存进全局变量或 map被外部引用
传给接口参数的值装箱interface{} 接收者无法确定具体类型
闭包捕获变量且闭包外泄变量随闭包存活
goroutine 里使用的局部变量无法静态确定生命周期
slice 扩容后返回底层数组底层数组逃逸,容量浪费也随之放大

对症做法:

go
// 1. 让调用方提供缓冲区:不返回内部切片,避免整块 buffer 逃逸
func MarshalInto(dst []byte, v Value) ([]byte, error) {
    return v.AppendTo(dst)          // 复用调用方的栈/池内存
}

// 2. 小对象返回值而不是指针:
//    Go 里“返回指针以省拷贝”常常反效果——值返回可以留在栈上,指针会逃逸
type Point struct{ X, Y float64 }
func Normalize(p Point, norm float64) Point { return Point{p.X / norm, p.Y / norm} }

// 3. 热路径避免 any:用具体类型或泛型约束
func AppendAll[T any](dst []T, src []T) []T { return append(dst, src...) }
bash
# 4. 验证手段固定两条:
go build -gcflags=-m=2 ./... 2>&1 | grep -E 'escapes to heap|moved to heap' | head -30
go test -bench=. -benchmem ./...          # 每次改动看 allocs/op 是否下降

复盘要点:判据是“热路径基准测试的 allocs/op 不高于上一版”——把它做成 CI 里的对比项(例如增长 > 20% 就失败),比事后翻 -m 输出有效。栈上对象在扫描时按黑色处理、新建栈对象也视为黑色,因此栈不需要二次扫描;让对象留在栈上同时降低分配与标记成本,这是“逃逸分析看得懂”的实际收益。

三、GOGC=100 撞上容器内存 limit,RSS 冲到 2 倍被 OOMKill ​

现象:一个常驻 6GB 堆的 Go 服务在 K8s 里 limit 12Gi,日常正常。某次导入任务让存活集升到 8GB,随后 RSS 冲到 16GB 被 OOM 杀掉。团队当时的判断是“内存泄漏”,但 pprof 显示堆是真实的活跃数据。

排查:GOGC 是相对堆增长的比例,不感知内存上限。

定位层取证据判定
触发阈值GOGC=100 → 下次 GC 目标约为存活集的 2 倍6GB 存活 → 堆可长到 12GB 才触发
真实进程占用heap_sys、RSS(堆外还有栈、cgo、页缓存)RSS > heap 是常态,不要按堆估 limit
是否被限GOMEMLIMIT 是否设置未设就没有软上限保护
归还速度scavenger 行为、MADV_FREE/MADV_DONTNEED 的平台差异RSS 不立即下降不等于泄漏
周期sysmon 每 2 分钟强制触发一次 GC(forcegcperiod)空闲期内存回落依赖这条
峰值来源存活集上升的调用(导入、批量、缓存)定位业务侧根因

对症做法:

bash
# 1. 设内存上限:GC 会在接近 limit 时更积极,避免被内核杀掉
GOMEMLIMIT=10GiB          # 预留 2GiB 给栈/直接内存/cgo/页缓存
GOGC=50                   # 配合使用,降低堆翻倍带来的峰值
# 容器 limit 12Gi 时,让 GC 在 10Gi 附近主动回收,而不是等 cgroup 阈值
go
// 2. 有界的常驻内存必须显式限容:
cache, _ := lru.New(200_000)                  // 上限由内存预算倒算
// 3. 大批量处理走流式分片,避免一次装进内存:
for batch := range src.Chunks(5000) { process(batch); }
text
4. 观测分三层看:heap_alloc(活跃对象)→ heap_sys(向 OS 拿到的)→ 进程 RSS。
   只有三者一起看才能区分“泄漏 / 未及时归还 / 堆外占用”。
5. 想主动归还内存:debug.FreeOSMemory() 可用于批处理结束后,
   但不要指望它替代 GOMEMLIMIT;频繁调用会增加 syscall 开销。
6. 依赖 finalizer 释放资源不可靠:
   GC 时机不确定,且 SetFinalizer 的对象至少活到下一轮标记,
   文件与连接必须显式 Close。

复盘要点:判据是“heap_sys 与 RSS 都被 GOMEMLIMIT 约束在 limit 的 85% 以下”(比例需按服务压测校准)。堆内存“只涨不降”常常是 scavenger 的节奏问题而不是泄漏,这个区分要在容量评审里写清楚:GOGC 控制触发时机、GOMEMLIMIT 控制上限,两个都设才有可预测的内存行为,这与 S01 JVM 内存模型 里“堆外也要设上限”的结论同构。

最小上手 ​

go
package main

import (
	"net/http"
	_ "net/http/pprof"
	"time"
)

var sink [][]byte

func main() {
	go http.ListenAndServe("127.0.0.1:6060", nil)
	for i := 0; i < 300000; i++ {
		b := make([]byte, 1024)
		if i%100 == 0 {
			sink = append(sink, b) // 约 3 MB 长期存活,其余成为垃圾
		}
	}
	time.Sleep(time.Hour)
}
bash
mkdir -p /tmp/gcdemo && cd /tmp/gcdemo
go mod init gcdemo
# 保存上面的 main.go 后
GODEBUG=gctrace=1 go run . 2>&1 | head -20

# 另开终端看堆
go tool pprof -http=:8081 http://127.0.0.1:6060/debug/pprof/heap

gctrace 每轮输出一行,字段随版本略有差异:

gc 8 @1.234s 0%: 0.021+1.8+0.003 ms clock, 0.084+1.1/2.0/0.01 ms cpu, 12->14->7 MB, 16 MB goal, 4 P

读法:8 是第几轮;0.021+1.8+0.003 ms clock 是「清扫终止 STW + 并发标记 + 标记终止 STW」;0.084+1.1/2.0/0.01 ms cpu 是「协助 + 后台专用/后台分摊/空闲」;12->14->7 MB 是「GC 开始时堆、GC 结束时堆、GC 后存活堆」;16 MB goal 是下一轮目标。若 goal 长期贴近 live,说明存活集大或分配快,GC 会变密。

pprof 默认看 inuse_space(当前存活),加 -sample_index=alloc_space 看累计分配。堆 profile 是采样的(runtime.MemProfileRate 默认约 512 KB 一个样本),不是精确值。

生产动作 ​

  • GOGC 取舍:调低(如 50)更省内存、更费 CPU;调高(如 200)更省 CPU、更费内存。它换的是「用多少 CPU 换多少堆」,不是越大越好。
  • GOMEMLIMIT:容器里可按内存上限的 80%–90% 设置(经验值),给栈、cgo、运行时元数据留余量。它是软限制:存活堆超过限制时 GC 会连续运行,仍可能 OOM;单个超大分配也拦不住。设置方式:GOMEMLIMIT=1800MiB 或 debug.SetMemoryLimit()。
  • 观测指标:GODEBUG=gctrace=1 只在排障时短开;长期用 runtime/metrics 的 /gc/heap/goal:bytes、/gc/cycles/total:gc-cycles、/sched/gomaxprocs:threads,或 go tool trace 看 STW 与 assist。
  • 排障路径:
    • CPU 高、GC 轮次密、goal 贴近 live → 减少分配或提高 GOGC/加内存。
    • RSS 持续涨但 heap profile 平 → 查 goroutine 栈、cgo、mmap 和碎片,cat /proc/<pid>/smaps_rollup。
    • P99 抖动、gctrace 的 STW 段变长 → 存活集过大或标记终止慢,go tool trace 定位。
    • GC 连续不停(死亡螺旋)→ 存活堆已超过 GOMEMLIMIT,先修泄漏或调高限制,不要只调 GOGC。

边界 ​

  • 没有硬性停顿上限:GOGC 和 GOMEMLIMIT 是目标值,不是保证;实时性要求高的系统不能只靠 GC。
  • 不分代、不压缩:每轮重扫存活堆,长期存活对象多时成本高;碎片可能让大对象分配失败并触发堆扩张。
  • runtime.GC() 不是调优手段:放在请求路径只会增加停顿;debug.FreeOSMemory() 同理。
  • cgo 和堆外内存不受管:RSS 上涨不一定是 Go 堆,必须先看 smaps_rollup 分辨。
  • Finalizer 会延迟回收:依赖 SetFinalizer 释放资源容易漏,优先用显式 Close。
  • 大对象直接进堆:一次超大 make 可能瞬间推高堆,GOMEMLIMIT 不能阻止单次分配失败。

讲清楚 ​

Go 的 GC 是并发标记清扫:从 goroutine 栈和全局变量出发做三色标记,标记期间业务继续跑,只留首尾两段很短的 STW。难点是并发时指针会被改,所以用混合写屏障把旧指针和新指针都染灰,保证不漏标。触发条件主要是堆增长到 存活堆 × (1+GOGC/100)、接近 GOMEMLIMIT、显式调用,以及两分钟没 GC 时由 sysmon 强制触发。

它的取舍很明确:不分代、不压缩,换来分配器和指针简单、延迟低,代价是每轮重扫存活堆、长期运行会有碎片。生产上调 GC 不是背参数,而是先看 gctrace 和堆 profile:GC 密说明分配快或存活集大,RSS 涨而堆平要查栈和堆外内存。

把一门技术讲透到既懂也会用