..

Golang-逃逸分析

什么是堆/栈

  • 堆(Heap):一般来讲是人为手动进行管理,手动申请、分配、释放。一般所涉及的内存大小并不定,一般会存放较大的对象。另外其分配相对慢,涉及到的指令动作也相对多
  • 栈(Stack):由编译器进行管理,自动申请、分配、释放。一般不会太大,我们常见的函数参数(不同平台允许存放的数量不同),局部变量等等都会存放在栈上

什么是逃逸分析

我们如何知道一个对象是应该放在堆内存,还是栈内存之上呢?可以官网的FAQ(地址:https://go.dev/doc/faq#stack_or_heap)中找到答案.

大致意思是:

从正确性的角度来看,你不需要知道。Go中的每个变量都存在,只要有对它的引用。实现选择的存储位置与语言的语义无关。 存储位置确实对编写高效的程序有影响。如果可能的话,Go编译器将为该函数的堆栈帧中的函数分配局部变量。但是,如果编译器不能证明该变量在函数返回后没有被引用,那么编译器必须在有垃圾回收的堆上分配该变量,以避免悬空指针错误。此外,如果局部变量非常大,那么将其存储在堆中可能比存储在栈中更有意义。 在当前编译器中,如果变量的地址被占用,那么该变量就是堆上分配内存的候选变量。然而,基本的逃逸分析可以识别出某些情况下,这些变量将不会存在于函数返回之后,而可以驻留在栈中。

在什么阶段确立逃逸

Go语言虽然没有明确说明逃逸分析规则,但是有以下几点准则,是可以参考的。

  • 逃逸分析是在编译器完成的,这是不同于jvm的运行时逃逸分析
  • 如果变量在函数外部没有引用,则优先放到栈中
  • 如果变量在函数外部存在引用,则必定放在堆中

逃逸规则

我们其实都知道一个普遍的规则,就是如果变量需要使用堆空间,那么他就应该进行逃逸。但是实际上Golang并不仅仅把逃逸的规则如此泛泛。Golang会有很多场景具备出现逃逸的现象。

一般我们给一个引用类对象中的引用类成员进行赋值,可能出现逃逸现象。可以理解为访问一个引用对象实际上底层就是通过一个指针来间接的访问了,但如果再访问里面的引用成员就会有第二次间接访问,这样操作这部分对象的话,极大可能会出现逃逸的现象。

Go语言中的引用类型有func(函数类型),interface(接口类型),slice(切片类型),map(字典类型),channel(管道类型),*(指针类型)等。

怎么确定是否逃逸

可以使用编译器提供的指令-gcflags 就可以看到详细的逃逸分析过程,命令如下:

go build -gcflags '-m -l' main.go

其指令涉及的参数如下:

  • -m:打印出逃逸分析的优化策略,实际上最多总共可以用 4 个 -m,但是这样子调试的信息量较大,一般用一个就足够了。
  • -l :禁用函数内联,在这里禁用掉 inline 能更好的观察逃逸情况,减少干扰。

常见逃逸案例分析

new的变量在栈还是堆

package main

func foo(argVal int) (*int) {

    var fooVal1 = new(int)
    var fooVal2 = new(int)
    var fooVal3 = new(int)
    var fooVal4 = new(int)
    var fooVal5 = new(int)
	
    //此处循环是防止go编译器将foo优化成inline(内联函数)
    //如果是内联函数,main调用foo将是原地展开,所以fooVal 1-5 相当于main作用域的变量
    //即使fooVal3发生逃逸,地址与其他也是连续的
    for i := 0; i < 5; i++ {
        println(argVal, fooVal1, fooVal2, fooVal3, fooVal4, fooVal5)
    }

    //fooVal3
    return fooVal3
}


func main() {
    mainVal := foo(666)

    println(*mainVal, mainVal)
}

我们将fooVal 1-5 全部用new的方式来开辟, 编译运行看结果, 显然fooVal3发生了逃逸

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:5:29: new(int) does not escape
./main.go:6:29: new(int) does not escape
./main.go:7:29: new(int) escapes to heap
./main.go:8:29: new(int) does not escape
./main.go:9:29: new(int) does not escape

暴露外部指针

package main

func foo() *int {
	a := 666
	return &a
}

func main() {
	_ = foo()
}

逃逸分析如下,变量a发生了逃逸

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:4:2: moved to heap: a

这种情况直接满足我们上述中的原则:变量在函数外部存在引用。这个很好理解,因为当函数执行完毕,对应的栈帧就被销毁,但是引用已经被返回到函数之外。 如果这时外部从引用地址取值,虽然地址还在,但是这块内存已经被释放回收了,这就是非法内存,问题可就大了。所以,很明显,这种情况必须分配到堆上。

变量所占内存过大

package main

func foo(){
	s := make([]int, 10000, 10000)
    for i:=0; i < len(s); i++ {
		s[i] = i
    }
}

func main() {
	foo()
}

逃逸分析如下,变量make([]int, 10000, 10000)发生了逃逸

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:4:11: make([]int, 10000, 10000) escapes to heap

当我们创建了一个容量为10000的int类型的底层数组对象时,由于对象过大,它也会被分配到堆上。这里我们不禁要想一个问题,为啥大对象需要分配到堆上。

小结

  • 逃逸分析是在编译器完成的,不是在运行时分析。
  • 静态分配到栈上,性能一般会比动态分配到堆上好。
  • 底层分配到堆,还是栈,实际上对你来说是透明的,不需要过度关心、纠结。
  • 每个 Go 版本的逃逸分析都会有所不同(会改变,会优化)。
  • 到处都用指针传递并不一定是最好的,要合理的用对。
  • 遇到怀疑,直接通过 go build -gcflags '-m -l' 可以看到逃逸分析的过程和结果,胜过道听途说。

参考