跳到正文

08 内存逃逸 ​

一句话:逃逸分析(escape analysis)是编译器在编译期判断变量能否留在栈上;只要它无法证明变量不会活过当前栈帧,就把变量搬到堆上,代价是分配开销和 GC 压力。

适用 Go 1.22+(1.14 起开放编码 defer 减少了 defer 记录本身的堆分配)。逃逸判定属于编译器实现细节,不同版本输出可能变化;栈和堆看完可以接 09 垃圾回收。

为什么需要 ​

栈分配几乎零成本:函数返回时整帧回收,不需要 GC 参与。堆分配要走 mallocgc、维护对象头、参与标记清扫。Go 不允许程序员手动选择栈或堆,只能由编译器判断,所以理解判定规则才能解释「为什么这个循环分配这么多」。

编译器必须保守:它能证明变量不逃逸时才放栈上;证明不了就放堆上。这是「安全优先」而不是「性能优先」的默认。

核心机制 ​

变量的生命周期超过当前函数栈帧,就叫逃逸。编译器在编译期做过程内和跨函数分析:函数参数带 leaking 摘要,内联后摘要会合并,所以同一个函数,内联与否可能得到不同结论。

常见判定:

写法结果原因
return &x堆调用方持有指针,生命周期超过当前帧
return x(值)栈返回值被复制,原变量随帧销毁
赋给全局变量或堆对象的字段堆引用被生命周期更长的对象持有
通过 channel 发送指针堆接收方的生命周期编译器看不到
被 goroutine 或逃逸闭包捕获堆闭包可能晚于当前函数执行
转成 any / 非空接口常常堆装箱(boxing),接口持有数据指针
传给参数会泄漏的函数堆跨函数 leaking param 摘要传播
使用 reflect、unsafe.Pointer堆分析无法跟踪,直接放弃
超大局部变量堆超过编译器栈变量阈值(显式变量约 10 MB,隐式变量约 64 KB,属实现细节)
make([]T, n),n 非常量底层数组堆大小在运行期才知道;常量且不逃逸时可能栈分配
make(map[K]V)堆map 本身是指向 hmap 的指针
new(T) 且指针逃逸堆new 只分配零值;是否逃逸看指针去向
string([]byte) / []byte(string)通常分配转换会产生新底层数组,少数场景可被优化

new(T) 和 make(T, ...) 的区别也在这里:new 只分配零值内存并返回指针,make 还要初始化 slice/map/channel 的运行时结构(底层数组、hmap、hchan)。是否真的落到堆上,仍由逃逸分析决定。

defer 闭包捕获的变量会随闭包逃逸;Go 1.14+ 的开放编码 defer 在多数场景不再为 defer 记录本身分配堆内存。defer 的参数在注册时求值、执行顺序后进先出,命名返回值可被 defer 改写。

最小上手 ​

go
package main

var global *int

func retPtr() *int { x := 1; return &x }   // 逃逸:指针返回
func local() int   { x := 1; return x }    // 不逃逸
func box() any     { x := 1000; return x } // 逃逸:装箱
func sink(p *int)  { global = p }          // p 泄漏到全局

func closure() func() int {
	x := 1
	return func() int { return x }         // 逃逸:闭包捕获
}

func main() {
	_ = retPtr()
	_ = local()
	_ = box()
	y := 2
	sink(&y)
	_ = closure()
}
bash
mkdir -p /tmp/escapedemo && cd /tmp/escapedemo
go mod init escapedemo
# 保存上面的 main.go 后
go build -gcflags="-m" .          # 基础判定
go build -gcflags="-m -m" .       # 追加原因
go build -gcflags="all=-m" .      # 包含依赖包
go build -gcflags="-m -l" .       # 关闭内联,隔离内联影响

输出类似(行号省略,措辞随版本变化,关键词稳定):

moved to heap: x
x does not escape
x escapes to heap
leaking param: p

看到 escapes to heap / moved to heap 就是堆分配;does not escape 是留在栈上;leaking param 说明这个参数会把引用交给调用方或更长寿的对象。

用基准测试量化,而不是只数 -m 行。新增同目录的 main_test.go:

go
package main

import "testing"

var benchmarkSink any

func BenchmarkBox(b *testing.B) {
	for i := 0; i < b.N; i++ {
		benchmarkSink = box()
	}
}
bash
go test -bench=. -benchmem    # 看 ns/op、B/op、allocs/op

生产动作 ​

  • 优先改热路径:先看 go tool pprof -sample_index=alloc_space 的分配点,再改。冷路径上一次逃逸不值得牺牲可读性。
  • 常见改法:
    • 小结构体返回值而非指针,减少堆对象;但复制成本高时仍应返回指针,用基准判断。
    • make([]T, 0, n) 预分配容量,避免扩容时的多次分配和复制。
    • 热循环里避免把值装箱成 any;用泛型或具体类型。
    • 复用缓冲区用 sync.Pool、strings.Builder、bytes.Buffer;Pool 中的对象可能被 GC 清掉,不能当缓存。
    • 流式处理代替 io.ReadAll,避免一次性大分配。
    • 闭包只捕获必要字段,不要把整个大结构体拖进闭包。
  • 验证顺序:-benchmem 或 testing.AllocsPerRun 看 allocs/op、B/op → pprof -alloc_space 看分配点 → gctrace 看 GC 压力是否下降。用 benchstat 比较多组结果,避免把噪声当优化。
  • 不要把 -m 输出写进 CI 断言:措辞和判定会随 Go 版本变化,稳定的是基准数字。

边界 ​

  • 逃逸不是泄漏:逃逸是正常的、必要的堆分配;内存泄漏是不可达对象仍被引用、无法回收。两者排查方法不同。
  • 堆分配不等于慢:小对象分配走 per-P 缓存,成本不高;为了「零逃逸」把代码改得难读通常不划算。
  • 结论依赖版本和编译选项:内联、-l、-N、跨包分析都会改变结果;不要拿旧版本的 -m 输出当保证。
  • sync.Pool 不是对象缓存:GC 时可能清空,取出的对象状态不可假设,Put 前要 Reset。
  • 反射、unsafe、cgo 会让分析失效:这些路径上的分配要单独用 profile 看。
  • 栈也会增长:避免堆分配不等于避免成本,深递归和大栈帧会触发栈复制;大对象即使放栈上也可能不划算。

讲清楚 ​

逃逸分析是编译器决定变量放栈还是放堆的机制。栈帧随函数返回整体回收,堆对象要参与 GC,所以能放栈就放栈。编译器很保守:返回指针、存进全局、通过 channel 发送、被 goroutine 或闭包捕获、装箱成接口、传给会泄漏参数的函数,这些都会让变量逃逸;反射和 unsafe 会让分析直接放弃。验证方法是 go build -gcflags="-m" 看 escapes to heap,再用 -benchmem 和 pprof -alloc_space 量化。

它的边界也要记住:逃逸不是内存泄漏,堆分配也不等于慢。优化应该从 profile 里的热路径开始,而不是追求全程序零逃逸;编译器判定还随版本和内联变化,不能当成稳定契约。

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