总结go语言逃逸分析
持续补充
什么是内存逃逸
在c/c++中,内存的分配都是由程序决定的。导致程序员们在内存分配完了之后,总是忘记回收。这就会造成内存泄露,一次泄露可能不会导致什么,但是在递归或者循环中泄露,对程序来说可能是致命的。
在go语言中,就不会出现这样的问题,因为go自带垃圾回收。那么当在go中定义一个变量时,他究竟是在堆上还是栈上分配的呢?在c语言中,默认只要不是malloc或者全局的变量都是局部变量,都在栈上分配,当函数要返回一个局部变量地址的时候,我就说这个变量(这块内存)想要逃逸,这就是内存逃逸。
然而在go语言中,局部变量的地址是返回的。你可能会问,为什么go这么厉害,又能垃圾回收,又能内存逃逸。这因为后生可畏。
go的编译器,在编译的时候会做逃逸分析,分析这个变量(这块内存)是否想要逃逸。逃逸,则在堆上分配内存;否则在栈上分配内存。
逃逸分析的好处
逃逸分析这种“骚操作”把变量合理地分配到它该去的地方,“找准自己的位置”。即使你是用new申请到的内存,如果我发现你竟然在退出函数后没有用了,那么就把你丢到栈上,毕竟栈上的内存分配比堆上快很多;反之,即使你表面上只是一个普通的变量,但是经过逃逸分析后发现在退出函数之后还有其他地方在引用,那我就把你分配到堆上。真正地做到“按需分配”,提前实现共产主义!
减少了gc压力。如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销(占用CPU容量的25%),甚至会导致STW(stop the world)。
提高效率。堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。
同步消除。如果你定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。
逃逸分析是如何完成
Go逃逸分析最基本的原则是:
如果一个函数返回对一个变量的引用,那么它就会发生逃逸。任何时候,一个值被分享到函数栈帧范围之外,它都会在堆上被重新分配。如果函数return之后,确定变量不再被引用,则将其分配到栈上。然而,如果编译器不能确保变量在函数 return之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。
简单来说,编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。
Go语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上。相反,编译器通过分析代码来决定将变量分配到何处。
对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果考察到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。套个取址符,就想骗补助?Too young!
简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:
- 如果函数外部没有引用,则优先放到栈中;
- 如果函数外部存在引用,则必定放到堆中;
- 如果栈上放不开,则必定放到堆上
哪些情况下会发生内存逃逸
先来说一下通过go编译器查看内存逃逸方式go build -gcflags=-m xxx.go
- 在方法内把局部变量指针返回并被引用。 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出,因此要分配到堆上。
- 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据的。所以编译器没法知道变量什么时候才会被释放,一般都会逃逸到堆上分配。
- 在一个切片上存储指针或带指针的值。 一个典型的例子就是
[]*string
。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。 - slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
- 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。
- 闭包引用对象逃逸,也会发生逃逸
- 尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆上
局部变量被返回 造成逃逸
package main
type User struct {
Name string
}
func foo(s string) *User {
u := new(User)
u.Name = s
return u // 1.方法内局部变量返回,逃逸
}
func main() {
user := foo("hui")
user.Name = "dev"
}
/* 逃逸分析日志
# command-line-arguments
./main.go:11:6: can inline foo
./main.go:17:6: can inline main
./main.go:18:13: inlining call to foo
./main.go:11:10: leaking param: s
./main.go:12:10: new(User) escapes to heap # 逃逸
./main.go:18:13: new(User) does not escape
*/
interface{}动态类型 逃逸
package main
import "fmt"
func main() {
name := "devhui"
fmt.Println(name)
}
/* 逃逸分析日志
# command-line-arguments
./main.go:7:13: inlining call to fmt.Println
./main.go:7:13: name escapes to heap # 逃逸
./main.go:7:13: []interface {} literal does not escape
*/
很多函数的参数为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)
编译期间很难确定其参数的具体类型,也能产生逃逸
func main() {
fmt.Println("hello 逃逸")
}
/* 逃逸日志分析
./main.go:5:6: can inline main
./main.go:6:13: inlining call to fmt.Println
./main.go:6:14: "hello 逃逸" escapes to heap
./main.go:6:13: []interface {} literal does not escape
*/
栈空间不足逃逸
栈空间足够分配小切片,不会发生逃逸
func main() {
s := make([]int, 1000, 1000)
for index, _ := range s {
s[index] = index
}
}
/* 小切片没有逃逸
# command-line-arguments
./main.go:4:11: make([]int, 1000, 1000) does not escape
*/
分配了一个超大的切片,栈空间不足,逃逸了
package main
func main() {
s := make([]int, 10000, 10000)
for index, _ := range s {
s[index] = index
}
}
/* 逃逸分析日志
# command-line-arguments
./main.go:4:11: make([]int, 10000, 10000) escapes to heap
*/
内存逃逸的弊端
提问:函数传递指针真的比传值效率高吗?
我们知道传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。
准确地说,你并不需要知道。Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。
知道变量的存储位置确实和效率编程有关系。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。 然而,如果编译器不能确保变量在函数 return之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。
当前情况下,如果一个变量被取地址,那么它就有可能被分配到堆上。然而,还要对这些变量做逃逸分析,如果函数return之后,变量不再被引用,则将其分配到栈上。
如何避免
- go 中的接口类型的方法调用是动态调度,因此不能够在编译阶段确定,所有类型结构转换成接口的过程会涉及到内存逃逸的情况发生。如果对于性能要求比较高且访问频次比较高的函数调用,应该尽量避免使用接口类型。
type Stringer interface {
String() string
}
if v, ok := any.(Stringer); ok {
return v.String()
}
- 由于切片一般都是使用在函数传递的场景下,而且切片在 append 的时候可能会涉及到重新分配内存,如果切片在编译期间的大小不能够确认或者大小超出栈的限制,多数情况下都会分配到堆上。
总结
- 堆上分配内存比在栈上分配内存,开销大的多
- go的内存逃逸是在编译期间完成
- 变量分配到栈上需要能够在编译的时候确定他的作用域,否则会分配到堆上。
- Go编译器会在编译期对考察变量的作用域,并作一系列检查。简单来说,编译器会根据变量是否被外部引用来决定是否逃逸。
- 不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。
- 最后,尽量写出少一些逃逸的代码,提升程序的运行效率。
参考文章
- [1] Golang内存逃逸是什么?怎么避免内存逃逸?
- [2] Golang 内存分配之逃逸分析
- [3] Golang内存分配逃逸分析