本文已参与「新人创作礼」活动,一起开启掘金创作之路。
golang逃逸分析
1.栈和堆
在golang中,应用程序的内存载体,可以简单的分为栈和堆。 栈的内存是由编译器自动进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁。 与栈不同的是,应用程序在运行时只会存在一个堆。狭隘地说,内存管理只是针对堆内存而言的。程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。
那么一个问题就来了,我们怎么知道一个对象是应该放在堆内存还是栈内存之上的呢?
其实golang的官网上已经给出了答案:
中文版:
我们发现了一个名词:逃逸分析。
2.逃逸分析
对于Golang程序,编译器是怎么判断一个变量到底是分配堆内存还是栈内存的呢?编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。
官网中虽然没有明确说明逃逸分析规则,但是有以下几点准则,是可以参考的:
- 逃逸分析是在编译器完成的,这是不同于jvm的运行时逃逸分析;
- 如果变量在函数外部没有引用,则优先放到栈中;
- 如果变量在函数外部存在引用,则必定放在堆中;
3.几种常见的逃逸情况:
我们可通过go build -gcflags '-m -l'
命令来查看逃逸分析结果,其中-m 打印逃逸分析信息,-l禁止内联优化。
3.1 指针逃逸
如果一个函数内部创建了一个对象(局部变量),但是在函数返回时是返回该对象的指针,那么该变量的生命周期就变了,即使当前函数执行结束了,但是变量的指针还在,并不是随着函数结束就被回收的,那么这个局部变量就会被分配在堆上,这就产生了指针逃逸。
package main
func foo(argVal int) *int {
var fooVal1 int = 11
var fooVal2 int = 12
var fooVal3 int = 13
var fooVal4 int = 14
var fooVal5 int = 15
println(&argVal, &fooVal1, &fooVal2, &fooVal3, &fooVal4, &fooVal5)
return &fooVal3
}
func main() {
mainVal := foo(666)
println(*mainVal, mainVal)
}
运行结果:
.\main.go:8:6: moved to heap: fooVal3
0xc000045f58 0xc000045f38 0xc000045f30 0xc00000e040 0xc000045f28 0xc000045f20
13 0xc00000e040
结果显示,逃逸分析结果显示fooVal3逃逸到了堆中,通过打印出来的变量地址我们可以发现,fooVal3的地址是0xc00000e040,与其他地址是不连续的。
3.2 interface{} 动态类型逃逸
在golang中空接口interface{}可以是任意类型,因此编译器并不能确定其类型,所以也会被分配到堆上。
package main
import "fmt"
func main() {
var valA interface{}
valA = 666
fmt.Println(&valA)
}
运行结果:
.\main.go:6:6: moved to heap: valA
.\main.go:7:7: 666 escapes to heap
.\main.go:8:13: ... argument does not escape
0xc00004a230
通过运行结果我们可以发现,interface{}变量varA逃逸到了堆中。
再来看下面这段程序:
package main
import "fmt"
func main() {
varB := 666
fmt.Println(varB)
}
运行结果:
.\main.go:7:13: ... argument does not escape
.\main.go:7:13: varB escapes to heap
666
可以看到,分析结果告诉我们变量varB逃逸到了堆上。但是,我们并没有外部引用啊,为什么也会有逃逸呢?为了看到更多细节,可以在语句中再添加一个-m参数:go build -gcflags '-m -m -l'
。得到信息如下
.\main.go:7:13: varB escapes to heap:
.\main.go:7:13: flow: {storage for ... argument} = &{storage for varB}:
.\main.go:7:13: from varB (spill) at .\main.go:7:13
.\main.go:7:13: from ... argument (slice-literal-element) at .\main.go:7:13
.\main.go:7:13: flow: {heap} = {storage for ... argument}:
.\main.go:7:13: from ... argument (spill) at .\main.go:7:13
.\main.go:7:13: from fmt.Println(... argument...) (call parameter) at .\main.go:7:13
.\main.go:7:13: ... argument does not escape
.\main.go:7:13: varB escapes to heap
varB逃逸是因为它被传入了fmt.Println的参数中,这个方法参数自己发生了逃逸。
func Println(a ...interface{}) (n int, err error)
因为fmt.Println的函数参数为interface类型,编译期不能确定其参数的具体类型,所以将其分配于堆上。
3.3 栈空间不足
如果程序中需要分配一个空间比较大的局部变量,栈空间已经不够分配了,那么也会被分配到堆上。
package main
func foo() {
s := make([]int, 10000, 10000)
for i := 0; i < len(s); i++ {
s[i] = i
}
}
func main() {
foo()
}
运行结果:
.\main.go:4:11: make([]int, 10000, 10000) escapes to heap
3.4 变量大小不确定
package main
func foo() {
n := 1
s := make([]int, n)
for i := 0; i < len(s); i++ {
s[i] = i
}
}
func main() {
foo()
}
运行结果:
.\main.go:5:11: make([]int, n) escapes to heap
3.5 闭包
package main
import "fmt"
func Add() func() int {
num := 0
return func() int {
num++
return num
}
}
func main() {
fn := Add()
fmt.Println(fn())
fmt.Println(fn())
}
运行结果:
.\main.go:6:2: moved to heap: num
.\main.go:7:9: func literal escapes to heap
.\main.go:14:13: ... argument does not escape
.\main.go:14:16: fn() escapes to heap
.\main.go:15:13: ... argument does not escape
.\main.go:15:16: fn() escapes to heap
1
2
上述代码块中,Add() 返回的是一个闭包,并且该闭包访问了外部变量num,那么num将会被分配到堆上,因为num此时生命周期已经不会随着Add() 函数的结束而被回收,直到 fn 被销毁,num才会被回收。
4.传值还是传指针
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。