在大多数编程语言中,栈和堆是程序在内存中存储数据的两种方式,这两种数据结构都由语言运行时来管理。它们各自针对不同的使用场景进行了优化,比如为了实现快速访问数据或灵活控制变量的生命周期。
Go语言也遵循同样的机制,但通常情况下,你并不需要直接选择将变量存储在栈上还是堆上。实际上,是Go编译器来决定变量应该存放在哪里。如果编译器能够确定某个变量只在当前的函数调用范围内被使用,那么它就会将该变量存储在栈上;反之,如果无法确认这一点,该变量就会被存储在堆上。这种技术被称为逃逸分析。
这一点非常重要,因为当在堆上进行内存分配时,会增加垃圾收集器的负担。对于那些需要频繁运行的程序来说,这种额外的开销可能会导致垃圾收集器消耗更多的CPU资源、产生更多的内存分配操作,进而使程序的性能变得不稳定且难以预测。
在本文中,我将解释什么是逃逸分析、哪些常见的编程模式会导致堆上的内存分配,以及如何识别并减少这些不必要的分配操作。
目录
先决条件
-
熟悉Go语言的基础知识(函数、变量、结构体、切片、映射等)
-
了解Go语言中指针的基本用法(
&和*操作符) -
对Goroutine的工作原理有一个大致的了解
你真的需要关注逃逸分析吗?
在进一步探讨这个话题之前,我想明确一点:对于程序的正确性而言,变量是存储在栈上还是堆上,或者你是否了解这一细节,其实都并不重要。Go编译器足够智能,它能够自动将变量放在合适的位置,以确保程序的正常运行。
大多数情况下,你根本不需要考虑这些问题。只有当性能成为问题时,这些因素才会变得重要起来。如果你的程序已经足够快了,那么就没有必要再试图进一步提升它的速度了。
只有当有基准测试结果显示你的程序运行速度过慢,并且这些测试结果进一步指出大量堆内存分配和垃圾回收是导致性能问题的原因时,你才需要开始关注栈与堆的区别。
内存布局与生命周期
要想更好地理解“逃逸分析”这一概念,首先你需要了解在程序运行过程中,Go语言是如何管理内存的。从本质上来说,这涉及到每个goroutine所使用的栈结构、栈帧是如何在栈中创建的,以及哪些数据会被移动到堆中以便垃圾回收器进行处理。
Goroutine栈与栈帧
当Go程序启动时,运行时会创建一个`main` goroutine,而每个`go`语句都会生成一个新的goroutine,每个goroutine都有自己的栈。
整个进程并没有一个全局的栈。以目前发布的Go v1.25.7版本为例,每个goroutine都会获得一块初始大小为2,048字节的内存作为其栈空间。栈是用来存储函数调用相关数据的地方:当一个goroutine调用某个函数时,Go会为该函数的局部变量分配一段栈空间,这段空间被称为“栈帧”。
栈帧中保存着函数的局部变量以及用于返回和继续执行所需的调用状态信息。如果该函数又调用了另一个函数,那么就会在栈帧之上再添加一个新的栈帧;当内层函数返回后,它的栈帧就会失效,goroutine会继续在外层函数的栈帧中执行。
一个栈帧的有效存在时间仅限于该函数被执行的整个过程。一旦函数返回,栈帧中保存的所有数据都会被视为无效的——即使这些数据的字节仍然存在于内存中并且以后可能会被重新使用。因此,在函数返回之后,代码绝对不能依赖这些数据。
Go语言的栈空间是可以动态扩展的。一个goroutine开始时使用的栈空间可能很小,但运行时会根据需要对其进行扩展;不过数据的生命周期规则仍然是不变的:只有当函数返回后没有任何地方还能引用该数据时,它才能安全地存储在栈帧中。如果未来还可能会有人引用这个数据,那么它就不能留在栈帧里,而必须被存放到更安全的地方。
在Go语言中,使用`p := &x`这样的语句会创建一个指针,这个指针位于某个栈帧中,但它所指向的数据可能实际上存在于另一个栈帧中。当你将这个指针传递给一个函数时,Go仍然是通过值传递的方式来进行传递的:被调用函数会在自己的栈帧中创建一个新的指针变量,但这个新指针仍然指向同一个数据。因此,指针是一种允许我们在不同的栈帧之间共享对同一数据的访问权限的方法,而无需复制数据本身。
当指针的生命周期超过了它所指向的数据的生命周期时,这个问题就变得非常重要了。只有当指针和被指向的数据都还存在于当前调用栈中活跃的栈帧里时,一切才会保持安全。
一旦原始函数执行完毕之后指针仍然存在,那么该指针所指向的值就不再属于那个已经失效的函数帧了。此时,这个值必须被存放到一个更安全的位置,这样才能确保没有任何指针会指向已经被释放的内存区域。
向下共享与向上共享
现在你们已经了解了栈、函数帧以及指针的基本概念,我们可以来看看指针在代码中移动的两种常见方式。我将这两种方式称为“向下共享”和“向上共享”。这些名称并不是Go语言中的专用术语,只是用来简单描述指针在调用栈中移动的方式而已。
向下共享
“向下共享”指的是一个函数将其持有的指针或引用传递给它调用的其他函数。此时,指针会深入到调用栈的更底层,但它所指向的值仍然属于某个处于活动状态的函数帧。
示例代码:
package main
import "fmt"
func main() {
n := 10
multiply(&n)
}
func multiply(v *int) {
*v = *v * 2
}
在main函数中,我们将变量n的地址传递给了multiply函数。当multiply函数运行时,main函数帧和multiply函数帧都处于活动状态。由于multiply函数中的指针仍然指向一个属于活跃函数帧的值,因此从内存管理的角度来看,这种情况是安全的。

在下面的示意图中,当multiply函数执行完毕并返回后,multiply函数帧就变得无效了。由于栈指针会自动跳回到上一个函数帧的地址,因此我们不需要采取任何额外的措施。这一过程会自动回收multiply函数所使用的所有内存,因此垃圾收集器并不会参与清理这些内存。

共享机制
“共享机制”指的是某种函数会返回一个指针,或者将该指针存储在函数执行结束后仍然存在的地方。当创建该值的函数即将结束时,这个指针会被移回调用栈中,或者被存放在某个能够长期存在的位置上,这样这个值就不会再与创建它的那个函数帧关联在一起了。
当你想要与其他goroutine共享数据时,同样的原理也会体现出来。因为Go语言不允许一个goroutine持有另一个goroutine的栈内存的指针,所以共享的数据必须具有不依赖于单一函数栈的生命周期。
堆、垃圾回收与对象的生命周期
那些可能会比某个函数帧的生命周期更长的数据,就不能留在那个函数帧中。编译器会将这类数据放到堆上。堆是内存中的一个独立区域,它并不与任何特定的函数调用相关联。任何goroutine都可以持有指向堆中数据的指针,只要程序中的其他部分仍然能够访问这些数据,这些数据就依然有效。你可以把堆看作是用来存储“可能会比当前函数执行时间更长而仍然存活的数据”的地方。
垃圾回收器的作用就是确保这种数据的安全性。运行时系统会定期从一些根节点开始(比如全局变量、正在执行的函数帧以及某些内部状态),然后沿着所有可用的指针路径来检查堆中的数据。那些仍然可以被访问到的堆中数据会被保留下来,而那些不再能被访问到的数据则会被视为垃圾,其所占用的内存也会被回收。
这意味着,在main函数中创建的指针,永远不会指向已经死亡的内存区域。要么这些数据仍然存在于某个正在执行的函数帧中,要么它们已经被放入了堆中,这样垃圾回收器就可以跟踪它们的生命周期了。不过,这种机制的缺点是:更多的堆内存分配以及寿命较长的对象,会使得垃圾回收器需要执行更多的工作。
下面是一个例子:
package main
import "fmt"
type Car struct {
Brand string
Model string
}
func main() {
// main函数从它调用的某个函数中接收到了一个指针,这就是一种共享机制的体现
carPtr := makeCar("Volkswagen", "Golf")
fmt.Printf("我得到了一辆汽车:%s %s\n", carPtr.Brand, carPtr.Model)
}
func makeCar(b, m string) *Car {
myCar := Car{
Brand: b,
Model: m,
}
return &myCar
}
在上面的代码中:
makeCar函数中(即被调用者框架内),Go语言会创建一个局部变量
myCar。由于你返回了&myCar,编译器就会将这个Car对象的值分配到堆上,因此myCar实际上存储的是堆上的地址0xc00029fa0。makeCar返回时,这个地址会被复制到
main函数中的carPtr变量里(即调用者框架)。carPtr只是一个栈变量,但它的值仍然是0xc00029fa0,因此现在main也指向了同一个堆上的Car>对象。Car>对象的地址确实是
0xc00029fa0。无论是在makeCar运行期间,还是在其返回之后,car和carPtr》这两个变量都是通过各自的指针来引用同一个堆对象。makeCar执行完毕,它的框架就会被丢弃到“无效内存”区域,但堆上的
Car>对象仍然存在,因为main依然持有carPtr>这个指针。这就是所谓的“值逃离栈空间”的现象:该值的生命周期不再与被调用者框架相关联,而是变成了堆上的对象。

实践中的逃逸分析
逃逸分析是Go编译器用来决定某个值应该存储在栈上还是堆上的机制。它不仅仅与返回指针有关,还会跟踪地址在代码中的流动路径。如果某个值的生命周期会超出当前函数的执行范围,编译器就无法将其保留在栈框架中,而会将其移动到堆上。由于只有编译器能够看到整个代码的执行过程,因此让编译器展示这些决策结果,并将这些结果与你的代码对应起来,是非常有用的。
要实现这一点,我们可以在运行go build或go run时通过-gcflags参数传递相应的编译器选项。如果你想查看所有可用的选项,可以执行go tool compile -h命令。在这个列表中,-m选项会打印出编译器的优化决策结果,其中包括逃逸分析的结论。如果你需要更详细的信息,可以使用-m=2或-m=3来获得更详细的输出。-l选项则会禁用内联函数优化,这样生成的报告会更容易阅读,因为编译器不会将一些小的函数合并到它们的调用者函数中。
因此,相应的命令格式如下:
go run -gcflags='all=-m -l' .
如果是用于构建程序,则命令如下:
go build -gcflags='all=-m -l' .
如何利用逃逸分析来优化程序性能
你可以将“逃逸分析”理解为这样一种机制:它会使你的代码选择转化为垃圾收集器需要执行的工作。当某个值发生“逃逸”时,它就会进入堆内存区域,从而迫使垃圾收集器去处理它。在那些执行频率很高的代码路径中,大量这种“逃逸”现象会导致垃圾收集器花费更多时间进行清理工作,进而影响程序的运行效率。
-
对于小数据量,优先使用值类型:如果函数不需要修改调用者的数据,在传递参数或返回结果时,应使用值类型来表示小型结构体或基本数据类型。复制一个`int`类型变量或一个小型结构体所消耗的资源很少,而且这类变量的生命周期通常仅限于单次函数调用范围内。
-
当需要共享数据或进行修改操作时,使用指针:当你确实需要共享可变状态,或者想要避免复制大型结构体时,应该选择使用指针。
-
注意避免无意中创建长期存在的引用:在返回指向局部变量的指针、在闭包中捕获变量,或将地址存储在长期存在的结构体、映射或接口中时,要格外小心。这些做法很可能会导致值被移出栈帧范围,从而引发“逃逸”问题。
-
在高频执行的代码路径上,使用可重用的缓冲区:对于那些经常被执行的代码路径来说,问题通常不在于一次性的大量内存分配,而在于循环中反复进行的小规模内存分配。一个常见的错误是:函数总是在内部创建新的缓冲区,而实际上调用者本可以提供已存在的缓冲区。
package main // 错误示例:辅助函数在每次调用时都会分配新的缓冲区。 func fillBad() []byte { buf := make([]byte, 4096) // 假设我们要向其中写入数据 buf[0] = 1 func hotPathBad() { 0; i < 1_000_000; i++ { b := fillBad() // 这个循环会执行1,000,000次 _ = b } } func main() { hotPathBad() }当我们使用以下命令进行逃逸分析时:
go run -gcflags='-m -l' .
我们会得到如下分析结果:
./main.go:5:13: make([]byte, 4096) escapes to heap
如果只进行几次内存分配,也许不必太担心这个问题;但真正的问题在于:在循环内部,每次调用`hotPathBad`函数时都会重新分配一个4KB大小的缓冲区。如果这个循环执行很多次,就会产生大量短期存在的堆内存对象,从而导致垃圾收集器需要花费额外的时间来清理这些垃圾,而其实只要重用同一个缓冲区,就可以避免这种浪费。
package main func fill(buf []byte) int { 假设我们要向其中写入数据 buf[0] = 1 1 } func hotPath() { buf := make([]byte, 4096) 0; i < 1_000_000; i++ { n := fill(buf) _ = buf[:n] } } func main() { hotPath() }在这个改进后的版本中,`hotPath`函数负责管理缓冲区。它只分配一次`buf`内存空间,然后在每次循环中将其传递给`fill`函数使用。这样虽然仍然会读取相同的数据,但每次调用都不会重新创建新的缓冲区副本,从而有效减少了不必要的内存分配。
结论
在 Go 语言中,一个值的最终去向并不是由创建它的方式决定的,而是由该值的有效存活时间以及代码运行过程中对它的引用方式来决定的。
实际操作中,我们不应该避免使用指针,而应该有意识地控制变量的生命周期。值语义可以帮助我们更精确地控制变量的存活时间,从而减少垃圾回收的工作量;而在需要共享数据或进行原地更新的情况下,使用指针则可能是更好的选择。关键在于先写出清晰、易于理解的代码,然后再通过测试和性能分析来确定是否真的有必要对代码进行修改。
进一步阅读
《栈与指针的语言机制》——威廉·肯尼迪