15.3 内存分配与逃逸分析

一句话

Go 的分配器是 TCMalloc 式三级结构(mcache → mcentral → mspan),按 size class 切块以避免碎片;而 “变量到底放栈还是堆” 由编译期逃逸分析决定——想少 GC,先让变量别逃逸。

三级分配

层 归谁 作用
mcache 每个 P 一份 无锁分配(最热路径)
mcentral 全局,按 size class 分 给 mcache 补货
mheap / mspan 全局 向 OS 要页(8KB 起)

对象按大小分成约 70 档 size class(8B–32KB),小对象还有 tiny allocator 合并(多个无指针小对象塞进一个块)。

逃逸规则(编译器视角)

写法 逃逸到堆? 原因
返回局部变量的指针 ✅ 函数返回后栈失效
赋给 interface{} / 接口方法 ✅(常见) 逃逸分析难以证明
闭包捕获并在外部使用 ✅ 生命周期超出栈帧
发送到 channel ✅ 可能被其他 goroutine 长期持有
超过栈容量的大对象 ✅ 栈放不下
仅函数内使用的值 ❌ 留在栈上,随栈回收
go build -gcflags='-m' ./...      # 打印逃逸分析结果
go build -gcflags='-m=2' ./...    # 更详细(含原因)

观测分配

想看什么 手段
谁在分配 pprof 的 alloc_objects / alloc_space
谁在占用 inuse_space(存活对象)
分配速率 runtime/metrics 的 /gc/heap/allocs:bytes

栈的增长与复制机制见 15.2 goroutine 与栈管理;分配与 GC 的联动见 15.4 GC 原理与调优 (GOGC / GOMEMLIMIT)。

落到数字:分配的三条路径

路径 成本量级 触发条件
栈分配 ~1ns 未逃逸
mcache 分配(小对象) 10–20ns 逃逸但命中本地缓存
mcentral/mheap 补货 100ns+ 缓存空或大对象
GC 扫描成本 与存活数量成正比 堆越大越贵
go build -gcflags='-m=2' ./... 2>&1 | grep -E "escapes to heap|moved to heap" | head -10
go test -run=^$ -bench=. -benchmem   # 看 B/op 与 allocs/op

反模式

反模式 后果
为 “减少分配” 到处上 sync.Pool 复杂度上升,且池化对象的生命周期难控
返回结构体指针 “省拷贝” 逃逸到堆,GC 压力反而更大
用 interface{} 做通用容器 装箱逃逸 + 类型断言开销
热路径里拼接字符串 每次生成新对象
用 []byte 反复扩容 多次拷贝(预分配 make([]byte, 0, n))

三条铁律

🔑 栈分配最便宜:能留在栈上就别让它逃逸。

🔑 先看 alloc_space 与分配速率,再决定优化哪一行。

🔑 sync.Pool 只给高频、短命、可重置的对象;它不是万能缓存。

自检

Q1 为什么 return &T{} 会让对象逃逸?

▸ 答案:因为函数返回后栈帧被回收,指针若指向栈内存就成了悬垂指针。编译器因此把 T 分配到堆,由 GC 管理生命周期。这是 “返回指针避免拷贝” 的隐性成本:一次堆分配 + 后续 GC 扫描。

Q2 什么情况下 sync.Pool 反而会拖慢性能?

▸ 答案:当对象很小、分配本来就便宜,或命中率低(每核缓存被 GC 清空)时:池的 Get/Put 有原子操作与边界检查开销,还要维护 “重置” 逻辑。判断标准是 profile 显示该路径分配占比高,且对象较大或有初始化成本(如 bytes.Buffer)。

Q3 为什么 fmt.Sprintf 在热路径里是性能杀手?

▸ 答案:它内部要解析格式串、反射取值、分配并拼接字符串,通常产生多次分配([]interface{} 参数 + 结果字符串)。热路径应改用 strconv + 预分配 []byte,或用结构化日志库的零分配 API。

Q4 怎样快速定位 “谁分配得最多”?

▸ 答案:用 pprof 的 heap profile 看 alloc_space(累计分配量)与 alloc_objects(次数),按函数排序;再结合 -gcflags=-m 确认是否是逃逸导致。注意区分 alloc_*(累计)与 inuse_*(当前存活)——前者高说明分配频繁,后者高才是内存占用大。

Q5 make([]byte, 0, 1024) 与 make([]byte, 1024) 有什么区别?

▸ 答案:前者长度 0、容量 1024,追加时不会立即扩容;后者长度 1024(零值填充)且容量 1024。预分配容量能避免多次扩容拷贝(扩容按倍数增长,复制旧数据),在已知大小时应显式给容量。

Q6 benchmem 显示 allocs/op 高但 B/op 低,优先优化哪个?

▸ 答案:先降 allocs/op:分配次数多意味着 GC 扫描与分配器路径开销高,即使字节数不大。常见来源是小对象装箱、字符串拼接、闭包捕获。次数降下来后,再看字节数。

关联