内存分配与逃逸分析
需要预先了解:
1. 内存分配与逃逸分析
一个变量分配在栈上还是堆上,由编译器通过**逃逸分析(escape analysis)**决定。这个决定直接影响性能:栈分配只是移动一下栈指针,几乎零成本;堆分配要走分配器、还要被垃圾回收扫描。这一篇讲清楚逃逸分析怎么看、哪些写法会让变量逃逸,以及内存对齐和分配大小分级这两个容易忽略的细节。
1.1 栈与堆
| 维度 | 栈 | 堆 |
|---|---|---|
| 分配成本 | 极低(移动栈指针) | 较高(分配器 + 可能的锁) |
| 回收方式 | 函数返回时自动释放 | 由垃圾回收器回收 |
| 生命周期 | 与函数调用绑定 | 由可达性决定 |
| 访问速度 | 更快(缓存友好) | 稍慢 |
Go 的 goroutine 栈是动态增长的:初始约 8KB,不够时自动扩容(最大到 1GB,64 位平台)。栈上变量的地址在扩容时会被修正,所以程序里取到的指针在逻辑上始终有效。
1.2 Go 的分配器
Go 运行时自带内存分配器,按对象大小走不同的路径:
小对象(≤ 32KB):
mcache(每个 P 私有,无锁)
|
mcentral(每个 size class 一个,需要加锁)
|
mheap(全局堆,向操作系统申请内存)
大对象(> 32KB):
直接走 mheap
- mcache:每个 P 持有一份,按大小分类缓存空闲内存块。小对象分配在 mcache 里完成,不需要加锁,这是 Go 分配快的主要原因。
- mcentral:当 mcache 里某类内存用完时,向 mcentral 申请一批;mcentral 是所有 P 共享的,需要加锁。
- mheap:全局堆,负责管理从操作系统申请来的大块内存(arena)和按页切分。
这套设计把"绝大多数分配"变成无锁操作,同时按大小分类减少了内存碎片。
1.3 逃逸分析
用编译器参数查看逃逸分析结果:
go build -gcflags='-m -l' .
-m输出优化决策;-l禁用函数内联,避免内联后看不到真实的逃逸情况。
常见逃逸场景
type Point struct {
X, Y int
}
// 返回局部变量地址:逃逸到堆
func returnPtr() *Point {
p := Point{1, 2}
return &p
}
// 返回局部变量值:不逃逸
func returnValue() Point {
p := Point{1, 2}
return p
}
// 赋值给接口:逃逸(装箱)
func toInterface() any {
p := Point{1, 2}
return p
}
// 闭包捕获:被捕获的变量逃逸
func closureCapture() func() int {
x := 1
return func() int { return x }
}
// 动态大小:逃逸
func makeDynamic(n int) []int {
return make([]int, n)
}
// 固定大小且不返回:分配在栈上
func fixedSlice() int {
s := make([]int, 0, 4)
s = append(s, 1, 2, 3)
return len(s)
}
// 发送指针到 channel:逃逸
func sendPtr(ch chan *Point) {
p := Point{3, 4}
ch <- &p
}
// 过大的局部变量:逃逸
func bigLocal() int {
var a [100000]int
a[0] = 1
return a[0]
}
对应的编译器输出(节选):
./main.go:11:2: moved to heap: p
./main.go:24:9: p escapes to heap
./main.go:30:9: func literal escapes to heap
./main.go:35:13: make([]int, n) escapes to heap
./main.go:40:11: make([]int, 0, 4) does not escape
./main.go:46:14: ch does not escape
./main.go:47:2: moved to heap: p
./main.go:53:6: moved to heap: a
逐条对应:
| 场景 | 输出 | 是否逃逸 |
|---|---|---|
| 返回局部变量地址 | moved to heap: p |
是 |
| 赋值给接口(装箱) | p escapes to heap |
是 |
| 闭包捕获变量 | func literal escapes to heap |
是 |
make 长度是变量 |
make([]int, n) escapes to heap |
是 |
make 长度是常量且不返回 |
make([]int, 0, 4) does not escape |
否 |
| 发送指针到 channel | moved to heap: p |
是 |
| 过大的局部变量 | moved to heap: a |
是 |
returnValue() 在 fmt.Println(returnValue()) 这一行也会被标记为 escapes to heap——那是返回值被装进 any 传给 Println 导致的,跟函数内部无关。
逃逸的代价
用一个基准测试对比"返回指针"和"返回值":
BenchmarkReturnPtr-24 44806212 7.685 ns/op 16 B/op 1 allocs/op
BenchmarkReturnValue-24 794514319 0.4424 ns/op 0 B/op 0 allocs/op
返回指针的版本每次多一次堆分配,慢了十几倍,还增加了 GC 压力。小对象优先返回值、让它留在栈上,是 Go 里最常见的优化。
注意:逃逸分析的结论会随编译器版本变化。不要凭记忆判断"这个一定逃逸",用
-gcflags='-m'实际跑一遍。
1.4 new 与 make 的底层区别
结构体与指针 里提过 make 只能用于 slice/map/channel。从底层看:
new(T):分配sizeof(T)字节并清零,返回*T。它不知道对象的语义,只是"一块归零的内存"。make([]T, len, cap):调用runtime.makeslice,除了分配底层数组,还要计算字节数、做长度与容量的合法性检查,并构造出切片头(指针 + len + cap)。make(map[K]V):调用runtime.makemap,要分配hmap结构并初始化哈希种子、桶数组。make(chan T, n):调用runtime.makechan,要分配hchan结构并初始化缓冲区、等待队列。
所以 make 不只是"分配内存",还负责初始化运行时数据结构。new 出来的 map 是指向零值 hmap 的指针,对它写入会 panic。
1.5 内存对齐
CPU 访问内存时按"字"读取,跨字边界的对象需要额外的读取次数。因此编译器会让每个字段的起始地址是其大小的整数倍,这就是内存对齐(alignment)。
三个查看工具:
unsafe.Sizeof(x) // 类型占用的字节数(含填充)
unsafe.Alignof(x) // 对齐要求
unsafe.Offsetof(s.f) // 字段 f 在结构体中的偏移
字段顺序会影响结构体大小:
type A struct {
a int8
b int64
c int16
}
type B struct {
b int64
a int8
c int16
}
fmt.Println("A: size =", unsafe.Sizeof(A{}), "align =", unsafe.Alignof(A{}))
fmt.Println(" a 偏移:", unsafe.Offsetof(A{}.a), "b 偏移:", unsafe.Offsetof(A{}.b), "c 偏移:", unsafe.Offsetof(A{}.c))
fmt.Println("B: size =", unsafe.Sizeof(B{}), "align =", unsafe.Alignof(B{}))
fmt.Println(" b 偏移:", unsafe.Offsetof(B{}.b), "a 偏移:", unsafe.Offsetof(B{}.a), "c 偏移:", unsafe.Offsetof(B{}.c))
A: size = 24 align = 8
a 偏移: 0 b 偏移: 8 c 偏移: 16
B: size = 16 align = 8
b 偏移: 0 a 偏移: 8 c 偏移: 10
A 里 int8 后面要空 7 个字节才能放 int64,最后 int16 之后还要补 6 个字节保证整体是 8 的倍数,共 24 字节。B 把最大的字段放前面,只需 16 字节。
结论:把字段按大小从大到小排列,可以省内存。 字段很多的结构体值得检查一下,结构体切片里这个差别会被放大。
空结构体
type Empty struct{}
fmt.Println(unsafe.Sizeof(Empty{})) // 0
空结构体大小为 0,常用来做"集合"的 value 或信号 channel 的元素。注意它的地址:
e1 := new(Empty)
e2 := new(Empty)
fmt.Printf("%p %p 相等: %v\n", e1, e2, e1 == e2)
0x7ff758ef4f20 0x7ff758ef4f20 相等: true
两个零大小对象的地址可能相同(都指向同一个"零基址"),这是允许的——因为不占空间,指向哪里都无所谓。所以不要用空结构体的地址来判断对象身份。
空结构体作为结构体字段时也不占空间,但会影响后续字段的偏移:
type WithEmpty struct {
n int64
e Empty
x int64
}
fmt.Println(unsafe.Sizeof(WithEmpty{}), unsafe.Offsetof(WithEmpty{}.e), unsafe.Offsetof(WithEmpty{}.x))
16 8 8
e 和 x 的偏移都是 8,说明 e 没有占位。
1.6 分配大小分级
小对象按**大小分级(size class)**分配,请求的字节数会被向上取整到最近的档位。用基准测试的 B/op 可以看到这个过程:
BenchmarkAlloc1-24 71537154 3.321 ns/op 1 B/op 1 allocs/op
BenchmarkAlloc9-24 32176756 7.663 ns/op 16 B/op 1 allocs/op
BenchmarkAlloc33-24 23738402 10.64 ns/op 48 B/op 1 allocs/op
BenchmarkAlloc40-24 20807504 11.33 ns/op 48 B/op 1 allocs/op
- 1 字节的对象走 tiny allocator(小于 16 字节的对象被合并到同一块 16 字节里),所以
B/op只有 1; - 9 字节向上取整到 16;
- 33 字节和 40 字节都向上取整到 48。
常见的档位是 8、16、24、32、48、64、80、96、112、128……超过 32KB 就走大对象路径。
这也解释了为什么"预分配切片容量"有用:如果提前知道要存 1000 个元素,make([]T, 0, 1000) 只分配一次;让 append 自己扩容,则会经历多次分配加拷贝。
1.7 练习
- 写几个函数,分别让局部变量以"返回值、返回指针、放进接口、被闭包捕获"的方式离开函数,用
go build -gcflags='-m -l'验证你的预测。 - 定义一个含
int8、int64、string、bool的结构体,按不同顺序排列字段,用unsafe.Sizeof找出最小的排列。 - 用
testing.B对比make([]int, 0, 1000)与不预分配的append循环,比较ns/op、B/op、allocs/op。



