逃逸分析是什么
逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为。
一个变量是在堆上分配,还是在栈上分配,是经过编译器的逃逸分析之后得出的“结论”。
Go 语言里编译器的逃逸分析:它是编译器执行静态代码分析后,对内存管理进行的优化和简化。
在编译原理中,分析指针动态范围的方法被称之为逃逸分析。通俗来讲,当一个对象的指针被多个方法或线程引用时,则称这个指针发生了逃逸。逃逸分析决定一个变量是分配在堆上还是分配在栈上。
逃逸分析有什么作用
函数的运行都是在栈上面运行的,在栈上面声明临时变量,分配内存,函数运行完毕之后,回收内存,每个函数的栈空间都是独立的,其他函数是无法进行访问,但是在某些情况下栈上面的数据需要在函数结束之后还能被访问,这时候就会设计到内存逃逸了,什么是逃逸,就是抓不住
如果变量从栈上面逃逸,会跑到堆上面,栈上面的变量在函数结束的时候回自动回收,回收代价比较小,栈的内存分配和使用一般只需要两个CPU指令"PUSH"和"RELEASE",分配和释放,而堆分配内存,则是首先需要找到一块大小合适的内存,之后通过GC回收才能释放,对于这种情况,频繁的使用垃圾回收,则会占用比较大的系统开销,所以尽量分配内存到栈上面,减少gc的压力,提高程序运行速度
Go 的垃圾回收,让堆和栈对程序员保持透明。真正解放了程序员的双手,让他们可以专注于
业务,“高效”地完成代码编写,而把那些内存管理的复杂机制交给编译器。
逃逸分析把变量合理地分配到它该去的地方,“找准自己的位置”。即使是用 new 函数申请到的内存,如果编译器发现这块内存在退出函数后就没有使用了,那就分配到栈上,毕竟栈上的内存
分配比堆上快很多;反之,即使表面上只是一个普通的变量,但是经过编译器的逃逸分析后发现,
在函数之外还有其他的地方在引用,那就分配到堆上。真正地做到“按需分配”。
如果变量都分配到堆上,堆不像栈可以自动清理。就会引起 Go 频繁地进行垃圾回收,而垃圾
回收会占用比较大的系统开销。
堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片;栈内存分配则会非常快。栈分配内存只需要通过 PUSH 指令,并且会被自动释放;而堆分配内存首先需要去找到一个大小合适的内存块,之后要通过垃圾回收才能释放。
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻堆内存分配的开销,同时也会减少垃圾回收(Garbage Collection,GC)的压力,提高程序的运行速度。
逃逸分析过程/是怎么完成的
Go 语言逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么这个变量就会发生逃逸。
在任何情况下,如果一个值被分配到了栈之外的地方,那么一定是到了堆上面。简而概之:编译器会分析代码的特征和代码的生命周期,Go 中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。
Go 语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上。相反,编译器通过
分析代码来决定将变量分配到何处。
对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果考虑到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:
1)如果变量在函数外部没有引用,则优先放到栈上。
2)如果变量在函数外部存在引用,则必定放到堆上。
针对第一条,放到堆上的情形:定义了一个很大的数组,需要申请的内存过大,超过了栈的存储能力。
如何确定是否发生逃逸
Go 提供了相关的命令,可以查看变量是否发生逃逸。使用前面提到的例子:
package main
import "fmt"
func foo() *int {t := 3return &t;
}
func main() {x := foo()fmt.Println(*x)
}
foo 函数返回一个局部变量的指针,使用 main 函数里变量 x 接收它。执行如下命令:
go build -gcflags '-m -l' main.go
其中 -gcflags 参数用于启用编译器支持的额外标志。例如,-m 用于输出编译器的优化细节
(包括使用逃逸分析这种优化),相反可以使用 -N 来关闭编译器优化;而 -l 则用于禁用 foo 函数
的内联优化,防止逃逸被编译器通过内联彻底的抹除。得到如下输出:
### command-line-arguments
src/main.go:7:9: &t escapes to heap
src/main.go:6:7: moved to heap: t
src/main.go:12:14: *x escapes to heap
src/main.go:12:13: main ... argument does not escape
foo 函数里的变量 t 逃逸了,和预想的一致,不解的是为什么 main 函数里的 x 也逃逸了?
这是因为有些函数的参数为 interface 类型,比如 fmt.Println(a ...interface{}),编译期间很难确定其
参数的具体类型,也会发生逃逸。
指针逃逸
我们知道传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。
如下实例:
package maintype Student struct {Name stringAge int
}func StudentRegister(name string, age int) *Student {s := new(Student) //局部变量s逃逸到堆s.Name = names.Age = agereturn s
}func main() {StudentRegister("Jim", 18)
}
虽然在函数 StudentRegister() 内部 s 为局部变量,其值通过函数返回值返回,s 本身为一指针,其指向的内存地址不会是栈而是堆,这就是典型的逃逸案例。
栈空间不足
package mainfunc MakeSlice() {s := make([]int, 100, 100)for index, _ := range s {s[index] = index}
}func main() {MakeSlice()
}
此时栈空间充足,slice分配在栈上,未发生逃逸,假设将slice扩大100倍,再看一下
package mainfunc MakeSlice() {s := make([]int, 10000, 10000)for index, _ := range s {s[index] = index}
}func main() {MakeSlice()
}
此时,分配的slice容量太大,当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中
动态类型逃逸
很多函数参数为interface类型。比如:
func Printf(format string, a ...interface{}) (n int, err error)
func Sprintf(format string, a ...interface{}) string
func Fprint(w io.Writer, a ...interface{}) (n int, err error)
func Print(a ...interface{}) (n int, err error)
func Println(a ...interface{}) (n int, err error)
编译期间很难确定其参数的具体类型,也能产生逃逸。
变量大小不确定
在创建切片的时候,初始化切片容量的时候,传入一个变量来指定其大小,由于变量的值不能在编译器确定,所以就不能确定其占用空间的大小,直接将对象分配在堆上
package mainfunc MakeSlice() {length := 1a := make([]int, length, length)for i := 0; i < length; i++ {a[i] = i}
}func main() {MakeSlice()
}
逃逸常见情况
指针逃逸,函数内部返回一个局部变量指针
分配大对象,导致栈空间不足,不得不分配到堆上
调用接口类型的方法。接口类型的方法调用是动态调度 - 实际使用的具体实现只能在运行时确定。考虑一个接口类型为 io.Reader 的变量 r。对 r.Read(b) 的调用将导致 r 的值和字节片b的后续转义并因此分配到堆上。
尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆上
如何避免
go 中的接口类型的方法调用是动态调度,因此不能够在编译阶段确定,所有类型结构转换成接口的过程会涉及到内存逃逸的情况发生。如果对于性能要求比较高且访问频次比较高的函数调用,应该尽量避免使用接口类型
由于切片一般都是使用在函数传递的场景下,而且切片在 append 的时候可能会涉及到重新分配内存,如果切片在编译期间的大小不能够确认或者大小超出栈的限制,多数情况下都会分配到堆上
总结
堆上动态分配内存比栈上静态分配内存,开销大很多。
变量分配在栈上需要能在编译期确定它的作用域,否则会分配到堆上。
Go编译器会在编译期对考察变量的作用域,并作一系列检查,如果它的作用域在运行期间对编译器一直是可知的,那么就会分配到栈上。简单来说,编译器会根据变量是否被外部引用来决定是否逃逸。
对于Go程序员来说,编译器的这些逃逸分析规则不需要掌握,我们只需通过go build -gcflags '-m'命令来观察变量逃逸情况就行了
不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。
下面代码中的变量发生逃逸了吗?
示例1:
package main
type S struct {}func main() {var x S_ = identity(x)
}func identity(x S) S {return x
}
分析:Go语言函数传递都是通过值的,调用函数的时候,直接在栈上copy出一份参数,不存在逃逸。
示例2:
package maintype S struct {}func main() {var x Sy := &x_ = *identity(y)
}func identity(z *S) *S {return z
}
分析:identity函数的输入直接当成返回值了,因为没有对z作引用,所以z没有逃逸。对x的引用也没有逃出main函数的作用域,因此x也没有发生逃逸。
示例3:
package maintype S struct {}func main() {var x S_ = *ref(x)
}func ref(z S) *S {return &z
}
分析:z是对x的拷贝,ref函数中对z取了引用,所以z不能放在栈上,z必须要逃逸到堆上。否则在ref函数之外,通过引用如何找到z。仅管在main函数中,直接丢弃了ref的结果,但是Go的编译器还没有那么智能,分析不出来这种情况。而对x从来就没有取引用,所以x不会发生逃逸。
示例4:如果对一个结构体成员赋引用会如何
package maintype S struct {M *int
}func main() {var i intrefStruct(i)
}func refStruct(y int) (z S) {z.M = &yreturn z
}
分析:refStruct函数对y取了引用,所以y发生了逃逸。
示例5:
package maintype S struct {M *int
}func main() {var i intrefStruct(&i)
}func refStruct(y *int) (z S) {z.M = yreturn z
}
分析:在main函数里对i取了引用,并且把它传给了refStruct函数,i的引用一直在main函数的作用域用,因此i没有发生逃逸。和上一个例子相比,有一点小差别,但是导致的程序效果是不同的:例子4中,i先在main的栈帧中分配,之后又在refStruct栈帧中分配,然后又逃逸到堆上,到堆上分配了一次,共3次分配。本例中,i只分配了一次,然后通过引用传递。
示例6:
package maintype S struct {M *int
}func main() {var x Svar i intref(&i, &x)
}func ref(y *int, z *S) {z.M = y
}
分析:本例i发生了逃逸,按照前面例子5的分析,i不会逃逸。两个例子的区别是例子5中的S是在返回值里的,输入只能“流入”到输出,本例中的S是在输入参数中,所以逃逸分析失败,i要逃逸到堆上。
Go 与C/C++中的堆和栈是同一个概念吗
在前面的分析中,其实隐式地默认了所提及Go 中堆和栈这些概念与 C/C++ 中堆和栈的概念
是同一种事物。但读者应该需要进一步认识到这里面的区别。
首先要明确,C/C++ 中提及的“程序堆栈”本质上其实是操作系统层级的概念,它通过
C/C++ 语言的编译器和所在的系统环境来共同决定。在程序启动时,操作系统会自动维护一个所启动程序消耗内存的地址空间,并自动将这个空间从逻辑上划分为堆内存空间和栈内存空间。这时,“栈”的概念是指程序运行时自动获得的一小块内存,而后续的函数调用所消耗的栈大小,会在编译期间由编译器决定,用于保存局部变量或者保存函数调用栈。如果在 C/C++ 中声明一个局部变量,则会执行逻辑上的压栈操作,在栈中记录局部变量。而当局部变量离开作用域之后,所谓的自动释放本质上是该位置的内存在下一次函数调用压栈的过程中,可以被无条件的覆盖;对于堆而
言,每当程序通过系统调用向操作系统申请内存时,会将所需的空间从维护的堆内存地址空间中分
配出去,而在归还时则会将归还的内存合并到所维护的地址空间中。
Go 程序也是运行在操作系统上的程序,自然同样拥有前面提及的堆和栈的概念。但区别在于
传统意义上的“栈”被 Go 语言的运行时全部消耗了,用于维护运行时各个组件之间的协调,例如调度器、垃圾回收、系统调用等。而对于用户态的 Go 代码而言,它们所消耗的“堆和栈”,其实只是 Go 运行时通过管理向操作系统申请的堆内存,构造的逻辑上的“堆和栈”,它们的本质都是从操作系统申请而来的堆内存。由于用户态 Go 程序的“栈空间”是由运行时管理堆内存得来,相较于只有 1MB 的 C/C++ 中的“栈”而言,Go 程序拥有“几乎”无限的栈内存(1GB)。更进一步,对于用户态 Go 代码消耗的栈,Go 语言运行时会为了防止内存碎片化,会在适当的时候对整个栈进行深拷贝,将其整个复制到另一块内存区域(当然,这个过程对用户态的代码是不可见的),这也是相较于传统意义上栈是一块固定分配好的内存所出现的另一处差异。也正是由于这个特点的存在,指针的算术运算不再能奏效,因为在没有特殊说明的情况下,无法确定运算前后指针所指向的地址的内容是否已经被 Go 运行时移动。