Golang逃逸分析
2015-10-18
垃圾回收是Go的一个很方便的特性--其自动的内存管理使代码更整洁,同时减少内存泄漏的可能性。但是,由于垃圾回收需要周期性的停止程序从而去收集不用的对象,不可避免的会增加额外开销。Go编译器是智能的,它会自动决定一个变量是应该分配在堆上从而在将来便于回收,还是直接分配到函数的栈空间。对于分配到栈上的变量,其与分配到堆上的变量不同之处在于:随着函数的返回,栈空间会被销毁,从而栈上的变量被直接销毁,不需要额外的垃圾回收开销。
Go的逃逸分析相对于Java虚拟机的HotSpot来说更为基础。基本规则就是,如果一个变量的引用从声明它的函数中返出去了,则发生“逃逸”,因为它有可能在函数外被别的内容使用,所以必须分配到堆上。如下几种情况会比较复杂:
- 函数调用其他函数
- 引用作为结构体的成员变量
- 切片和映射
- Cgo指向变量的指针
为了实现逃逸分析,Go会在编译阶段构造函数调用关系图,同时跟踪入参和返回值的流程。一个函数如果只是引用一个参数,但这个引用并没有返出函数的话,这个变量也不会逃逸。如果一个函数返回了一个引用,但是这个引用被栈中的其他函数解除或者没有返回此引用,则也不会逃逸。为了论证几个例子,可以在编译时加-gcflags '-m'
参数,这个参数会打印逃逸分析的详细信息:
package main
type S struct {}
func main() {
var x S
_ = identity(x)
}
func identity(x S) S {
return x
}
你可以执行go run -gcflags '-m -l'
(注:原文中略了go代码文件名)来编译这个代码,-l参数是防止函数identity
被内联(换个时间再讨论内联这个话题)。你将会看到没有任何输出!Go使用值传递,所以main
函数中的x
这个变量总是会被拷贝到函数identity
的栈空间。通常情况下没有使用引用的代码都是通过栈空间来分配内存。所以不涉及逃逸分析。下面试下困难一点的:
package main
type S struct {}
func main() {
var x S
y := &x
_ = *identity(y)
}
func identity(z *S) *S {
return z
}
其对应的输出是:
./escape.go:11: leaking param: z to result ~r1
./escape.go:7: main &x does not escape
第一行显示了变量z
的“流经”:入参直接作为返回值返回了。但是函数identity
没有取走z
这个引用,所以没有发生变量逃逸。在main
函数返回后没有任何对x
的引用存在,所以x
这个变量可以在main
函数的栈空间进行内存分配。
第三次实验:
package main
type S struct {}
func main() {
var x S
_ = *ref(x)
}
func ref(z S) *S {
return &z
}
其输出为:
./escape.go:10: moved to heap: z
./escape.go:11: &z escapes to heap
现在有了逃逸发生。记住Go是值传递的,所以z
是对变量x
的一个拷贝。函数ref
返回一个对z
的引用,所以z
不能在栈中分配,否则当函数ref
返回时,引用会指向何处呢?于是它逃逸到了堆中。其实执行完ref
返回到main
函数中后,main
函数丢弃了这个引用而不是解除引用,但是Go的逃逸分析还不够机智去识别这种情况。
值得注意的是,在这种情况下,如果我们不停止引用,编译器将内联ref
。
如果结构体成员定义的是引用又会怎样呢?
package main
type S struct {
M *int
}
func main() {
var i int
refStruct(i)
}
func refStruct(y int) (z S) {
z.M = &y
return z
}
其输出为:
./escape.go:12: moved to heap: y
./escape.go:13: &y escapes to heap
在这种情况下,尽管引用是结构体的成员,但Go仍然会跟踪引用的流向。由于函数refStruct
接受引用并将其返回,因此y
必须逃逸。对比如下这个例子:
package main
type S struct {
M *int
}
func main() {
var i int
refStruct(&i)
}
func refStruct(y *int) (z S) {
z.M = y
return z
}
其输出为:
./escape.go:12: leaking param: y to result z
./escape.go:9: main &i does not escape
尽管在main
函数中对i
变量做了引用操作,并传递到了函数refStruct
中,但是这个引用的范围没有超出其声明它的栈空间。这和之前的那个程序语义上有细微的差别,这个会更高效:在上一个程序中,变量i
必须分配在main
函数的栈中,然后作为参数拷贝到函数refStruct
中,并将拷贝的这一份分配在堆上。而在这个例子中,i
仅被分配一次,然后将引用到处传递。
再来看一个有点弯弯绕的例子:
package main
type S struct {
M *int
}
func main() {
var x S
var i int
ref(&i, &x)
}
func ref(y *int, z *S) {
z.M = y
}
其输出为:
./escape.go:13: leaking param: y
./escape.go:13: ref z does not escape
./escape.go:9: moved to heap: i
./escape.go:10: &i escapes to heap
./escape.go:10: main &x does not escape
问题在于,y
被赋值给了一个入参结构体的成员。Go并不能追溯这种关系(go只能追溯输入直接流向输出),所以逃逸分析失败了,所以变量只能分配到堆上。由于Go的逃逸分析的局限性,许多变量会被分配到堆上,请参考此链接,这里面记录了许多案例(从Go1.5开始)。
最后,来看下映射和切片是怎样的呢?请记住,切片和映射实际上只是具有指向堆内存的指针的Go结构:slice
结构是暴露在reflect
包中(SliceHeader
)。map
结构就更隐蔽了:存在于hmap。如果这些结构体不逃逸,将会被分配到栈上,但是其底层的数组或者哈希桶中的实际数据会被分配到堆上去。避免这种情况的唯一方法是分配一个固定大小的数组(例如[10000]int
)。
如果你剖析过你的程序堆使用情况(https://blog.golang.org/pprof
),并且想减少垃圾回收的消耗,可以将频繁分配到堆上的变量移到栈上,可能会有较好的效果。进一步研究HotSpot JVM是如何进行逃逸分析的会是一个不错的话题,可以参考这个链接,这个里面主要讲解了栈分配,以及有关何时可以消除同步的检测。