摘要:今天我们来了解一下 Golang 中的内存逃逸的概念。
引言:写过C/C++的同学都知道,调用著名的malloc和new函数可以在堆上分配一块内存,这块内存的使用和销毁的责任都在程序员。一不小心,就会发生内存泄露,搞得胆战心惊;切换到Golang后,基本不会担心内存泄露了。虽然也有new函数,但是使用new函数得到的内存不一定就在堆上。堆和栈的区别对程序员“模糊化”了,当然这一切都是Go编译器在背后帮我们完成的。一个变量是在堆上分配,还是在栈上分配,是经过编译器的逃逸分析
之后得出的结论。
什么是逃逸分析
以前写C/C++代码时,为了提高效率,常常将pass-by-value
(传值)“升级”成pass-by-reference(传引用)
,企图避免构造函数的运行,并且直接返回一个指针。但是这里有一个坑;在函数内部定义了一个局部变量,然后返回这个局部变量的地址(指针)。这些局部变量是在栈上分配的(静态内存分配),一旦函数执行完毕,变量占据的内存会被销毁,任何对这个返回值作的动作(如解引用),都将扰乱程序的运行,甚至导致程序直接崩溃。比如下面的这段代码:
int *foo ( void ) { int t = 3; return &t; }
当然我们可以在函数内部使用 new函数构造一个变量(动态内存分配),然后返回此变量的地址,因为变量是在堆上创建的,所以函数退出时不会被销毁,但是调用者可能会忘记delete或者直接拿返回值传给其他函数,之后就再也不能delete它了,也就是发生了内存泄露。在编译原理中,分析指针动态范围的方法称之为逃逸分析
。通俗来讲,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。简单来说,逃逸分析决定一个变量是分配在堆上还是分配在栈上。
为什么要逃逸分析
逃逸分析
这种“骚操作”把变量合理地分配到它该去的地方,“找准自己的位置”。即使你是用new申请到的内存,如果我发现你竟然在退出函数后没有用了,那么就把你丢到栈上,毕竟栈上的内存分配比堆上快很多;反之,即使你表面上只是一个普通的变量,但是经过逃逸分析后发现在退出函数之后还有其他地方在引用,那我就把你分配到堆上。真正地做到“按需分配”。
如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销(占用CPU容量的25%);堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。
逃逸分析原理
Go逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么它就会发生逃逸。
简单来说,编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。Go语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上,相反,编译器通过分析代码来决定将变量分配到何处。
简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:
1:如果函数外部没有引用,则优先放到栈中;
2:如果函数外部存在引用,则必定放到堆中;
逃逸分析实例
Go 提供了相关的命令,可以查看变量是否发生逃逸。下面是一个示例代码:
package main import "fmt" func foo() *int { t := 3 return &t //返回对局部变量的引用 } func main() { x := foo() fmt.Println(*x) }
我们执行如下命令来查看逃逸情况:
//在 windows 下用双引号
//-m: 查看逃逸过程;-l: 禁止函数内联
$ go build -gcflags '-m -l' main.go
我们可以得到如下输出:
C:Userssweenzhanggosrc est>go build -gcflags "-m -l" main.go # command-line-arguments .main.go:6:2: moved to heap: t .main.go:12:13: main ... argument does not escape .main.go:12:14: *x escapes to heap
我们可以看到:t 发生了逃逸,局部变量应该被分配到栈上,但是现在逃逸到了堆上,这和我们前面分析的一致。但是 x 为什么也逃逸到了呢?这是因为有些函数参数为interface类型,比如fmt.Println(a ...interface{}),编译期间很难确定其参数的具体类型,也会发生逃逸。
当然我们也可以使用反汇编命令查看执行的汇编代码来查看变量是否发生逃逸,命令如下:
$ go tool compile -S main.go
截取部分结果如下:
0x0024 00036 (main.go:6) PCDATA $0, $1 0x0024 00036 (main.go:6) PCDATA $1, $0 0x0024 00036 (main.go:6) LEAQ type.int(SB), AX 0x002b 00043 (main.go:6) PCDATA $0, $0 0x002b 00043 (main.go:6) MOVQ AX, (SP) 0x002f 00047 (main.go:6) CALL runtime.newobject(SB) 0x0034 00052 (main.go:6) PCDATA $0, $1 0x0034 00052 (main.go:6) MOVQ 8(SP), AX 0x0039 00057 (main.go:6) MOVQ $3, (AX) 0x0040 00064 (main.go:7) PCDATA $0, $0 0x0040 00064 (main.go:7) PCDATA $1, $1 0x0040 00064 (main.go:7) MOVQ AX, "".~r0+32(SP) 0x0045 00069 (main.go:7) MOVQ 16(SP), BP 0x004a 00074 (main.go:7) ADDQ $24, SP 0x004e 00078 (main.go:7) RET
结果中标记出来的 newobject() 说明发生了堆内存的申请,我们在前面的一篇 Golang 内存分配 的博客中,详细讲解了该函数的内存分配过程,可移步观看。
总结
1:堆上动态分配内存比栈上静态分配内存,开销大很多;
2:变量分配在栈上需要能在编译期确定它的作用域,否则会分配到堆上;
3:Go编译器会在编译期对考察变量的作用域,并作一系列检查,如果它的作用域在运行期间对编译器一直是可知的,那么就会分配到栈上;
简单来说,编译器会根据变量是否被外部引用来决定是否逃逸。对于Go程序员来说,编译器的这些逃逸分析规则不需要掌握,我们只需通过上述命令来观察变量逃逸情况就行了。另外需要提醒的是:不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。
参考资料: