Go 编译器偷偷帮你做的那些内存优化

0 阅读6分钟

在 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.24Go 1.25Go 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,或许会有意外之喜。


参考资料: