跳到主要内容

逃逸分析

一 变量逃逸

由于栈的性能相对较高,变量是分配到了栈,还是堆中,对程序的性能和安全有较大影响。

逃逸分析是一种确定指针动态范围的方法,用来分析程序的哪些地方可以访问到指针。当一个变量或对象在子程序中分配内存时,一个指向变量或对象的指针可能逃逸到其他执行线程中,甚至去调用子程序。

指针逃逸:一个对象的指针在任何一个地方都可以访问到。

逃逸分析的结果可以用来保证指针的声明周期只在当前进程或线程中。

func toHeap() *int {
var x int
return &x
}

func toStack() int {
x := new(int)
*x = 1
return *x
}

func main() {

}

上述两个函数分别创建了2个变量,但是申请的位置是不一样的。打开逃逸分析日志:

go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:4:6: moved to heap: x
./main.go:9:10: toStack new(int) does not escape

如上所示,toHeap()中的x分配到了堆上,toStack()中的x最后分配到了栈上。does not escape 表示未逃逸。同样是变量内存的申请,两个函数获得的位置却是不一样的!!

这是因为go在一定程序上消除了堆和栈的区别,在编译的时候会自动进行变量逃逸分析,不逃逸的对象就放到栈上,可能逃逸的对象就放到堆上。

  • 一般情况下,函数的局部变量会分配到函数栈上
  • 变量在函数return之后还被引用,会被分配到堆上,比如上述的案例toHeap()

Go的GC判断变量是否回收的实现思路:从每个包级的变量、每个当前运行的函数的局部变量开始,通过指针和引用的访问路径遍历,是否可以找到该变量,如果不存在这样的访问路径,那么说明该变量是不可达的,也就是说它是否存在并不会影响后续计算结果。

示例:

var global *int
func f() {
var x int
x = 1
global = &x
}
func g() {
y := new(int)
*y = 1
}

上述的函数调用结果说明:

  • 虽然x变量定义在f函数内部,但是其必定在堆上分配,因为函数退出后仍然能通过包一级变量global找到,这样的变量,我们称之为从函数f中逃逸了
  • g函数返回时,变量*y不可达,因此没有从函数g中逃逸,其内存分配在栈上,会马上被被回收。(当然也可以选择在堆上分配,然后由Go语言的GC回收这个变量的内存空间)

二 变量逃逸分析案例

2.1 案例一

在C++中,开发者需要自己手动分配内存来适应不同的算法需求。比如,函数局部变量尽量使用栈(函数退出,内部变量也依次退出),全局变量、结构体使用堆。

Go语言将这个过程整合到了编译器中,命名为“变量逃逸分析”,这个技术由编译器分析代码的特征和代码生命期,决定是堆还是栈进行内存分配。

func test(num int) int {
var t int
t = num
return t
}

//空函数,什么也不做
func void() {

}

func main() {

var a int //声明变量并打印
void() //调用空函数
fmt.Println(a, test(0)) //打印a,并调用test

}

运行上述代码:

# -gcflags参数是编译参数,-m表示进行内存分析,-l表示避免程序内联(优化)
go run -gcflags "-m -l" test.go

得到结果:

# command-line-arguments
./test.go:22:13: a escapes to heap # 29行的变量a逃逸到堆
./test.go:22:21: test(0) escapes to heap # test(0)调用逃逸到堆
./test.go:22:13: main ... argument does not escape # 默认提示
0 0

test(0)调用逃逸到堆,但是test()函数会返回一个整数值,这个值被fmt.Println()使用后还是会在其声明后继续在main函数中存在。

test函数中的声明的变量t是整型,该值通过test函数返回值逃出了函数,t变量的值被复制并作为test函数的返回值返回,即使t在test函数中分配的内存被释放,也不会影响main函数中使用test返回的值,t变量使用栈分配不会影响结果。

2.2 案例2

type Data struct {

}

func test() *Data {
var d Data
return &d // 返回局部变量地址
}

func main() {
fmt.Println(test()) //输出 &{}
}

继续使用命令:go run -gcflags "-m -l" test.go

# command-line-arguments
./test.go:11:9: &d escapes to heap
./test.go:10:6: moved to heap: d # 新增提示:将d移到堆中
./test.go:15:18: test() escapes to heap
./test.go:15:13: main ... argument does not escape
&{}

moved to heap: d 表示go编译器已经确认如果c变量被分配在栈上是无法保证程序最终结果的,如果坚持这样做,test()的返回值是僵尸Data结构的一个不可预知的内存地址。这种情况一般是C/C++语言中容易犯错的地方:引用了一个函数局部变量的地址。Go最终选择将d的Data结构分配到堆上,然后由垃圾回收期去回收c的内存。

三 原则总结

在使用Go语言进行编程时,Go语言设计者不希望开发者将精力放在内存应该分配在栈还是堆上,编译器会自动帮助开发者完成这个纠结的选择。

编译器觉得变量应该分配在堆还是栈上的原则是:

  • 变量是否被取地址
  • 变量是否发生逃逸