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

楔子

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

可是 Go 的编译器是如何得知的呢?比如这样一个例子:

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

这个函数接收两个整数,然后相加并用变量保存起来、再返回变量的指针。这段代码在 C 程序员的眼中肯定是有问题的,但在 Go 里面却是完全正常的代码。因为代码中的变量 c 是分配在堆上的,如果我们返回的不是 c 的指针,而是 c,那么 c 这个变量就会分配在栈上面。

所以我们看到一个变量究竟分配在什么地方,Go 编译器会帮我们进行检测。如果返回的是值,那么原来的变量 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 观察 Go 编译器是如何进行逃逸分析的。

我们看到变量 c 发生了逃逸,这和我们想的是一样的。除此之外,还可以通过反汇编命令来查看: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 的逃逸分析。当然我们不需要知道编译器的逃逸分析规则,只需要观察程序的运行情况就行了。

posted @ 2019-08-31 22:12  古明地盆  阅读(5164)  评论(0编辑  收藏  举报