什么是逃逸
垃圾回收是Go的一个很方便的特性–其自动的内存管理使代码更整洁,同时减少内存泄漏的可能性。但是,由于垃圾回收需要周期性的停止程序从而去收集不用的对象,不可避免的会增加额外开销。Go编译器是智能的,它会自动决定一个变量是应该分配在堆上从而在将来便于回收,还是直接分配到函数的栈空间。对于分配到栈上的变量,其与分配到堆上的变量不同之处在于:随着函数的返回,栈空间会被销毁,从而栈上的变量被直接销毁,不需要额外的垃圾回收开销。
但是在有些情况下,本应该分配到栈上的变量,被编译器智能的分配到了堆上,变量从栈逃逸到堆上就是逃逸现象。
Go的逃逸分析相对于Java虚拟机的HotSpot来说更为基础。基本规则就是,如果一个变量的引用从声明它的函数中返出去了,则发生“逃逸”,因为它有可能在函数外被别的内容使用,所以必须分配到堆上。
以下是一些触发内存逃逸的情况:
- 在函数中返回指针:如果在函数中创建一个局部变量,然后返回它的指针,那么这个变量很可能会逃逸到堆上,因为它需要在函数退出后仍然可访问。
- 在函数中开启 goroutine:如果在函数内部开启了一个 goroutine,并将局部变量传递给这个 goroutine,这个变量可能会逃逸,因为 goroutine 可能在函数退出后继续访问该变量。
- 变量被闭包使用:如果一个闭包引用了外部函数的局部变量,这个变量也可能会逃逸到堆上,因为闭包可能会在函数退出后继续存在。
- 变量占用空间太大:如果一个局部变量很大,超过了栈的大小限制,编译器可能会将其分配到堆上,以避免栈溢出。
如下几种情况会比较复杂:
- 函数调用其他函数
- 引用作为结构体的成员变量
- 切片和映射
- Cgo指向变量的指针
以下是一些避免内存逃逸的方法:
- 避免闭包: 闭包可能导致变量的生命周期延长,从而导致内存逃逸。尽量避免在闭包中使用外部变量。
- 避免返回指针或引用: 返回指向局部变量的指针或引用会导致内存逃逸。(Go有三个引用类型:slice,map,chan)
- 返回数组而不是切片(slice):数组是值类型的,切片是引用类型的。
- 使用值类型的接收器(receiver): 当定义方法时,如果不需要修改接收器的状态,尽量使用值类型的接收器而不是指针接收器,可以减少内存逃逸的可能性。
- 使用编译器分析工具:可以使用go build -gcflags="-m"命令来触发编译器的逃逸分析报告。这会在编译过程中输出逃逸分析的结果,帮助我们了解哪些变量逃逸到了堆上。
为了实现逃逸分析,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的逃逸分析的局限性,许多变量会被分配到堆上。
最后,来看下映射和切片是怎样的呢?请记住,切片和映射实际上只是具有指向堆内存的指针的Go结构:slice结构是暴露在reflect包中(SliceHeader)。map结构就更隐蔽了:存在于hmap。如果这些结构体不逃逸,将会被分配到栈上,但是其底层的数组或者哈希桶中的实际数据会被分配到堆上去。避免这种情况的唯一方法是分配一个固定大小的数组(例如[10000]int)。
如果你剖析过你的程序堆使用情况(https://blog.golang.org/pprof),并且想减少垃圾回收的消耗,可以将频繁分配到堆上的变量移到栈上,可能会有较好的效果。进一步研究HotSpot JVM是如何进行逃逸分析的会是一个不错的话题,可以参考这个链接,这个里面主要讲解了栈分配,以及有关何时可以消除同步的检测。
实践意义
在堆上和栈上分配内存也有很大差异,堆区使用的是虚拟内存,分配速度较慢,栈区使用的是真实内存,分配速度很快,差别有十倍左右。
在golang中,有个问题就是,在使用结构体的时候是否要以指针的形式,排除了其实际应用之外,另外一个考虑的因素就是变量逃逸导致的GC,以及堆区和栈区的分配速度的差异。