一个变量放在栈上还是堆上,不由 new 或者 &T{} 这样的写法决定,而是由编译器的逃逸分析决定。这篇先读 -m 的诊断,再用 AllocsPerRun 数出真实的分配次数,发现两者并不总是一致;然后量出栈上限、对照内联的影响,最后量一量逃逸到底多花多少。
源码(省略 main):
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | )
7 |
8 | type Point struct{ X, Y int }
9 |
10 | //go:noinline
11 | func byValue() int { p := Point{1, 2}; return p.X + p.Y }
12 |
13 | //go:noinline
14 | func returnPtr() *Point { p := Point{1, 2}; return &p }
15 |
16 | //go:noinline
17 | func toInterface() { p := Point{1, 2}; fmt.Fprintln(io.Discard, p) }
18 |
19 | //go:noinline
20 | func smallMake() int { s := make([]int, 10); s[0] = 1; return s[0] }
21 |
22 | //go:noinline
23 | func varMake(n int) int { s := make([]int, n); s[0] = 1; return s[0] }
24 |
25 | //go:noinline
26 | func closure() func() int { x := 0; return func() int { x++; return x } }
27 |
28 | //go:noinline
29 | func make64k() int { s := make([]int, 8000); s[0] = 1; return s[0] }
30 |
31 | //go:noinline
32 | func make65k() int { s := make([]int, 8200); s[0] = 1; return s[0] }
33 |
34 | //go:noinline
35 | func arr8M() int { var a [1 << 20]int; a[0] = 1; return a[0] }
36 |
37 | func main() {
38 | fmt.Println(byValue(), returnPtr().X, smallMake(), varMake(10), closure()(), make64k(), make65k(), arr8M())
39 | toInterface()
编译器诊断(只列 11–38 行的函数体,省略 main 里的):
main.go:14:27: moved to heap: p ← func returnPtr() *Point { p := Point{1, 2}; return &p }
main.go:17:52: ... argument does not escape ← func toInterface() { p := Point{1, 2}; fmt.Fprintln(io.Discard, p) }
main.go:17:65: p escapes to heap ← func toInterface() { p := Point{1, 2}; fmt.Fprintln(io.Discard, p) }
main.go:20:33: make([]int, 10) does not escape ← func smallMake() int { s := make([]int, 10); s[0] = 1; return s[0] }
main.go:23:36: make([]int, n) does not escape ← func varMake(n int) int { s := make([]int, n); s[0] = 1; return s[0] }
main.go:26:29: moved to heap: x ← func closure() func() int { x := 0; return func() int { x++; return x } }
main.go:26:44: func literal escapes to heap ← func closure() func() int { x := 0; return func() int { x++; return x } }
main.go:29:31: make([]int, 8000) does not escape ← func make64k() int { s := make([]int, 8000); s[0] = 1; return s[0] }
main.go:32:31: make([]int, 8200) escapes to heap ← func make65k() int { s := make([]int, 8200); s[0] = 1; return s[0] }
main.go:35:24: moved to heap: a ← func arr8M() int { var a [1 << 20]int; a[0] = 1; return a[0] }
运行 rc=0,输出:3 1 1 1 1 1 1 1编译器的措辞有三种:moved to heap: p 说的是变量 p 本身被搬到堆上;… escapes to heap 说的是某个表达式的值(make 的结果、转成接口的值、函数字面量)要放在堆上;does not escape 是留在栈上。第 17 行的 p escapes to heap 说的是“把 p 转成接口这一步”,它并不等于运行时一定有一次分配,下一节会看到。
byValue 0 次/调用
returnPtr 1 次/调用
toInterface(常量 Point{1,2}) 0 次/调用
ifacePoint(n=1000) Point{n,2} 1 次/调用
ifaceInt(n=7) 0 次/调用
ifaceInt(n=255) 0 次/调用
ifaceInt(n=256) 1 次/调用
ifaceInt(n=1000) 1 次/调用
smallMake make(10) 0 次/调用
staticN n:=10; make(n) 0 次/调用
staticBig n:=1000; make(n) 0 次/调用
closure(返回闭包) 2 次/调用
make64k make(8000) 0 次/调用
make65k make(8200) 1 次/调用
arr8M [1<<20]int 1 次/调用
varMake n=1(8 字节) 0 次/调用
varMake n=2(16 字节) 0 次/调用
varMake n=4(32 字节) 0 次/调用
varMake n=5(40 字节) 1 次/调用
varMake n=8(64 字节) 1 次/调用
varMake n=16(128 字节) 1 次/调用
varMake n=64(512 字节) 1 次/调用
varMake n=1000(8000 字节) 1 次/调用
varMake n=8192(65536 字节) 1 次/调用
varMake n=8193(65544 字节) 1 次/调用
同一份代码加 -gcflags=-d=variablemakethreshold=1024(把变长 make 的栈缓冲从 32 字节调到 1024 字节),只看 varMake:
varMake n=1(8 字节) 0 次/调用
varMake n=2(16 字节) 0 次/调用
varMake n=4(32 字节) 0 次/调用
varMake n=5(40 字节) 0 次/调用
varMake n=8(64 字节) 0 次/调用
varMake n=16(128 字节) 0 次/调用
varMake n=64(512 字节) 0 次/调用
varMake n=1000(8000 字节) 1 次/调用
varMake n=8192(65536 字节) 1 次/调用
varMake n=8193(65544 字节) 1 次/调用
同一份代码加 -gcflags=-N(关闭优化),只看 varMake 与 smallMake:
smallMake make(10) 0 次/调用
staticN n:=10; make(n) 0 次/调用
varMake n=1(8 字节) 1 次/调用
varMake n=4(32 字节) 1 次/调用三个出乎意料的结果。第一,toInterface 传的是常量 Point{1,2},编译器直接指向只读数据,0 次分配;换成运行时才知道的值就是 1 次。我在编译器源码 walk/convert.go 里读到同一个函数的几个分支:零大小用 zerobase、单字节或布尔用 staticuint64s 表、只读全局直接用,最后才调用运行时分配。int 的 255 / 256 分界来自 runtime/iface.go 的 convT64:值小于 len(staticuint64s)(256)时直接取表项,否则调 mallocgc,实测与之一致。第二,变长 make([]int, n) 的诊断是 does not escape,但并不保证零分配:源码(walk/builtin.go 的 walkMakeSlice 与 base/flag.go 里的 VariableMakeThreshold = 32)写的是“如果 cap ≤ K,就用栈上的 [K]E,否则调 makeslice”,K 由 32 字节除以元素大小得到——int 是 8 字节,所以 K = 4,实测正是 n=4 零分配、n=5 一次分配。第三,-d=variablemakethreshold=1024 把阈值调大后 n=64 也是零分配,-N 关闭优化后变长 make 就回到一次分配,而常量长度的 make(10) 在 -N 下仍是零。
var a [N]byte(显式声明的局部数组):131072 字节仍在栈上,131073 字节被放到堆上(= 128 KiB + 0 字节为界)
make([]byte, N)(常量长度):65536 字节仍在栈上,65537 字节被放到堆上(= 64 KiB + 0 字节为界)
new([N]byte):65536 字节仍在栈上,65537 字节被放到堆上(= 64 KiB + 0 字节为界)
&[N]byte{}(取地址的复合字面量):65536 字节仍在栈上,65537 字节被放到堆上(= 64 KiB + 0 字节为界)数字和源码里的两个常量对得上:ir/cfg.go 里 MaxStackVarSize 是 128 KiB(显式的 var x T),MaxImplicitStackVarSize 是 64 KiB(new(T)、&T{}、make([]T, n))。二分法的结果是恰好以字节为界:131072 与 65536 在栈上,多一个字节就去了堆。注意这里是 arm64 上 Go 1.27.1 的数字,其它版本的上限可能不同。
returnPtr(第 14 行 p):
main.go:14:27: p escapes to heap in returnPtr:
main.go:14:27: flow: ~r0 ← &p:
main.go:14:27: from &p (address-of) at main.go:14:52
main.go:14:27: from return &p (return) at main.go:14:45
main.go:14:27: moved to heap: p
toInterface(第 17 行 p):
main.go:17:65: p escapes to heap in toInterface:
main.go:17:65: flow: {storage for ... argument} ← &{storage for p}:
main.go:17:65: from p (spill) at main.go:17:65
main.go:17:65: from ... argument (slice-literal-element) at main.go:17:52
main.go:17:65: flow: {heap} ← {storage for ... argument}:
main.go:17:65: from ... argument (spill) at main.go:17:52
main.go:17:65: from fmt.Fprintln(io.Discard, ... argument...) (call parameter) at main.go:17:52
closure(第 26 行 x):
main.go:26:29: x escapes to heap in closure:
main.go:26:29: flow: {storage for func literal} ← &x:
main.go:26:29: from x (captured by a closure) at main.go:26:57
main.go:26:29: from x (reference) at main.go:26:57
main.go:26:29: moved to heap: x
make65k(第 32 行):
main.go:32:31: make([]int, 8200) escapes to heap in make65k:
main.go:32:31: flow: {heap} ← &{storage for make([]int, 8200)}:
main.go:32:31: from make([]int, 8200) (too large for stack) at main.go:32:31-m -m 的 flow 链从右往左读:flow: ~r0 ← &p 是“返回值 ~r0 取得了 p 的地址”,所以 p 的生命周期要超过函数。接口参数那条链里出现了 {heap},意思是它流进了编译器无法证明不逃逸的调用(fmt.Fprintln 的 ...any 参数)。too large for stack 是大小原因,不涉及数据流。看到 moved to heap 时,往上找 flow 链里第一个“address-of”和最后一个流向,就能定位是哪一行造成的。
默认(newPoint 被内联进 sum):
main.go:10:6: can inline newPoint ← func newPoint() *Point { return &Point{1, 2} }
main.go:13:31: inlining call to newPoint ← func sum() int { p := newPoint(); return p.X + p.Y }
main.go:10:33: &Point{...} escapes to heap ← func newPoint() *Point { return &Point{1, 2} }
main.go:13:31: &Point{...} does not escape ← func sum() int { p := newPoint(); return p.X + p.Y }
sum() 每次调用的分配次数:0
-gcflags=-l(关闭内联):
main.go:10:33: &Point{...} escapes to heap ← func newPoint() *Point { return &Point{1, 2} }
sum() 每次调用的分配次数:1逃逸分析是在函数边界上做的。newPoint 返回指针,在它自己的诊断里永远逃逸;但被内联进 sum 之后,指针没有离开 sum,这个值就留在了栈上。所以排查分配问题时,-gcflags=-l 能帮你分辨“这次分配是不是只因为某个调用没被内联”。
byValue(栈) 500 万次:耗时中位 10.1 ms(最小 9.7 / 最大 12.9);Mallocs +2,TotalAlloc +80 字节,GC 次数 +0 returnPtr(堆) 500 万次:耗时中位 33.3 ms(最小 33.0 / 最大 33.8);Mallocs +5000003,TotalAlloc +80000288 字节,GC 次数 +21
代价有两层:每次调用多一次 mallocgc(这里 16 字节),以及这些堆对象带来的 GC 工作。上面的 33.3 ms 里包含了这 21 次 GC 的成本,不能全算成分配本身。byValue 每次调用约 2 纳秒(10.1 ms / 500 万),这是 //go:noinline 函数调用本身的量级;不要把“3.3 倍”套到别的程序上,它只说明在这个最小例子里,逃逸把一次几乎没有成本的调用变成了一次分配加回收。