Go 语言中的变量究竟是分配在栈上、还是分配在堆上?逃逸分析告诉你答案?

楔子

对于像C这样的语言,不同位置的变量应该申请在内存的哪个区都是很固定的,比如:全局变量会在全局区创建,函数里面的变量会在栈区创建,并且我们还可以手动地从堆区申请内存、手动地释放内存。但是到了go语言中,这些都不需要我们管了,我们不需要关心变量到底申请在哪个区,编译器的垃圾回收机制会自动帮我们判断创建的变量到底应该分配到哪个区。可是go的编译器是如何得知的呢?比如这样一个例子:

func sum(a int, b int) *int {
    var c = a + b
    return &c
}

这段代码表示接收两个整型,然后相加用变量存储起来、返回变量的指针。这段代码在C程序员的眼中肯定是有问题的,但是在go语言中这是完全正常的代码。因为这个变量c是分配在堆上的,如果我们返回的不是c的指针,而是c,那么c这个变量就会分配在栈上面,所以我们看到一个变量究竟分配在什么地方,go编译器会帮我们进行检测。如果返回一个值的话(golang是值拷贝),那么原来的变量c对应的内存就会被回收,如果返回的是指针(&c),那么c对应的内存就不会被回收,因为go编译器知道要是回收了,那么返回的指针就获取不到指向的值了,于是就会把c分配到堆上。那么编译器是如何检测的呢,答案是通过逃逸分析。

什么是逃逸分析?

逃逸分析:通过指针的动态范围决定一个变量究竟是分配在栈上还是应该分配在堆上。

我们知道栈区是可以自动清理的,所以栈区的效率很高,但是不可能把所有的对象都申请在栈上面,而且栈空间也是有限的。但如果所有的对象都分配在堆区的话,堆又不像栈那样可以自动清理,因此会频繁造成垃圾回收,从而降低运行效率。这里多提一句,Python中的对象都是分配在堆上的,Python中的对象本质上就是c中malloc在堆上申请的一块内存,尽管Python通过内存池降低了频繁和操作系统交互,但还是架不住效率低。所以在go中,会通过逃逸分析,把那些一次性的对象分配到栈区,如果后续还有变量指向,那么就放到堆区。

逃逸分析的标准

首先可以肯定的是,如果函数里面的变量返回了一个地址,那么这个变量肯定会发生逃逸。go编译器会判断变量的生命周期,如果编译器认为函数结束后,这个变量不再被外部的引用了,会分配到栈,否则分配到堆。

package main

import "fmt"

func sum(a int, b int) *int {
    var c = a + b
    var d = 1
    var e = new(int)
    fmt.Println(&d)
    fmt.Println(&e)
    return &c
}
//比如这里的变量d, 尽管通过&d获取了它的地址, 但是这仅仅是打印。
//而e虽然调用了new方法, 但这并不能成为分配到堆区的理由
//因为d和e并没有被外部引用, 所以不好意思, sum函数执行结束, 这两位老铁必须"见上帝"
//但是对于c, 我们返回了它的指针, 既然返回了指针, 那么就代表这个变量对应的内存可以被外部访问, 所以会逃逸到堆

因此有两个结论:

  • 如果一个函数结束之后外部没有引用,那么优先分配到栈中(如果申请的内存过大,栈区存不下,会分配到堆)。
  • 如果一个函数结束之后外部还有引用,那么必定分配到堆中

逃逸分析演示

package main

import "fmt"

func sum(a int, b int) *int {
    var c = a + b
    return &c
}

func main() {
    var p = sum(1, 2)
    fmt.Println(p)
}

通过命令go build -gcflags "-m -l" xxx.go观察golang是如何进行逃逸分析的

# command-line-arguments
.\1.go:7:9: &c escapes to heap
.\1.go:6:6: moved to heap: c
.\1.go:12:13: p escapes to heap
.\1.go:12:13: main ... argument does not escape

我们看到变量c发生了逃逸,这和我们想的一样,但是为什么main函数里面的p居然也逃逸了。因为编译期间不确定变量类型的话,那么也会发生逃逸。除此之外,还可以通过反汇编命令来查看:go tool compile -S xxx.go

总结

堆上动态内存分配的开销比栈要大很多,所以有时我们传递值比传递指针更有效率。因为复制是栈上完成的操作,开销要比变量逃逸到堆上再分配内存要少的多,比如说:

func func1(a int, b int) *int {
    var c = a + b
    func2(&c)
}

func func2(p *int){

}

因为func2里面接收一个指针,所以func1里面的c变量毫无疑问会逃逸到堆、在堆上分配;而如果func2接收不是指针,那么c变量会直接栈上分配,然后只需要拷贝一个值即可;因为是栈,所以拷贝值的效率反而会更高一些,要是逃逸到堆、再分配的话,效率反而更低。因此根据场景具体分析,到底函数要不要接受指针,总之最好学会擅用go语言的逃逸分析。当然我们不需要知道编译器的逃逸分析规则,只需要观察程序的运行情况就行了。