-
作者:Keith Randall(Go 编译器团队)
在 Go 1.25 和 Go 1.26 这两个版本中,Go 编译器悄悄做了一件很有价值的事:把更多的内存分配从堆(heap)挪到了栈(stack)上。
这件事听起来像是编译器内部的技术细节,但它直接影响你的程序性能,而且完全不需要你修改任何代码。这篇文章就来讲清楚这件事的来龙去脉。
堆和栈,为什么差别这么大
在深入优化细节之前,先把基础概念说清楚。
Go 程序在运行时使用两种主要的内存区域:栈(stack) 和 堆(heap)。
栈是每个 goroutine 私有的,随函数调用自动增长,函数返回时自动回收。分配成本极低,有时编译器只需调整一个寄存器的值,完全不需要与 GC 打交道。
堆是全局共享的,由垃圾回收器管理。每次堆分配都需要走一套分配流程(找合适的 size class、加锁或走 mcache、记录对象用于 GC 扫描),相对昂贵。更重要的是,堆上的对象会给 GC 带来持续压力。
栈分配随函数帧自动回收,堆分配则需要 GC 介入
在高并发、高吞吐的 Go 服务中,频繁的堆分配会造成 GC 抖动,是性能问题的常见根源之一。Go 团队从 1.21 到 1.26,每个版本都在持续压缩这部分开销。
第一层:常量大小切片的栈分配(Go 1.24)
先从最基础的场景说起。假设你知道切片大概有多少元素,于是写出这样的代码:
func process2(c chan task) {
tasks := make([]task, 0, 10)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
容量写死为 10,是一个编译期常量。Go 1.24 的编译器能识别这一点:它知道 backing store 的大小在编译期就确定了,所以直接在当前函数的栈帧里开辟这块内存,完全绕过堆分配器。
如果你用 benchmark 验证,会发现 allocs/op 从你以为的 1 变成了 0。
当然,这有一个前提:processAll 不能让这个切片逃逸到堆上(比如把它存到全局变量里)。
第二层:变量大小切片的栈分配(Go 1.25 新增)
常量容量的限制太死了。很多时候,你想把容量猜测值作为参数传进来:
func process3(c chan task, lengthGuess int) {
tasks := make([]task, 0, lengthGuess)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
在 Go 1.24 中,lengthGuess 是运行时变量,编译器无法在编译期确定 backing store 的大小,因此只能把它分配到堆上。你的零分配代码又变成了一次堆分配。
Go 1.25 引入了一个新策略: 编译器在栈上自动预留一个固定的小缓冲区(当前大小为 32 字节)。在运行时,如果 lengthGuess 对应的大小能放进这个缓冲区,就直接用栈上的那块内存;如果放不下,再走正常的堆分配路径。
这个变换等价于编译器自动替你写出:
func process4(c chan task, lengthGuess int) {
var tasks []task
if lengthGuess <= 10 { // 能放进 32 字节
tasks = make([]task, 0, 10) // 栈分配
} else {
tasks = make([]task, 0, lengthGuess) // 堆分配
}
// ...
}
只是你不需要自己写这段丑陋的代码——编译器帮你做了。
第三层:append 启动阶段的栈分配(Go 1.26 新增)
前面两层都要求你主动写 make,并且给出容量估计。如果你什么都不写呢?
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
这是最自然的写法。来看看它在 Go 1.25 及之前的运行时发生了什么:
| 迭代轮次 | 操作 | 分配大小 |
|---|---|---|
| 第 1 次 | backing store 为空,append 分配 | 1 个元素 |
| 第 2 次 | 满了,扩容 | 2 个元素 |
| 第 3 次 | 满了,扩容 | 4 个元素 |
| 第 4 次 | 未满,直接写入 | 无分配 |
| 第 5 次 | 满了,扩容 | 8 个元素 |
启动阶段(slice 还很小时)需要多次堆分配,且每一次旧的 backing store 变成垃圾,等待 GC 回收。对于小切片来说,这个"启动税"可能占据了大部分分配开销。
Go 1.26 的做法是: 在 append 第一次分配时,同样在栈上预留那个小缓冲区,直接从这里开始。如果整个生命周期里切片都没超过这个大小,全程零堆分配。一旦超出,再走正常的堆路径。
这样,大量"小切片"的场景可以彻底避开堆。
第四层:逃逸切片的优化(Go 1.26 新增)
前面说的都是切片不逃逸的情况。如果你要把切片作为返回值呢?
func extract(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
return tasks // 这个 slice 必须活到函数返回之后
}
函数栈帧在 return 之后就消失了,所以最终返回的那个 backing store 必须在堆上——这一点无法改变。
但问题的关键是:那些在 append 启动阶段产生的、后来被丢弃的中间 backing store,是否也必须在堆上?
答案是不必。Go 1.26 的编译器会做如下变换:
// 编译器生成的等价代码
func extract3(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t) // 中间过程用栈上缓冲区
}
tasks = runtime.move2heap(tasks) // 只在最后做一次堆分配
return tasks
}
runtime.move2heap 是一个编译器和运行时共同维护的特殊函数:
- 如果 slice 已经在堆上(因为超出了栈缓冲区大小),它是个 no-op,直接返回。
- 如果 slice 还在栈上,它分配一块精确大小的堆内存,把数据拷贝过去,返回堆上的副本。
这样,最终的效果是:如果任务数量很少,整个过程只有最后一次堆分配,大小恰好等于实际元素数量——比手写的优化版本还要好,因为手写版无论如何都要做一次额外的 make + copy。
版本对照总结
| 场景 | Go 1.24 | Go 1.25 | Go 1.26 |
|---|---|---|---|
make([]T, 0, 10) 常量容量 | 0 次堆分配 | 0 次堆分配 | 0 次堆分配 |
make([]T, 0, n) 变量容量(n 小) | 1 次 | 0 次 | 0 次 |
| 无 make,append 驱动,不逃逸 | 多次启动分配 | 多次启动分配 | 首次用栈,可能 0 次 |
| 无 make,append 驱动,逃逸 | 多次启动 + 1 次最终 | 多次启动 + 1 次最终 | 最终仅 1 次 |
如何确认优化有没有生效
用 -gcflags='-m' 可以看逃逸分析结果:
go build -gcflags='-m' ./...
用 benchmark 可以直接量化分配次数:
func BenchmarkProcess(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// ...
}
}
如果你怀疑某个优化引入了正确性问题或带来了负向的性能影响,可以用以下 flag 关闭它:
go build -gcflags=all=-d=variablemakehash=n ./...
如果关闭后问题消失,建议提交 Issue 给官方团队。
写在最后
这一系列优化的核心逻辑只有一句话:让编译器比你更聪明地管理切片内存,在不改变任何语义的前提下,把更多分配从堆挪到栈。
对开发者来说,这意味着:写自然的代码,不要过度手工优化,让编译器去做它擅长的事。当然,如果你确实有很好的大小估计,提前 make 并给出容量依然是正确且有效的做法。
把 Go 升级到最新版本,重新跑一下你的 benchmark,或许会有意外之喜。
参考资料:
- 原文:go.dev/blog/alloca…
- Go 逃逸分析指南:go.dev/doc/gc-guid…
- Green Tea GC:go.dev/blog/greent…