需要预先了解:


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 练习

  1. 写几个函数,分别让局部变量以"返回值、返回指针、放进接口、被闭包捕获"的方式离开函数,用 go build -gcflags='-m -l' 验证你的预测。
  2. 定义一个含 int8、int64、string、bool 的结构体,按不同顺序排列字段,用 unsafe.Sizeof 找出最小的排列。
  3. 用 testing.B 对比 make([]int, 0, 1000) 与不预分配的 append 循环,比较 ns/op、B/op、allocs/op。