Skip to content

逃逸分析:为什么同一个指针,有时不分配堆内存 ​

看到 &x,不能马上说“x 逃逸了”;看到编译器说 does not escape,也不能马上说“运行时一次堆分配都没有”。要判断分配,得把三件事接起来:值会活多久,编译器在这个调用位置知道多少,以及生成的代码实际走了哪条路径。

这篇用几个很小的对照程序解释这三件事。目标不是把所有逃逸都消掉,而是能读懂 go build -gcflags=-m,找到热路径上真正值得改的分配。本次编译、诊断和分配计数均固定到 Go 1.27.2、Linux/amd64,普通优化构建。语言允许的程序行为,与这版编译器选择栈还是堆,是不同层面的事实。[1][2]

栈和堆,首先是生命周期问题 ​

函数的栈帧随调用建立和退出。如果一个对象的地址还会在这次调用结束后被使用,就不能把它留在即将失效的帧里。Go 的编译器会分析地址的流向,让需要更长生命周期的对象放到合适的位置;返回局部变量指针因此可以是正常、有效的 Go 程序。[2:1][1:1]

存放方式生命周期由谁安排常见成本不能据此下的结论
寄存器或被优化消除编译器可能根本没有独立对象存储不能要求从源代码声明数推导分配数
goroutine 栈上的对象当前调用及栈管理建立帧、栈扫描,必要时栈增长栈也要占内存,也可能需要搬移
Go 堆上的对象运行时分配器与 GC分配、元数据、后续回收;含指针对象还有扫描成本堆分配不等于每次系统调用,也不一定是性能瓶颈

编译器实现把两个约束写得很直接:指向栈对象的指针不能存入堆;指针不能比被指向的栈对象活得更久。分析会建立表示变量和赋值关系的图,追踪地址是否流到生命周期更长的地方,并用函数参数摘要处理跨函数调用。它做的是静态证明,不是运行时看这个对象“好像快用完了”再临时猜。[2:2]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TD
    X["源代码中的变量 x"] --> A{"需要可见的存储吗?"}
    A -->|不需要| O["常量传播、寄存器使用或消除"]
    A -->|需要| L{"地址所需的生存期是否超出<br/>可用栈存储的有效期?"}
    L -->|是或不能证明安全| H["需要堆存储"]
    L -->|否| S{"尺寸、对齐与生成策略<br/>是否允许栈路径?"}
    S -->|允许| K["栈路径或运行时条件分支"]
    S -->|不允许| H
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TD
    X["源代码中的变量 x"] --> A{"需要可见的存储吗?"}
    A -->|不需要| O["常量传播、寄存器使用或消除"]
    A -->|需要| L{"地址所需的生存期是否超出<br/>可用栈存储的有效期?"}
    L -->|是或不能证明安全| H["需要堆存储"]
    L -->|否| S{"尺寸、对齐与生成策略<br/>是否允许栈路径?"}
    S -->|允许| K["栈路径或运行时条件分支"]
    S -->|不允许| H

图中特意保留了最后一层。逃逸分析不只是看“有没有 return 指针”;尺寸、对齐、动态大小等也影响生成策略,而且后续优化还可能改变实际分配。[3][4]

先破除一个误判:取地址,不等于逃逸 ​

下面把局部参数的地址交给一个只读取它的函数。两处 //go:noinline 用来固定这个对照的调用边界,不是建议在业务代码里禁用内联。

go
//go:noinline
func consume(p *int) int { return *p }

//go:noinline
func localAddress(n int) int { return consume(&n) }

Go 1.27.2 的 -m=2 输出包含:

text
./main.go:19:14: p does not escape

文末用 testing.AllocsPerRun 运行这个调用,本次输出:

text
local address: 0 allocs/run

consume 没有把 p 存到全局、返回给更长寿命的地方,也没有交给分析无法约束的长期持有者。编译器知道这次取地址不会制造悬空引用,因此无需仅仅因为有一个 & 就把 n 移到堆上。这里的 0 来自实际计数,旁边的诊断解释了为什么这条路径有机会避免堆分配。[2:3][5]

“指针很贵,所以都改成值传递”不是从这个实验能得到的结论。较大的值复制也有成本,API 的语义和所有权要求同样重要。该比较的是实际调用链,而不是只数星号。

返回指针:为什么调用位置会改变结果 ​

接着对照两个函数,代码相同,只给其中一个禁止内联:

go
func makePointer(n int) *int { return &n }

//go:noinline
func makePointerNoInline(n int) *int { return &n }

如果孤立分析函数,返回地址必须在函数返回后继续有效。诊断中确实会同时出现:

text
./main.go:13:18: moved to heap: n
./main.go:16:26: moved to heap: n

但把调用放回上下文,就有了不同结果:

go
number = *makePointer(42)         // 返回的指针立即解引用,只保留整数
number = *makePointerNoInline(42) // 相同用法,但固定了调用边界
sink = makePointer(42)            // sink 是全局 *int,指针被长期保留

分别计数,本次输出:

text
inlined pointer consumed: 0 allocs/run
noinline pointer consumed: 1 allocs/run
pointer retained: 1 allocs/run

第一条调用被内联后,编译器能一起看到“产生地址”和“立即读取值”,不再必须为孤立函数那个返回值保留堆对象。第二条保留函数边界,返回对象仍要活过被调函数;第三条即使内联了,也确实把指针存进全局,必须维持对象的有效生命周期。日志同时记下 inlining call to makePointer 和全局保留位置的 moved to heap。[2:4][6]

因此,函数定义处的一条 moved to heap,不能直接变成“每次调用都会分配一次”的结论。 看优化后的调用上下文,再用分配计数核对,才不会把本来已经消掉的分配改得更复杂。

常见逃逸场景,要沿着“谁还持有它”解释 ​

写法或场景为什么可能需要堆判断时要补看的条件
返回局部变量的指针调用者可能在被调函数结束后继续使用内联后是否能把生命周期缩回当前调用
保存到全局、堆对象字段等位置新的持有者可能比当前栈帧活得久保存的是值,还是原对象的地址
返回捕获变量的闭包闭包执行时,定义它的函数可能早已返回捕获的是值还是可变引用,闭包有没有真正逃出
新 goroutine 使用局部数据新任务可能活过发起它的调用参数、闭包捕获与被传入对象的去向
转成接口后长期保存接口中的动态值需要有效存储动态类型、具体值、内联与装箱实现
大对象或动态大小的对象栈布局或当前编译策略无法全放在栈上大小限制和运行时条件路径;不只看地址流向

前三行是生命周期流向,最后一行还包含实现限制。Go 1.27.2 的分析入口、go/defer 处理、闭包捕获和 HeapAllocReason 分开处理这些情况。表中的“可能”是重要条件,不能删成一份“写了这些语法就必定逃逸”的背诵清单。[2:5][7][8][3:1]

闭包保留的不只是函数代码 ​

go
//go:noinline
func keepClosure() func() int {
    n := 0
    return func() int { n++; return n }
}

调用返回的函数两次,得到:

text
closure: 1 2
closure retained: 2 allocs/run

第二行是在每次测量中把新闭包保存到全局 fn。n 必须在 keepClosure 返回后继续存在,下一次闭包调用还要读写上一次的 n。当前诊断显示 capturing by ref、n 移到堆,以及函数字面量逃逸;这次计数为 2,与闭包环境和被捕获可变状态的存储相符。这个数量只描述本例和当前编译器。只在当前函数里立即调用、不向外保留的闭包,可能被内联或保留在栈上,不能推广成“所有闭包都分配两次”。[8:1][2:6]

接口装箱:有动态类型,不代表每次都申请一块堆 ​

go
var boxed any

//go:noinline
func keepBox(n int) { boxed = n }

对 keepBox(1000) 做分配计数,本次得到:

text
interface retains 1000: 1 allocs/run

接口需要保留动态值。对于这个逃出调用范围的整数,当前代码路径为装箱提供了存储。但接口转换不是统一的“每次分配”:有的动态值可直接放进接口表示,有的可使用已有地址,某些小整数转换还有静态数据快路径。runtime/iface.go 的 convT64 就会先检查一张小整数表。[9]

这也解释了为什么用 fmt.Println(x) 观察一个变量,可能在逃逸诊断中增加新的信息:格式化调用引入了接口参数和额外调用链。正文的分配计数把打印放在被测闭包之外,避免把打印本身算成“业务调用分配”。仍然要读完整报告,不能把所有标为 escapes 的打印参数都机械计成一次真实 malloc。[5:1][9:1]

一个反直觉的当前行为:does not escape,仍可能走堆路径 ​

Go 1.27.2 对不逃逸的动态大小 make 可以生成混合策略:较小容量使用预留的栈空间,较大容量走运行时分配。这比“大小不是编译期常量,所以必定在堆上”更细,也让诊断与分配次数不能简单画等号。[3:2][4:1]

go
//go:noinline
func dynamic(n int) int {
    b := make([]byte, n)
    for i := range b {
        b[i] = byte(i)
    }
    return int(b[n-1])
}

这个函数只在文末以正整数 16、64、4096 调用,不是任意 n 都安全的通用 API。当前诊断写的是:

text
./main.go:29:11: make([]byte, n) does not escape

实际计数却是:

text
dynamic 16: 0 allocs/run
dynamic 64: 1 allocs/run
dynamic 4096: 1 allocs/run

源码 walkMakeSlice 为这类情况生成容量检查,再在栈数组和运行时 makeslice 路径间选择。本版默认动态 make 栈阈值为 32 字节,所以这三个输入走了不同路径。阈值属于编译器策略,不能写成 Go 语言规则,更不应该为了凑 32 字节而破坏数据结构设计。[4:2][10]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    N["make byte slice,容量 n"] --> E["静态分析:内容不逃出调用"]
    E --> C{"本版生成代码检查容量"}
    C -->|足够小| S["使用预留栈数组"]
    C -->|较大| H["调用运行时分配"]
    S --> R["返回最后一个字节的值"]
    H --> R
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    N["make byte slice,容量 n"] --> E["静态分析:内容不逃出调用"]
    E --> C{"本版生成代码检查容量"}
    C -->|足够小| S["使用预留栈数组"]
    C -->|较大| H["调用运行时分配"]
    S --> R["返回最后一个字节的值"]
    H --> R

这个例子比一句“逃逸与堆分配并不完全等价”更具体:分析确定的是对象不需要被长期保留,生成策略还要处理容量大小。诊断、机器码和计数各回答一部分问题。

怎样使用 -m,而不被一屏输出带偏 ​

动作命令或工具看什么
先找热点CPU / heap profile、业务基准分配是否真的在高频或高成本路径
看当前包的基本结论go build -gcflags='-m'内联与逃逸摘要
看地址流向的理由go build -gcflags='-m=2'从哪一行流向返回值、全局或堆
检验实际分配testing.AllocsPerRun 或 go test -bench . -benchmem每次调用实际分配次数和字节
仍然有疑问再看生成结果汇编或编译器诊断调用是否已内联、分配是否分支化

-gcflags 是传给编译器的标志;需要覆盖依赖包时可用包模式,比如 all=-m=2,但输出会多得多。不要一开始就为了“看得清楚”关闭内联或优化,然后把那组结果当生产构建:你可能正好关闭了本来会消除分配的优化。本文的 //go:noinline 只用于明确标出的对照函数,其他优化保持默认。[11][6:1]

读报告时,先定位自己的函数和调用行,再问三个问题:地址最终去了哪里?那一行是独立函数的摘要,还是当前内联调用的结果?测量中有没有把日志、测试准备和保留对象的容器一起算进去?

AllocsPerRun 会先预热一次,并在测量期间把 GOMAXPROCS 设成 1,结束后恢复原数值。因此它适合这种小范围分配检查,不是并发服务吞吐评测。本文每组计数运行 1000 次;没有由 0 allocs/run 推导“耗时一定更低”,也没有比较未测的编译器版本。[5:2]

若热路径真有不必要的分配,优先让生命周期更清楚:别无意长期保留大对象;能使用局部结果时别存全局;避免为观察而把打印留在测量路径。需要复用缓存时,同时考虑并发安全、引用留存和清理成本。正确性与清晰的所有权先成立,再用证据决定是否值得换 API 或引入池。

依据与验证边界 ​

本文输出来自 Linux/amd64、Go 1.27.2。普通构建、1000 次分配计数、-m=2、功能测试、race 和 vet 均实际执行。race 构建只作正确性检查,分配数字来自普通构建。全文没有把某条逃逸诊断当成永久 API 契约;更换工具链、架构、输入或调用上下文后,都应重新测量。

完整代码与复现 ​

下列文件包含所有正文对照和所运行的测试。先确认 Go 版本,然后在同一目录执行文末命令。

go.mod ​

go.mod
module example.com/runtime/escape

go 1.27

main.go ​

代码块 · 55 行 · 点击收起 / 展开
go
package main

import (
	"fmt"
	"testing"
)

var sink *int
var boxed any
var number int
var fn func() int

func makePointer(n int) *int { return &n }

//go:noinline
func makePointerNoInline(n int) *int { return &n }

//go:noinline
func consume(p *int) int { return *p }

//go:noinline
func localAddress(n int) int { return consume(&n) }

//go:noinline
func keepClosure() func() int { n := 0; return func() int { n++; return n } }

//go:noinline
func dynamic(n int) int {
	b := make([]byte, n)
	for i := range b {
		b[i] = byte(i)
	}
	return int(b[n-1])
}

//go:noinline
func keepBox(n int) { boxed = n }

func measure(name string, f func()) {
	fmt.Printf("%s: %.0f allocs/run\n", name, testing.AllocsPerRun(1000, f))
}
func main() {
	fmt.Println("values:", localAddress(42), *makePointer(42), *makePointerNoInline(42))
	f := keepClosure()
	fmt.Println("closure:", f(), f())
	measure("local address", func() { number = localAddress(42) })
	measure("inlined pointer consumed", func() { number = *makePointer(42) })
	measure("noinline pointer consumed", func() { number = *makePointerNoInline(42) })
	measure("pointer retained", func() { sink = makePointer(42) })
	measure("closure retained", func() { fn = keepClosure() })
	measure("interface retains 1000", func() { keepBox(1000) })
	measure("dynamic 16", func() { number = dynamic(16) })
	measure("dynamic 64", func() { number = dynamic(64) })
	measure("dynamic 4096", func() { number = dynamic(4096) })
}

main_test.go ​

go
package main

import "testing"

func TestValues(t *testing.T) {
	if localAddress(42) != 42 || *makePointer(42) != 42 || *makePointerNoInline(42) != 42 {
		t.Fatal("value mismatch")
	}
	f := keepClosure()
	if f() != 1 || f() != 2 {
		t.Fatal("closure state lost")
	}
	if dynamic(64) != 63 || dynamic(4096) != 255 {
		t.Fatal("slice mismatch")
	}
}

执行命令 ​

bash
go version
go build -o escape .
./escape
go build -gcflags='-m=2' -o escape . 2> escape.txt
go test -count=1 -v ./...
go test -count=1 -race -v ./...
go vet ./...

  1. Go 规范:变量与地址操作符。规范定义可观察语义,不规定本文这些栈/堆优化。 ↩︎ ↩︎

  2. Go 1.27.2 escape.go,两条不变量、静态流图、跨函数摘要。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. Go 1.27.2 escape/utils.go,HeapAllocReason。 ↩︎ ↩︎ ↩︎

  4. Go 1.27.2 walk/builtin.go,walkMakeSlice 的动态大小栈/堆分支。 ↩︎ ↩︎ ↩︎

  5. Go 1.27.2 testing/allocs.go,AllocsPerRun 的预热、计数与 GOMAXPROCS 行为。 ↩︎ ↩︎ ↩︎

  6. Go 1.27.2 编译器文档,优化开关与 //go:noinline;本例的具体内联位置由 -m=2 原始诊断确认。 ↩︎ ↩︎

  7. Go 1.27.2 escape/call.go,函数调用及 goDeferStmt。 ↩︎

  8. Go 1.27.2 escape/escape.go,flowClosure 与按值/按引用捕获;walk/closure.go,闭包转换。 ↩︎ ↩︎

  9. Go 1.27.2 runtime/iface.go,convT、convT64、staticuint64s;internal/abi/type.go,IsDirectIface。 ↩︎ ↩︎

  10. Go 1.27.2 cmd/compile/internal/base/flag.go,Debug.VariableMakeThreshold = 32。 ↩︎

  11. Go 1.27.2 cmd/go:Compile packages and dependencies,-gcflags 的包模式;Go diagnostics,profile 与性能诊断。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准