什么是逃逸
一句话,逃逸分析是编译器用于决定将变量分配到栈上还是堆上的一种行为。
众所周知,函数的运行都在操作系统内存空间中的栈空间内。我们在栈上声明临时变量,分配内存,函数运行完毕后,回收内存。每个函数的栈空间都是独立的,其他函数没有权限访问。但在某些情况下,我们需要在函数结束以后访问栈上面的某些数据,这就涉及到内存逃逸了。
如果变量从栈上逃逸,那么他会逃到哪儿去呢?他会跑到堆上。由于栈上的变量是在函数结束的时候自动进行回收,回收代价比较小;而堆空间分配内存,则首先需要找到一块大小合适的内存,之后通过GC回收才能释放。对于这种情况,频繁使用垃圾回收会占用比较大的开销,所以要尽量分配内存到栈上,减少GC的压力。
逃逸分析基本过程
Go语言的逃逸分析最基本的原则:如果一个函数返回一个对变量的引用,那么他就会发生逃逸。
在任何情况下,如果一个值被分配到了栈空间以外的地方,那么它一定是被分配到了堆上。简言之:编译器会分析代码的特征和生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的情况下,才会被分配到栈上,否则会被分配到堆上。
不同于C++中的new,Go语言中的new关键字不一定会将内存分配到堆空间上,在Go语言中,没有关键字或者函数可以直接将变量分配到堆上,而是通过编译器来分析代码决定将变量分配到何处。
一句话:
编译器会根据变量是否被外部引用来决定是否逃逸。
如果函数外部没有引用,则优先放到栈中;
如果函数外部存在引用,则必定放到堆中;
常见逃逸情况
指针逃逸
我们知道传递指针可以减少底层值的拷贝,提高效率。但是如果拷贝的数据量小,指针传递会产生逃逸,可能会使用堆空间,增加GC负担,所以传递指针不一定是高效的。
比如:
go
package main
type Student struct {
Name string
Age int
}
func StudentRegister(name string, age int) *Student {
s := new(Student) // 局部变量s逃逸到堆
s.Name = name
s.Age = age
return s
}
func main() {
StudentRegister("similar", 18)
}
虽然函数StudentRegister
内部s为局部变量,但是由于返回了指针,其指向的内存地址不会是栈而是堆,这是典型的逃逸案例。
使用命令 go build -gcflags '-m -l' main.go
,得到:
bash
# command-line-arguments
./escape.go:8:22: leaking param: name
./escape.go:9:10: new(Student) escapes to heap # 表示该行内存发生了逃逸现象
栈空间不足
如果分配太大容量的slice在栈上,当栈空间不足存放当前对象或者无法判断当前切片长度时就会将对象分配到堆中。
go
package main
func MakeSlice() {
s := make([]int, 10000, 10000)
for index := range(s){
s[index] = index
}
}
func main() {
MakeSlice()
}
同样使用命令go build -gcflags '-m -l' main.go
:
arduino
# command-line-arguments
./escape_1.go:4:11: make([]int, 10000) escapes to heap
动态类型逃逸
很多函数的参数为interface
类型,比如:
go
func Printf(format string, a ...interface{}) (n int, err error)
func Scanf(format string, a ...interface{}) (n int, err error)
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)
在编译时很难确定其参数的具体类型,也能产生逃逸。
变量大小不确定
在创建切片,初始化切片容量的时候,有时会传入一个变量指定其大小,由于变量的值不能被编译器确定,所以不能确定其占用空间大小,从而编译器可能会直接将变量分配到堆上。
go
package main
func MakeSlice() {
length := 1
a := make([]int, length, length)
for i := 0; i < length; i++ {
a[i] = i
}
}
func main() {
MakeSlice()
}
编译结果:
perl
# command-line-arguments
./escape_1.go:5:11: make([]int, length, length) escapes to heap
常见的逃逸情况总结
- 指针逃逸:函数内部返回一个局部变量指针
- 分配大对象:导致栈空间不足,不得不分配到堆上
- 调用接口类型的方法,接口类型的方法调用是动态调度 - 实际使用的具体实现只能在运行时确定。
- 尽管能够符合分配到栈的场景,但是其大小不能在编译的时候确定,也会分配到堆上。
如何避免
- Go中的接口类型的方法调用是动态调度,因此不能够在编译阶段确定,所有类型结构转换成接口的过程会涉及到内存逃逸的情况发生。如果对于性能要求比较高且访问频次比较高的函数调用,应该尽量避免使用接口类型。
- 由于切片一般都是使用在函数传递的场景下,而且切片在append的时候可能会涉及到重新分配内存,如果切片在编译期间的大小不能够确认或者大小超出栈的限制,多数情况下都会被分配到堆上。
总结
- 堆上分配内存比栈上分配内存,开销大很多。
- 变量分配在栈上需要能够在编译期确定他的作用域,否则会分配到堆上。
- Go语言编译器会通过变量是否被外部引用类决定是否逃逸
- 通过
go build -gcflags '-m'
命令可以观察变量是否逃逸 - 不能盲目使用变量的指针作为函数参数,虽然会减少复制操作,但是当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少得多。