Blog

Go 内存:栈、堆、逃逸分析和 GC

值放在栈上还是堆上,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 = 0 n = 3 func literal main next next = nil 现在没有东西指向这里 GC 到达不了它们:已释放 main 调用 makeCounter():栈上压入新帧,n = 0 返回的函数用到 n,n 不能留在帧里:它在堆上 makeCounter 返回,帧被弹出;next 指向堆上的函数 next() 运行三次,每次给堆上的 n 加一 next = nil:栈上再没有东西指向函数或 n GC 下次运行时到达不了它们,内存被释放

上面程序里的 makeCounter。它的帧里有 n,但它返回的函数用到了 n,所以 n 放在堆上,挨着那个函数。帧被弹出,main 的 next 指向堆里。一旦 next 被设为 nil,就没有东西能到达那个函数和 n,垃圾回收器会释放它们。

如果动画没有播放,下面用文字把这几步再说一遍:

  1. main 调用 makeCounter。栈上压入一个新帧,按理说 n = 0 应该放在这里。
  2. makeCounter 返回的函数用到了 n,所以 n 必须活得比帧更久。它放在堆上,挨着函数值。
  3. makeCounter 返回,帧被弹出。main 里的 next 指向堆上的函数,函数又指向 n
  4. 三次调用 next(),每次都给同一个 n 加一,现在它是 3。
  5. next = nil。栈上再也没有东西指向那个函数或 n
  6. 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):每次写指针,都会顺带把涉及的指针涂色。标记期间新分配的对象直接标成黑色。

这个比喻的局限:故事里的清洁工一个人干活,其他人都在等。真正的回收器和你的程序同时运行,用多个线程,而且程序自己在分配时也会分担一部分标记工作。箱子上也没有贴纸:标记是运行时在堆旁边另外保存的一些位。

两个旋钮:GOGCGOMEMLIMIT

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 运行时所管理内存总量的软限制,以字节为单位,可以带 MiBGiB 这样的后缀。程序接近这个限制时,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 设定一个软上限。

先写清晰的代码,再让测量结果告诉你该去掉哪次分配。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。