Go是一门带有垃圾回收的现代语言,它抛弃了传统C/C++的开发者需要手动管理内存的方式,实现了内存的主动申请和释放的管理。Go的垃圾回收,让堆和栈的概念对程序员保持透明,它增加的逃逸分析与GC,使得程序员的双手真正地得到了解放,给了开发者更多的精力去关注软件设计本身。
就像《CPU缓存体系对Go程序的影响》文章中说过的一样,“你不一定需要成为一名硬件工程师,但是你确实需要了解硬件的工作原理”。Go虽然帮我们实现了内存的自动管理,我们仍然需要知道其内在原理。内存管理主要包括两个动作:分配与释放。逃逸分析就是服务于内存分配,为了更好理解逃逸分析,我们先谈一下堆栈。
堆和栈
应用程序的内存载体,我们可以简单地将其分为堆和栈。
在Go中,栈的内存是由编译器自动进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁。一个goroutine对应一个栈,栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系,每一帧对应一次尚未返回的函数调用,它本身也是以栈形式存放数据。
举例:在一个goroutine里,函数A()正在调用函数B(),那么这个调用栈的内存布局示意图如下。
与栈不同的是,应用程序在运行时只会存在一个堆。狭隘地说,内存管理只是针对堆内存而言的。程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。
栈是每个goroutine独有的,这就意味着栈上的内存操作是不需要加锁的。而堆上的内存,有时需要加锁防止多线程冲突(为什么要说有时呢,因为Go的内存分配策略学习了TCMalloc的线程缓存思想,他为每个处理器P分配了一个mcache,从mcache分配内存也是无锁的)。
而且,对于程序堆上的内存回收,还需要通过标记清除阶段,例如Go采用的三色标记法。但是,在栈上的内存而言,它的分配与释放非常廉价。简单地说,它只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。而这,只需要借助于栈相关寄存器即可完成。
另外还有一点,栈内存能更好地利用CPU的缓存策略。因为它们相较于堆而言是更连续的。
逃逸分析
那么,我们怎么知道一个对象是应该放在堆内存,还是栈内存之上呢?可以官网的FAQ(地址:https://golang.org/doc/faq)中找到答案。
如果可以,Go编译器会尽可能将变量分配到到栈上。但是,当编译器无法证明函数返回后,该变量没有被引用,那么编译器就必须在堆上分配该变量,以此避免悬挂指针(dangling pointer)。另外,如果局部变量非常大,也会将其分配在堆上。
那么,Go是如何确定的呢?答案就是:逃逸分析。编译器通过逃逸分析技术去选择堆或者栈,逃逸分析的基本思想如下:检查变量的生命周期是否是完全可知的,如果通过检查,则可以在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。
Go语言虽然没有明确说明逃逸分析规则,但是有以下几点准则,是可以参考的。
- 逃逸分析是在编译器完成的,这是不同于jvm的运行时逃逸分析;
- 如果变量在函数外部没有引用,则优先放到栈中;
- 如果变量在函数外部存在引用,则必定放在堆中;
我们可通过go build -gcflags '-m -l'
命令来查看逃逸分析结果,其中-m 打印逃逸分析信息,-l禁止内联优化。下面,我们通过一些案例,来熟悉一些常见的逃逸情况。
情况一:变量类型不确定
package main
import "fmt"
func main() {
a := 666
fmt.Println(a)
}
逃逸分析结果如下
$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:7:13: ... argument does not escape
./main.go:7:13: a escapes to heap
可以看到,分析结果告诉我们变量a
逃逸到了堆上。但是,我们并没有外部引用啊,为啥也会有逃逸呢?为了看到更多细节,可以在语句中再添加一个-m
参数。得到信息如下
$ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:7:13: a escapes to heap:
./main.go:7:13: flow: {
storage for ... argument} = &{
storage for a