服务内存每分钟涨 30 MB,到 2 GB 后回落到 400 MB,然后再涨。GC 频繁但回收量小,pprof 里 inuse_space 最大的却是一个你早已丢弃的切片。答案通常不在 GC 参数上,而在哪些值被分配到堆、谁还在引用它们。按 Go 1.22 起的行为拆两层:GC 怎么回收,分配怎么发生。
1. 三色标记与并发标记
| 阶段 | 是否 STW | 做什么 |
|---|---|---|
| 标记准备(mark setup) | 是,极短 | 开启写屏障,初始化标记队列 |
| 并发标记(concurrent mark) | 否 | 与用户代码同时运行,三色标记推进 |
| 标记终止(mark termination) | 是,极短 | 关闭写屏障,收尾统计 |
| 并发清除(sweep) | 否 | 惰性回收,与分配交错进行 |
三色抽象:白(未访问,待回收)、灰(已访问但子对象未扫完)、黑(已扫描完)。写屏障的作用是:并发标记期间用户代码修改指针时,把被覆盖或新指向的对象标灰,防止"黑对象指向白对象"导致活对象被误回收。
Go 没有分代,也没有压缩(对象不移动)。因此碎片不会自动整理,长期运行的服务要关注对象大小分布,而不是期待 GC 帮忙紧凑化。
$ GODEBUG=gctrace=1 ./app
gc 12 @3.214s 2%: 0.021+1.8+0.003 ms clock, 0.17+0.9/2.1/0+0.02 ms cpu, 512->520->310 MB, 900 MB goal, 8 P
一行 gctrace 的关键字段:512->520->310 MB 是标记前堆、标记后堆、存活堆;900 MB goal 是下次触发目标;+1.8+ 中间那段是并发标记耗时。存活堆(310 MB)才是真实占用,前两个数包含未回收的垃圾。
2. GOGC 与 GOMEMLIMIT
| 变量 | 语义 | 计算方式 | 适用 |
|---|---|---|---|
GOGC=100(默认) |
每次 GC 后,存活堆增长 100% 就再触发 | 比例,随存活堆放大 | 通用 |
GOGC=off |
关闭比例触发 | — | 只用于调试 |
GOMEMLIMIT |
进程内存软上限(Go 1.19+) | 绝对值 | 容器内存受限场景 |
| 组合使用 | 两者取先到的触发条件 | 比例 + 上限 | 推荐:GOGC=100 + GOMEMLIMIT=容器 limit × 0.8 |
GOGC 的问题:存活堆 1 GB 时,GOGC=100 允许涨到 2 GB 才回收——在 2 GB 的容器里直接 OOM。GOMEMLIMIT 解决的就是这个:无论存活堆多大,接近上限就提前加大 GC 频率(不是拒绝分配)。
注意 GOMEMLIMIT 是软上限:它不阻止内存被分配到上限之外,只是让 GC 更激进。设成等于容器 hard limit 会导致 GC 死亡螺旋(GC 追不上分配),留 10%–20% 余量是常规做法。
3. 逃逸分析判定与 -gcflags=-m
$ go build -gcflags='-m' ./... # 一级:报告逃逸决策
$ go build -gcflags='-m=2' ./... # 二级:给出判定理由(噪音大)
$ go build -gcflags='all=-m' ./... # 含标准库,用于理解边界
./main.go:12:6: moved to heap: buf
./main.go:20:14: &Item{} escapes to heap
./main.go:25:11: leaking param: p
./main.go:30:9: x does not escape
| 判定条件 | 结果 | 说明 |
|---|---|---|
| 返回局部变量的指针 | 堆 | 调用方可能持有更久 |
| 被闭包捕获且闭包逃逸 | 堆 | 生命周期与闭包绑定 |
| 赋值给接口类型 | 堆(含装箱) | 接口值需要在堆上有稳定地址 |
| 大小在编译期不确定 | 堆 | 栈帧大小必须静态可知 |
传入 make 的容量是变量 |
堆 | 切片底层数组大小未知 |
| 指针存入全局/包级变量 | 堆 | 全局可达 |
| 纯局部、大小固定、不取地址 | 栈 | 最快路径 |
逃逸分析是编译器优化,不是你能"指挥"的开关。它的收益是省掉堆分配与 GC 压力;写着不变应万变的 &T{} 不会自动被优化到栈上,只会真的上堆。
4. 栈分配与堆分配的成本来源
| 维度 | 栈 | 堆 |
|---|---|---|
| 分配 | 移动栈指针,纳秒级 | 需要 span/尺寸类匹配,较慢 |
| 回收 | 函数返回即批量释放 | GC 扫描 + 清除 |
| 局部性 | 极好(连续、热) | 差(分散、可能跨 span) |
| 生命周期 | 编译期确定 | 由引用关系决定 |
| 并发 | 每 G 独立栈 | 全局共享,需 mcache/mcentral 协调 |
单次调用多分配 10 个小对象,差异不明显;每秒百万次调用时,差异直接体现为 GC 周期变短、CPU 上升。优化方向是"减少热路径上的分配次数",不是"消灭所有堆分配"。
5. 预分配:切片与 map
s := make([]int, 0, 1000) // 容量 1000,长度 0
for i := 0; i < 1000; i++ { s = append(s, i) } // 零次扩容
m := make(map[string]int, 1000) // 预分配桶,减少 rehash
var b strings.Builder
b.Grow(1024) // 字符串拼接的预分配
| 结构 | 未预分配的代价 | 预分配写法 |
|---|---|---|
| slice | 增长按容量翻倍,每次复制旧数组 | make([]T, 0, n) |
| map | 增长时 rehash,重新分布桶 | make(map[K]V, n) |
strings.Builder |
逐次扩容复制 | Grow(n) |
bytes.Buffer |
同上 | Grow(n) |
切片的扩容策略是容量不足时按约 2 倍(小切片)或 1.25 倍(大切片)增长,每次扩容都要 memmove 整个数组。容量能预估就预估——这是 Go 里最便宜的一次优化。
6. 切片共享底层数组导致的内存泄漏
func first10K(big []byte) []byte {
return big[:10000] // 泄漏:只要这 10KB 被引用,整个 big 都无法回收
}
// 修法 1:复制裁剪
func first10K(big []byte) []byte {
out := make([]byte, 10000)
copy(out, big[:10000])
return out
}
// 修法 2:三索引切片限制 append 越界(不释放内存,但防误写)
s := big[:10000:10000]
| 写法 | 持有的底层数组 | 是否泄漏 |
|---|---|---|
s = big[:n] |
整个 big |
是 |
s = big[:n:n] |
整个 big,但 append 会重新分配 |
是(内存仍被持有) |
copy 到新切片 |
只有 n |
否 |
slices.Clone(big[:n]) |
只有 n |
否 |
这类泄漏在 pprof 里很典型:inuse_space 大而对象数不多——少量大数组被小切片挂着。判断方法看切片 len 与底层 cap 的比例。
7. 字符串与 []byte 的转换开销
| 写法 | 是否分配 | 说明 |
|---|---|---|
[]byte(s) |
通常分配并复制 | 转换后修改不影响原串 |
string(b) |
通常分配并复制 | 保证字符串不可变 |
m[string(b)] |
可优化为不分配 | 编译器识别 map 查找场景 |
s == string(b) |
可优化为不分配 | 比较场景不复制 |
for i := range []byte(s) |
视版本而定 | 按版本核对编译结果 |
// 零分配的安全替代:需要多次使用时用 unsafe,但先问是否真的必要
b := []byte(s) // 一次复制,换来可读与安全
编译器能省掉的是"临时转换",省不掉的是"你需要一份独立数据"。不要为了省一次复制引入 unsafe——除非 -benchmem 证明它是热点。
8. sync.Pool 的适用面
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func handle() {
b := bufPool.Get().(*bytes.Buffer)
defer func() { b.Reset(); bufPool.Put(b) }() // 必须 Reset:清掉状态
b.WriteString("...")
}
| 适用 | 不适用 |
|---|---|
| 短生命周期的临时缓冲 | 有状态、需要精确生命周期的对象 |
| 高频创建销毁的固定结构 | 连接、事务、会话 |
| 大小相对稳定的对象 | 大对象(占着不放反而浪费) |
sync.Pool 的两个"不保证"必须记住:GC 时池可能被清空;Get 不保证返回你 Put 的那个对象。因此它只适合"没有则新建、有则复用"的纯优化场景,任何把正确性建立在池命中上的代码都是错的。
9. 观测:MemStats 与 pprof
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
log.Printf("HeapAlloc=%d HeapInuse=%d HeapObjects=%d NumGC=%d",
ms.HeapAlloc, ms.HeapInuse, ms.HeapObjects, ms.NumGC)
| 指标 | 含义 | 用途 |
|---|---|---|
HeapAlloc |
当前存活对象占用 | 真实内存压力 |
HeapInuse |
已向 OS 申请并使用的 span | 含未回收空间 |
HeapObjects |
存活对象数 | 判断"少量大对象"还是"海量小对象" |
NumGC |
GC 次数 | 斜率反映分配速率 |
Mallocs/Frees |
累计分配/释放次数 | 定位分配热点 |
$ go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
$ go tool pprof -sample_index=alloc_space app mem.pprof # 看累计分配,抓热点
$ go tool pprof -sample_index=inuse_space app mem.pprof # 看当前占用,抓泄漏
两个 sample_index 的区别是排查方向的分水岭:alloc_space 回答"谁分配得多"(优化目标),inuse_space 回答"谁占着不放"(泄漏目标)。用错方向会得出完全错误的结论。
10. 排错表:常见坑
| 坑 | 表现 | 修法 |
|---|---|---|
| 以为逃逸分析能优化掉所有堆分配 | 手写 &T{} 仍上堆 |
看 -gcflags='-m',按判定条件改结构 |
用 sync.Pool 缓存有状态对象 |
状态串线、偶发错误 | 只池化无状态缓冲,Put 前 Reset |
map 删除大量元素后内存不降 |
桶未收缩 | 重建 map,或按版本核对回收行为 |
切片反复 append 无预分配 |
大量复制、GC 抖动 | make([]T, 0, n) |
| 返回大数组的小切片 | inuse_space 高、对象数少 |
copy 裁剪 |
GOMEMLIMIT 设成等于容器 limit |
GC 死亡螺旋 | 留 10%–20% 余量 |
11. 边界表
| 场景 | 该用的手段 | 理由 |
|---|---|---|
| 分配热点定位 | pprof -sample_index=alloc_space |
累计分配才是优化目标 |
| 泄漏定位 | inuse_space + 支配树 |
占用与引用链 |
| 容器内存受限 | GOMEMLIMIT |
绝对值上限才对应容器 limit |
| 判断某变量是否上堆 | -gcflags='-m' |
唯一权威来源 |
| 高频临时对象 | sync.Pool |
但需接受"可能被清空" |
| 大数组传小片段 | copy/slices.Clone |
切断底层数组引用 |
一句话:GC 只回收没人引用的对象,它管不了"你还引用着不该引用的东西"。绝大多数 Go 内存问题,答案在引用关系里,不在 GC 参数里。
12. 自测
- 三色标记里写屏障解决什么问题?不加会误回收哪类对象?
GOGC与GOMEMLIMIT的触发条件分别是什么?为什么后者是软上限?- 列出三种会导致变量逃逸到堆的写法,以及如何在编译输出里确认。
s = big[:n]为什么会泄漏?两种修法各有什么代价?sync.Pool的两个"不保证"是什么?为什么池化有状态对象是错的?alloc_space与inuse_space分别回答什么问题?