带GC语言给我们程序的编写带来了极大的便利,但是与此同时屏蔽了很多底层的细节,比如一个对象是在栈上分配还是在堆上分配。对于普通的代码来说虽然不需要关心这么多,但是作为强迫症程序猿,还是希望能让自己写出来的代码性能最优,所以还是需要了解什么是逃逸,以及如何判断是否发生了逃逸。
什么是堆和栈?
首先需要知道,我们说的堆和栈是啥。这个可不是数据结构里面的”堆”和”栈”,而是操作系统里面的概念。
栈
在程序中,每个函数块都会有自己的内存区域用来存自己的局部变量(内存占用少)、返回地址、返回值之类的数据,这一块内存区域有特定的结构和寻址方式,大小在编译时已经确定,寻址起来也十分迅速,开销很少。这一块内存地址称为栈。栈是线程级别的,大小在创建的时候已经确定,所以当数据太大的时候,就会发生”stack overflow”。
堆
在程序中,全局变量、内存占用大的局部变量、发生了逃逸的局部变量存在的地方就是堆,这一块内存没有特定的结构,也没有固定的大小,可以根据需要进行调整。简单来说,有大量数据要存的时候,就存在堆里面。堆是进程级别的。当一个变量需要分配在堆上的时候,开销会比较大,对于go这种带GC的语言来说,也会增加gc压力,同时也容易造成内存碎片。
为什么有的变量要分配在堆,有的要分配在栈?
这个问题要从C++说起了。在C++中,假设我们有以下代码:
1 | int* f1() { |
这时候程序结果是无法预期的,因为在函数f1中,i是一个局部变量,会分配在栈上,而栈在函数返回之后就失效了(Plan9 汇编中SP指针被修改),于是i的地址所存的值是不可预期的,后续在main中对返回的i的地址中的值的修改可能会修改掉程序运行的数据,造成结果无法预期。
所以对于需要返回一个地址回去的情况,在C++中需要用new来分配一块堆上的内存才行,因为堆是进程级别的,也就是全局的,除非程序猿手动释放,否则不会被回收(释放不好会段错误,忘了释放会内存泄漏),于是就可以使得这个地址不会再被使用到,可以安全地返回。
如何进行逃逸分析?
在golang中,所有内存都是由runtime管理的,程序猿不需要关心具体变量分配在哪里,什么时候回收,但是编译器需要知道这一点,这样才能确定函数栈帧大小、哪些变量需要”new”在堆上,所以编译器需要进行逃逸分析
。简单来说,逃逸分析
决定了一个变量是分配在栈上还是分配在堆上。
golang逃逸分析最基本的原则是:如果一个函数返回的是一个(局部)变量的地址,那么这个变量就发生逃逸
。
在golang里面,变量分配在何处和是否使用new无关,意味着程序猿无法手动指定某个变量必须分配在栈上或者堆上(自己撸asm的当我没说),所以我们需要通过一些方法来确定某个变量到底是分配在了栈上还是堆上。
我们用以下代码作为例子:
1 | package main |
在以上代码中,给f1增加了noinline标记,让go编译器不要将函数内联。
使用编译参数
golang提供了编译的参数让我们可以直观地看到变量是否发生了逃逸,只需要在go build时指定 -gcflags '-m'
即可:
1 | go build -gcflags '-m' escape.go |
这样可以很直观地看到在第10、11行,i发生了逃逸,内存会分配在堆上。
除了使用编译参数之外,我们还可以使用一种更底层的,更硬核,也更准确的方式来判断一个对象是否逃逸,那就是:直接看汇编!
使用汇编
我们使用go tool compile -S
生成汇编代码:
1 | $ go tool compile -S escape.go | grep escape.go:10 |
可以看到,这里的00040有调用runtime.newobject(SB)
这个方法,看到这个方法大家就应该懂了!
总结
以上提供了两种方法可以用来判断某个变量是否发生了逃逸,其中使用编译参数比较简单,使用汇编比较硬核。通过这两种方法分析完逃逸,就能进一步优化堆上内存数量,减轻GC压力了。