值放在栈上还是堆上,Go 替你决定。本文讲逃逸分析怎样做这个决定,怎样用 go build -gcflags=-m 查看它,怎样统计分配次数,以及垃圾回收器随后做什么。
在 Go 里,你从来不用写“把这个放到堆上”。你写的是普通代码,由编译器决定每个值放在哪里。大多数时候你不用关心。但当性能分析显示某个热循环在分配内存时,你就需要知道这个决定是怎么做出的,以及怎样看到它。
本文讲栈、堆、逃逸分析、统计分配次数,以及负责清理堆的垃圾回收器。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。凡是来自编译器的说法,也一并贴出编译器自己的输出。
每次调用都在栈上得到一个帧
调用 Go 函数时,它会得到一个帧(frame):一块内存,存放它的局部变量和一些记账信息。函数返回时,帧被弹出,这块内存由下一次调用接着用。每个 goroutine 都有自己的一摞帧,也就是自己的栈。
goroutine 的栈一开始很小,需要时再增长。你可以问运行时新栈的初始大小是多少,然后递归到远超这个大小所能容纳的深度:
package main
import (
"fmt"
"runtime/metrics"
)
func depth(n int) int {
if n == 0 {
return 0
}
return 1 + depth(n-1)
}
func main() {
s := []metrics.Sample{{Name: "/gc/stack/starting-size:bytes"}}
metrics.Read(s)
fmt.Println("new goroutine stacks start at", s[0].Value.Uint64(), "bytes")
fmt.Println(depth(1_000_000))
}
输出:
new goroutine stacks start at 2048 bytes
1000000
一百万层嵌套调用不可能塞进 2,048 字节,所以栈增长了。运行时的源码描述了做法:函数需要的栈空间超过剩余空间时,运行时分配一个更大的栈,把旧栈整个复制过去,然后继续执行。垃圾回收期间,它还可以再把栈缩小。2,048 是这次在 Linux 上运行报告的数字。运行时还会根据 goroutine 实际用了多少栈,逐渐调整初始大小,所以长时间运行的程序可能报告更大的数字。
增长有上限。runtime/debug.SetMaxStack 的文档说,在 64 位系统上,单个 goroutine 的栈最多可以长到 1 GB。无限递归会撞上这个上限,以栈溢出崩溃。
堆存放活得比调用更久的值
有些值在创建它的函数返回之后还得活着。函数一返回,帧就没了,所以这些值不能放在帧里。它们放在堆上。堆是一块共享的内存区域,不属于任何一次调用。当没有东西再引用堆上的内存时,垃圾回收器会把它释放。
用十岁孩子能懂的话说
栈是你桌上的一摞便签本。每开始一件事,你就在最上面放一本新的,在上面写写画画。事情做完,就把这本扔掉。事情里套着事情,就再加一本。这样很快,收拾起来也不费劲:你永远只扔最上面那本。
堆是走廊尽头的公共储藏室。如果你做出的东西在你忙完之后别人还要用,就不能把它留在便签本上,因为便签本要进垃圾桶。于是你把它装进箱子放到储藏室,再递给对方一张标签,写着它在哪个架子上。
每隔一段时间,清洁工会在储藏室里走一圈。任何没有标签指向的箱子,都会被扔掉。
准确的说法
栈帧在函数被调用时创建,在函数返回时丢弃。在帧里分配几乎没有开销,之后也不需要任何东西跟踪它。
堆分配要向运行时申请内存。只要还有指针能到达这块内存,它就一直有效。之后,垃圾回收器(GC)会找出没有指针能到达的堆对象,并重新利用它们的内存。所以堆上的值要付两次代价:一次是分配,另一次是给 GC 增加的工作。
这个比喻的局限:哪些值放进储藏室,不由你决定。决定的是编译器,在构建程序时就定了,下一节会演示怎么看到。而且清洁工也不是在你放下箱子时逐张读标签。它会从还在桌上的便签本出发,走遍所有能到达的东西,后面讲 GC 的部分会解释具体怎么做。
不由你选:逃逸分析说了算
Go 没有“new 或 & 就意味着堆”这样的规则。Go 语言规范根本没有用到栈和堆这两个词。Go FAQ 把承诺说得很直白:只要还有引用指向某个变量,它就一直存在。怎样兑现这个承诺,由编译器决定。
编译器靠逃逸分析来兑现承诺。对每个值,它都会问:函数返回之后,这个值还能被访问到吗?如果无法证明答案是否定的,这个值就逃逸了,编译器把它放到堆上。否则,编译器可以把它留在帧里。
所以在 Go 里返回局部变量的指针是安全的。同样的代码在 C 里,返回的是一个已经不存在的栈帧的地址。
package main
import "fmt"
type point struct{ x, y int }
func sum() int {
p := point{1, 2}
return p.x + p.y
}
func newPoint() *point {
p := point{3, 4}
return &p
}
func five() int {
n := new(int)
*n = 5
return *n
}
func main() {
fmt.Println(sum(), newPoint().y, five())
}
输出:
3 4 5
想看编译器的决定,就把它存为某个模块里的 main.go,用 -gcflags=-m 构建。额外的 -l 会关闭内联,这样每个函数都按原样分析:
$ go build -gcflags='-m -l' .
# example.com/escape
./main.go:13:2: moved to heap: p
./main.go:18:10: new(int) does not escape
./main.go:24:13: ... argument does not escape
./main.go:24:17: sum() escapes to heap
./main.go:24:31: newPoint().y escapes to heap
./main.go:24:39: five() escapes to heap
第 13 行是 newPoint 里的 p。它的地址被返回了,所以必须活得比调用更久,编译器把这个变量“moved to heap”(移到了堆上)。第 18 行是 five 里的 new(int):它“does not escape”(没有逃逸),因为指针从没离开过函数。new 并没有强制在堆上分配。sum 里的 p 根本没被提到,因为没有任何地方取它的地址。
最后四行说的是对 fmt.Println 的调用。下面讲接口的那一节会解释它们。
用 testing.AllocsPerRun 统计分配次数
-m 的输出告诉你编译器做了什么决定。想确认代码运行时实际发生了什么,就要统计分配次数。testing.AllocsPerRun 会多次调用一个函数,返回平均每次调用发生几次堆分配。在普通的 package main 里也能导入 testing:
package main
import (
"fmt"
"testing"
)
type point struct{ x, y int }
func newPoint(x, y int) *point {
return &point{x, y}
}
var saved *point
func main() {
dropped := testing.AllocsPerRun(1000, func() {
p := newPoint(3, 4)
_ = p.x + p.y
})
kept := testing.AllocsPerRun(1000, func() {
saved = newPoint(3, 4)
})
fmt.Println("used, then dropped:", dropped)
fmt.Println("kept in a global: ", kept)
}
输出:
used, then dropped: 0
kept in a global: 1
对这种简单代码,每次运行得到的次数都一样,这点和计时不同。
同一个函数,在一处分配了一次,在另一处完全没有分配。用普通的 -m 构建(保留内联),再找 point:
$ go build -gcflags=-m . 2>&1 | grep point
./main.go:11:9: &point{...} escapes to heap
./main.go:18:16: &point{...} does not escape
./main.go:22:19: &point{...} escapes to heap
第 11 行是单独的 newPoint,指针被返回,于是逃逸。但 newPoint 很小,所以编译器把它内联了:把函数体复制到每个调用方里,再在那里重新做一遍逃逸分析。第 18 行的调用方只读取 p 然后丢掉,所以这个 point 留在栈上。第 22 行的调用方把它存进全局变量,所以它逃逸了。
一个值逃不逃逸,既取决于函数本身,也取决于周围的代码。所以要测量,而不是猜。
值逃逸的四个常见原因
你会遇到的逃逸,大多来自少数几种模式。返回指针是第一种,你已经见过了。下面是另外三种。
把值存进接口
fmt.Println 以 ...any 接收参数。把值放进接口可能让它逃逸,因为接口可能需要持有一个指向该值副本的指针,而编译器通常看不到被调用方拿它做了什么。
package main
import (
"fmt"
"io"
"testing"
)
func add(total *int, n int) {
*total += n
}
func show(n int) {
fmt.Fprintln(io.Discard, n)
}
func main() {
n, total := 1000, 0
added := testing.AllocsPerRun(1000, func() {
n++
add(&total, n)
})
shown := testing.AllocsPerRun(1000, func() {
n++
show(n)
})
small := testing.AllocsPerRun(1000, func() {
n++
show(n % 200)
})
fmt.Println("add n to a total:", added)
fmt.Println("print n: ", shown)
fmt.Println("print n % 200: ", small)
}
输出:
add n to a total: 0
print n: 1
print n % 200: 0
编译器的输出和前两行一致:
$ go build -gcflags='-m -l' . 2>&1 | head -4
# example.com/boxing
./main.go:9:10: total does not escape
./main.go:14:14: ... argument does not escape
./main.go:14:27: n escapes to heap
add 接收指针,但从不保存它,所以 total 没有逃逸。在 show 里,n 逃逸进了接口,每次调用打印都要分配一次。
第三行出乎意料。show(n % 200) 走的是同一段代码,却没有分配。运行时预先准备了一张表,存着 0 到 255 的整数(runtime/iface.go 里的 staticuint64s),持有其中某个值的接口直接指向这张表,不用分配。所以 -m 输出里的“escapes to heap”意思是编译器无法排除堆分配,并不保证每次都会分配。
闭包捕获变量
闭包用到外层函数的变量时,会和外层函数共享这个变量,讲函数的那一部分演示过。如果闭包活得比调用更久,这个变量也得活得更久:
package main
import "fmt"
func makeCounter() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
next := makeCounter()
fmt.Println(next(), next(), next())
next = nil
fmt.Println(next == nil)
}
输出:
1 2 3
true
$ go build -gcflags='-m -l' .
# example.com/counter
./main.go:6:2: moved to heap: n
./main.go:7:9: func literal escapes to heap
./main.go:15:13: ... argument does not escape
./main.go:15:18: next() escapes to heap
./main.go:15:26: next() escapes to heap
./main.go:15:34: next() escapes to heap
./main.go:17:13: ... argument does not escape
./main.go:17:19: next == nil escapes to heap
n 被移到了堆上,函数值本身也是(第 7 行的“func literal”,即函数字面量)。其余几行又是 fmt.Println 的参数进入接口。下面是程序运行时的样子:
上面程序里的 makeCounter。它的帧里有 n,但它返回的函数用到了 n,所以 n 放在堆上,挨着那个函数。帧被弹出,main 的 next 指向堆里。一旦 next 被设为 nil,就没有东西能到达那个函数和 n,垃圾回收器会释放它们。
如果动画没有播放,下面用文字把这几步再说一遍:
main调用makeCounter。栈上压入一个新帧,按理说n = 0应该放在这里。makeCounter返回的函数用到了n,所以n必须活得比帧更久。它放在堆上,挨着函数值。makeCounter返回,帧被弹出。main里的next指向堆上的函数,函数又指向n。- 三次调用
next(),每次都给同一个n加一,现在它是 3。 next = nil。栈上再也没有东西指向那个函数或n。- GC 下次运行时到达不了它们,于是它们的内存被释放。
图里 n 在移动,但运行时其实什么都没移动。决定是编译器在构建程序时做的,所以 makeCounter 从第一行起就在堆上创建 n。“moved to heap”是编译器的说法,意思是“这个局部变量在帧里没有位置”。
-l 标志在这里也很关键。开启内联时,makeCounter 会被内联进 main,编译器会报告那份闭包副本没有逃逸。动画里的分析针对的是按原样编写的 makeCounter。
切片太大,或大小未知
如果编译器知道切片底层数组的大小,而且大小适中,底层数组就可以放在帧里。多大算适中,是编译器的设置,不是语言规则:
package main
import (
"fmt"
"testing"
)
func fixed8192() int {
buf := make([]int, 8192)
return len(buf)
}
func fixed8193() int {
buf := make([]int, 8193)
return len(buf)
}
func sized(n int) int {
buf := make([]int, n)
return len(buf)
}
func main() {
fmt.Println(testing.AllocsPerRun(100, func() { fixed8192() }))
fmt.Println(testing.AllocsPerRun(100, func() { fixed8193() }))
for _, n := range []int{4, 5} {
fmt.Println(testing.AllocsPerRun(100, func() { sized(n) }))
}
}
输出:
0
1
0
1
$ go build -gcflags='-m -l' . 2>&1 | grep make
./main.go:9:13: make([]int, 8192) does not escape
./main.go:14:13: make([]int, 8193) escapes to heap
./main.go:19:13: make([]int, n) does not escape
8,192 个 int 是 64 KiB,正好是编译器源码里隐式栈变量的上限(cmd/compile/internal/ir/cfg.go 里的 MaxImplicitStackVarSize)。多一个 int,数组就去了堆上,哪怕它从没离开过函数。
make([]int, n) 是第二个意外。编译器说它“does not escape”,可 sized(5) 却分配了。编译器在帧里预留一块固定的小缓冲区,运行时检查 n。切片放得下,就用这块缓冲区;放不下,就在堆上分配。默认缓冲区是 32 字节,在 cmd/compile/internal/base/flag.go 里设定,正好放四个 int。所以 4 放得下,5 放不下。这些阈值是编译器的实现细节,不同版本之间可能会变。
垃圾回收器:先标记,再清扫
Go 的垃圾回收器释放没有东西能到达的堆内存。按 runtime/mgc.go 开头的注释,它是并发标记-清扫(concurrent mark and sweep)回收器。它不移动对象,也不把堆分代。
一轮回收有两项主要工作。标记从根出发,根是每个 goroutine 的栈和全局变量。它顺着每个指针走,给到达的每个对象做标记。清扫随后遍历堆,没被标记的对象,内存就可以重新使用。两项工作都和你的程序同时进行。运行时只在两个时刻暂停所有 goroutine:标记开始时和标记结束时。
Go 1.26 默认启用了重新设计的标记实现,名叫 Green Tea。它改变的是回收器遍历内存的方式,下面讲的标记-清扫思路没变。
用十岁孩子能懂的话说
储藏室里的每个箱子一开始都贴着白色贴纸,意思是“还没人检查过”。
清洁工从桌子开始。便签本指向的每个箱子都贴上灰色贴纸:“找到了,但还没看里面”。
然后清洁工反复做同一个动作:挑一个灰箱子,打开,给它里面标签指向的每个箱子贴上灰色贴纸。再把这个箱子的贴纸换成黑色:“找到了,也检查完了”。
等灰箱子一个都不剩,所有能被人找到的箱子都是黑的。还贴着白色贴纸的箱子,谁都够不着,于是和垃圾一起扔掉。
准确的说法
这就是三色标记。白色对象还没被到达,灰色对象已被到达但还没扫描,黑色对象已扫描完。标记开始时,先把根涂成灰色。回收器取出一个灰色对象,把它指向的所有对象涂灰,再把它涂黑。灰色对象没有了,剩下的白色对象就是不可达的,由清扫阶段释放。
因为标记期间你的程序还在运行,它可能在回收器工作时修改指针。比如把一个指向白色对象的指针,存进一个回收器已经处理完的黑色对象里。为了防止这个对象被误释放,运行时在标记期间开启写屏障(write barrier):每次写指针,都会顺带把涉及的指针涂色。标记期间新分配的对象直接标成黑色。
这个比喻的局限:故事里的清洁工一个人干活,其他人都在等。真正的回收器和你的程序同时运行,用多个线程,而且程序自己在分配时也会分担一部分标记工作。箱子上也没有贴纸:标记是运行时在堆旁边另外保存的一些位。
两个旋钮:GOGC 和 GOMEMLIMIT
Go 运行时有两个设置,控制 GC 多久运行一次。两者都是环境变量,runtime/debug 里也各有一个函数,能在程序运行时修改它们:
package main
import (
"fmt"
"math"
"runtime/debug"
)
func main() {
oldPercent := debug.SetGCPercent(100)
oldLimit := debug.SetMemoryLimit(-1)
fmt.Println("GOGC:", oldPercent)
fmt.Println("GOMEMLIMIT is off:", oldLimit == math.MaxInt64)
}
输出:
GOGC: 100
GOMEMLIMIT is off: true
每个 setter 都返回之前的设置,传入负数的内存限制只读取当前限制,不做修改。两个变量都没设置时,程序报告的是默认值。在环境里加上 GOGC=50 GOMEMLIMIT=1GiB 再运行,两行都会变。
GOGC 是一个百分比。运行时文档说,自上次回收以来新分配的内存,达到上次回收后存活数据的这个百分比时,就开始新一轮回收。默认值是 100,所以堆可以长到存活数据的大约两倍,才触发下一轮。值越大,回收越少,占用内存越多;值越小,回收越多,占用内存越少。GOGC=off 会关闭回收器。
GOMEMLIMIT 是对 Go 运行时所管理内存总量的软限制,以字节为单位,可以带 MiB 或 GiB 这样的后缀。程序接近这个限制时,GC 会更频繁地运行,好让内存保持在限制以下。它默认关闭,math.MaxInt64 就是这个意思。SetMemoryLimit 的文档提醒,如果限制低于程序实际需要的内存,GC 可能几乎一刻不停地运行。它是软限制,不是硬上限。
你还可以用 runtime.ReadMemStats 读取内存统计。像 HeapAlloc(当前堆上的字节数)、TotalAlloc(累计分配过的字节数)和 NumGC(已完成的回收轮数)这样的字段,调试时很有用。这里不贴输出,因为每次运行数字都不一样。
这些知识怎么用
大多数 Go 代码根本用不到这些。下面这几条建议经得起检验。
没有性能分析,就别优化。启动时只运行一次的代码里有一次分配,不会造成任何值得在意的开销。先测量,用 go test -bench . -benchmem 跑基准测试,或者像上面那样用 AllocsPerRun,只在数字显示有问题的地方改代码。
知道大小就预先分配切片。讲切片的那一部分演示过,make([]T, 0, n) 可以避免底层数组一次又一次地扩容。
小结构体按值传递。指针并不自动更便宜。复制小结构体开销很小,而且永远不会让它逃逸;而取它的地址,如果编译器无法证明指针只在局部使用,就可能导致逃逸。函数必须修改这个值,或者结构体很大时,再用指针,讲结构体的那一部分解释过。
要点
- 每次调用都得到一个栈帧,返回时弹出。goroutine 的栈一开始很小,靠复制来增长。
- 必须活得比函数更久的值放在堆上,没有东西能到达它们时,垃圾回收器把它们释放。
- 栈还是堆,不由你选,由逃逸分析决定。返回局部变量的指针是安全的。
go build -gcflags=-m显示编译器的决定,testing.AllocsPerRun统计真正发生的分配。内联可能改变结果。- 被返回或被保存的指针、接口、闭包,以及过大或大小未知的切片,是逃逸的常见原因。
- GC 是并发的三色标记-清扫回收器。
GOGC在内存和 CPU 之间取舍,GOMEMLIMIT设定一个软上限。
先写清晰的代码,再让测量结果告诉你该去掉哪次分配。