深入理解Go语言的内存逃逸分析

发表时间: 2023-07-24 17:37

Golang的内存逃逸分析

1、什么是逃逸分析?

逃逸分析是一种确定指针动态范围的方法, 可以分析在程序的哪些地方可以访问到指针。

2、逃逸分析做了什么?

Go是通过在编译期间通过编译器来进行逃逸分析, 决定一个变量是放堆上还是栈上。

3、所以怎么知道变量是分配在栈(stack)上还是堆(heap)上?

Go官网上有一段可以表明分配的规则:

准确来说, 你并不需要知道。Golang 中的变量只要被引用就一直会存活, 存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。知道变量的存储位置确实对程序的效率有帮助。

如果可能, Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。然而, 如果编译器不能确保变量在函数 return 之后不再被引用, 编译器就会将变量分配到堆上。

而且, 如果一个局部变量非常大, 那么它也应该被分配到堆上而不是栈上。当前情况下, 如果一个变量被取地址, 那么它就有可能被分配到堆上。然而, 还要对这些变量做逃逸分析, 如果函数 return 之后, 变量不再被引用, 则将其分配到栈上。

如果局部变量在函数外被引用了, 变量会被分配到堆上。

如果一个局部变量非常大, 无法放在栈上, 也会被放到堆上。

如果一个局部变量在函数return之后没有被引用了, 就会将其放到栈上。

4、逃逸分析的作用(好处)是什么?

逃逸分析这种"骚操作"把变量合理地分配到它该去的地方, "找准自己的位置"。即使你是用new申请到的内存, 如果我发现你竟然在退出函数后没有用了, 那么就把你丢到栈上, 毕竟栈上的内存分配比堆上快很多;

反之, 即使你表面上只是一个普通的变量, 但是经过逃逸分析后发现在退出函数之后还有其他地方在引用, 那我就把你分配到堆上。真正地做到"按需分配"。

如果变量都分配到堆上, 堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收, 而垃圾回收会占用比较大的系统开销(占用CPU容量的25%); 堆和栈相比, 堆适合不可预知大小的内存分配。

但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:"PUSH"和"RELEASE", 分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块, 之后要通过垃圾回收才能释放。

通过逃逸分析, 可以尽量把那些不需要分配到堆上的变量直接分配到栈上, 函数返回时就回收了资源; 堆上的变量少了, 会减轻分配堆内存的开销; 同时也会减少gc的压力, 提高程序的运行速度。

相反的, 如果没有逃逸分析把所有对象都分配到堆上会导致如下问题:

GC的压力不断增大

申请、分配、回收内存的系统开销增大

动态分配产生一定量的内存碎片

5 逃逸分析原理

Go逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用, 那么它就会发生逃逸。

简单来说,编译器会分析代码的特征和代码生命周期, Go中的变量只有在编译器可以证明在函数返回后不会再被引用的, 才分配到栈上, 其他情况下都是分配到堆上。

Go语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上, 相反, 编译器通过分析代码来决定将变量分配到何处。

简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:

(1):如果函数外部没有引用,则优先放到栈中;

(2):如果函数外部存在引用,则必定放到堆中;

逃逸的常见情况

(1) 发送指针的指针或值包含了指针到 channel 中, 由于在编译阶段无法确定其作用域与传递的路径, 所以一般都会逃逸到堆上分配。

(2) slices 中的值是指针的指针或包含指针字段。一个例子是类似[]*string 的类型。这总是导致 slice 的逃逸。即使切片的底层存储数组仍可能位于堆栈上, 数据的引用也会转移到堆中。

(3) slice 由于 append 操作超出其容量, 因此会导致 slice 重新分配。这种情况下, 由于在编译时 slice 的初始大小的已知情况下, 将会在栈上分配。如果 slice 的底层存储必须基于仅在运行时数据进行扩展, 则它将分配在堆上。

(4) 调用接口类型的方法。接口类型的方法调用是动态调度 - 实际使用的具体实现只能在运行时确定。考虑一个接口类型为 io.Reader 的变量 r。对 r.Read(b) 的调用将导致 r 的值和字节片b的后续转义并因此分配到堆上。

参考
http://npat-efault.github.io/programming/2016/10/10/escape-analysis-and-interfaces.html

(5) 尽管能够符合分配到栈的场景, 但是其大小不能够在在编译时候确定的情况, 也会分配到堆上

6、怎么知道变量是否发生了逃逸?

通过 go build -gcflags "-m -l" 就可以查看逃逸分析的过程和结果

实例1:

package mainfunc getStr() *string{str := new(string)*str = "chasel1"str2 := "chasel2"return &str2}func main() {_ = getStr()}

执行命令并输出:

> go build -gcflags "-m -l" main.go

# command-line-arguments

./main.go:6:2: moved to heap: str2

./main.go:4:12: new(string) does not escape

通过查看分析我们可以知道str2被函数外部引用了, 发生了逃逸(moved to heap), 分配到堆上, 而另外一个对象则是没有发生逃逸的。

实例2

package mainimport "fmt"func foo() *int {t := 3return &t //返回对局部变量的引用}func main() {x := foo()fmt.Println(*x)}

> # go build -gcflags "-m -l" main.go

./main.go:6:2: moved to heap: t

./main.go:12:13: main ... argument does not escape

./main.go:12:14: *x escapes to heap

我们可以看到:t 发生了逃逸, 局部变量应该被分配到栈上, 但是现在逃逸到了堆上(moved to heap),这和我们前面分析的一致。但是 x 为什么也逃逸到了呢?

这是因为有些函数参数为interface类型, 比如fmt.Println(a ...interface{}), 编译期间很难确定其参数的具体类型, 也会发生逃逸。

当然我们也可以使用反汇编命令查看执行的汇编代码来查看变量是否发生逃逸, 命令如下:

> # go tool compile -S main.go

0x0024 00036 (main.go:6) PCDATA 0x0024 00036 (main.go:6) PCDATA $0, $1,

0x0024 00036 (main.go:6) PCDATA , 0x0024 00036 (main.go:6) PCDATA $1, $0

0x0024 00036 (main.go:6) LEAQ type.int(SB), AX

0x002b 00043 (main.go:6) PCDATA 0x002b 00043 (main.go:6) PCDATA $0, $0, 0x002b 00043 (main.go:6) PCDATA $0, $0

0x002b 00043 (main.go:6) MOVQ AX, (SP)

0x002f 00047 (main.go:6) CALL runtime.newobject(SB)

0x0034 00052 (main.go:6) PCDATA 0x0034 00052 (main.go:6) PCDATA $0, $1,

0x0034 00052 (main.go:6) MOVQ 8(SP), AX

0x0039 00057 (main.go:6) MOVQ , (AX)

0x0040 00064 (main.go:7) PCDATA 0x0040 00064 (main.go:7) PCDATA $0, $0, 0x0040 00064 (main.go:7) PCDATA $0, $0

0x0040 00064 (main.go:7) PCDATA ,

0x0040 00064 (main.go:7) MOVQ AX, "".~r0+32(SP)

0x0045 00069 (main.go:7) MOVQ 16(SP), BP

0x004a 00074 (main.go:7) ADDQ , SP

0x004e 00078 (main.go:7) RET

runtime.newobject(SB) 说明发生了堆内存的申请


解决方法:

基本数据类型不要使用指针(*) 如: bool string int int8 int16 int32 int64 float32 float64等;

package mainimport "fmt"func foo() int {t := 3return t}func main() {x := foo()fmt.Println(x)}

总结

1:堆上动态分配内存比栈上静态分配内存, 开销大很多;

2:变量分配在栈上需要能在编译期确定它的作用域, 否则会分配到堆上;

3:Go编译器会在编译期对考察变量的作用域, 并作一系列检查, 如果它的作用域在运行期间对编译器一直是可知的, 那么就会分配到栈上;

简单来说, 编译器会根据变量是否被外部引用来决定是否逃逸。对于Go程序员来说, 编译器的这些逃逸分析规则不需要掌握, 我们只需通过上述命令来观察变量逃逸情况就行了。

另外需要提醒的是:不要盲目使用变量的指针(基本数据类型)作为函数参数, 虽然它会减少复制操作。但其实当参数为变量自身的时候, 复制是在栈上完成的操作, 开销远比变量逃逸后动态地在堆上分配内存少的多。

预先设定好slice长度, 避免频繁超出容量, 重新分配。

最后, 尽量写出少一些逃逸的代码, 提升程序的运行效率。