内存逃逸分析
[TOC]
内存逃逸
栈可以简单得理解成一次函数调用内部申请到的内存,它们会随着函数的返回把内存还给系统。
通常一个变量超过当前函数栈侦的范围,就会被分配到堆上,即 outlive
变量。例如当一个函数的变量在函数返回后还被引用,会发生逃逸。
申请栈内存好处:函数返回直接释放,需执行的指令也极少,只需要简单的 push 和 release, 不会引起垃圾回收,对性能的影响极小。
申请到堆上面的内存会引起垃圾回收
,如果这个过程(特指垃圾回收不断被触发)过于高频就会导致 gc 压力过大,程序性能出问题。
实例:
func f3() []int {
a := make([]int , 0, 11) //栈 空间小
b := make([]int ,0, 20000) //堆 空间过大导致逃逸到堆上
cap := 10
c := make([]int, 0, cap) //堆 动态分配不定空间
d := make([]int , 0, 12) //作为函数返回值,会在堆上分配
println(a, b, c)
return d
}
a 小的临时变量会在栈上申请
b 临时变量,申请过大也会在堆上面申请。
对于 c 编译器对于这种不定长度的申请方式,也会在堆上面申请,即使申请的长度很短。
d 申请后作为返回值返回了,编译器会认为变量之后还会被使用,当函数返回之后并不会将其内存归还,那么它就会被申请到 堆 上面了。
函数申请的对象:
- 如果分配在栈中,则函数执行结束可自动将内存回收;
- 如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理;
通过检查变量的作用域是否超出了它所在的栈来决定是否将它分配在堆上”的技术,其中“变量的作用域超出了它所在的栈”这种行为即被称为逃逸。
逃逸分析
go语言中对象内存的分配不是由语言运算符或函数决定,而是通过逃逸分析来决定。逃逸分析在编译阶段完成,是通过编译器的“逃逸分析”来决定。
如在关键 new
、make
和字面量等方法隐式分配的内存时都会涉及到内存上分配在栈上还是逃逸到堆内存上。
Go 语言的逃逸分析遵循以下两个不变性:
- 指向栈对象的指针不能存在于堆中,只能保存在栈上;
- 指向栈对象的指针不能在栈对象回收后存活;
逃逸分析在大多数语言里属于静态分析:在编译期由静态代码分析来决定一个值是否能被分配在栈帧上,还是需要“逃逸”到堆上。
在编译器解析了 Go 语言源文件后,获得整个程序的抽象语法树(Abstract syntax tree,AST),编译器可以根据抽象语法树分析静态的数据流。
逃逸分析的主要逻辑在 /src/cmd/compile/internal/escape.go
- 构建带权重的有向图,其中顶点
cmd/compile/internal/gc.EscLocation
表示被分配的变量,边cmd/compile/internal/gc.EscEdge
表示变量之间的分配关系,权重表示寻址和取地址的次数; - 遍历对象分配图并查找违反两条不变性的变量分配关系,如果堆上的变量指向了栈上的变量,那么栈上的变量就需要分配在堆上;
- 记录从函数的调用参数到堆以及返回值的数据流,增强函数参数的逃逸分析;
为了保证内存的绝对安全,编译器可能会将一些变量错误地分配到堆上,但是因为这些对也会被垃圾收集器处理,所以不会造成内存泄露以及悬挂指针等安全问题,解放了工程师的生产力。
栈内存空间小,生命周期短,速度极快,不受 GC影响,但工程师不可控;堆内存空间大,可常驻,工程师可控,但是速度比栈内存慢,会触发 GC。
内存逃逸场景
go build -gcflags=-m
即内存被分配到堆内存中。
终端运行命令查看逃逸分析日志:go build -gcflags=-m
如上述f3()函数
go build -gcflags=-m escape.go
./escape.go:18:11: make([]int, 0, 20000) escapes to heap
./escape.go:20:11: make([]int, 0, cap) escapes to heap
./escape.go:21:11: make([]int, 0, 12) escapes to heap
./escape.go:17:11: f3 make([]int, 0, 11) does not escape
逃逸现象一:指针逃逸
基本的原则是:如果一个函数返回对一个变量的引用,那么它就会发生逃逸。
编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。
-
如果函数外部没有引用,则优先放到栈中;
-
如果函数外部存在引用,则必定放到堆中;
函数返回指针类型时,会发生逃逸。函数中局部对象指针被返回(不确定被谁访问)。对象指针被多个子程序(如线程 协程)共享使用。
type App struct {
Name string
Version string
Package string
}
//返回结构体副本,未发生逃逸
func retStruct() App {
return App{
Name: "微博",
Version: "1.0.1",
Package: "pkg.wb",
}
}
//返回结构体指针,发生逃逸
//&App literal escapes to heap
func retStructPtr() *App {
return &App{
Name: "微博",
Version: "1.0.1",
Package: "pkg.wb",
}
}
上述第二个返回了一个 *App 指针对象,因为返回了指针,会导致函数内部的临时变量可能还会继续被其他地方引用,其生命周期就可能不是在函数调用结束而终止,故逃逸到了堆内存上。堆内存到变量声明周期可以长时间存在,但栈不行。
又如,返回*int和int的对比:
//返回int指针,发生逃逸
func retIntPtr() *int {
var a int
a = 1000
return &a
//./escape.go:58:9: &a escapes to heap
//./escape.go:56:6: moved to heap: a
}
//返回int,未发生逃逸
func retInt() int {
var b int
b = 1000
return b
}
逃逸现象二:栈空间不足,开辟过大
当栈空间不足以盛放下申请的空间时,会在堆中分配内存。
a := make([]int , 0, 11) //栈 空间小
b := make([]int ,0, 20000) //堆 空间过大导致逃逸到堆上
逃逸现象三:动态逃逸
很多函数参数为interface类型,比如fmt.Println(a …interface{}),编译期间很难确定其参数的具体类型,也能产生逃逸。
func main() {
a := 10 // a escapes to heap
fmt.Println(a)
}
func handler(i int) func() int {
sum := 0
return func() int {
sum += i
return sum
}
//./escape.go:103:9: func literal escapes to heap
//./escape.go:103:9: func literal escapes to heap
//./escape.go:104:3: &sum escapes to heap
//./escape.go:102:2: moved to heap: sum
}
案例
案例一:
package main
type S struct {}
func main() {
var x S
y := &x
_ = *identity(y)
}
func identity(z *S) *S {
return z
}
./main.go:11:15: leaking param: z to result ~r1 level=0 表示输入直接当成返回值了
identity 函数的输入直接当成返回值了,因为没有对z作引用,所以z没有逃逸。对x的引用也没有逃出main函数的作用域,因此x也没有发生逃逸。
案例二:
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
z是对x的拷贝,新没有逃逸;ref 函数中对 z 取了引用,在ref函数之外可以用 Z,所以z逃逸到堆上。
案例三:
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
一个引用被赋值给 struct 成员, refStruct 函数对y取了引用,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
i 和 z 都没有逃逸。
在 main 函数里对 i 取了引用,并且把它传给了 refStruct 函数,i 的引用一直在main函数的作用域用,因此i没有发生逃逸。
和上一个例子相比,有一点小差别,但是导致的程序效果是不同的:
- 例子3中,i 先在 main 的栈帧中分配,之后又在 refStruct 栈帧中分配,然后又逃逸到堆上,到堆上分配了一次,共3次分配。
- 本例中,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 被赋值给一个输入参数都结构的成员 z。Go无法跟踪这种关系——输入只允许流向输出——因此转义分析失败,变量必须被堆分配。
由于Go 的 逃逸分析的限制,有许多有文档记录的病态情况(从Go 1.5开始),在这些情况下,变量必须被堆分配——请参阅Go Escape Analysis Flaws。
本例 i 发生了逃逸,前面例子 i不会逃逸。两个例子的区别是例子4中的S是在返回值里的,输入只能“流入”到输出,本例中的S是在输入参数中,所以逃逸分析失败,i要逃逸到堆上。
最后,map
和 slice
呢? map
和 slice
实际上只是带有指向堆内存的指针的 Go 结构体: slice
为 reflect.SliceHeader
。map
为 runtime.hmap
。如果这些结构不能逃逸,它们将分配在栈上,但是后备数组(backing array)或散列桶(hash buckets)中的数据本身将每次被堆分配。避免这种情况的唯一方法是分配一个固定大小的数组(如[10000]int), 即避免动态逃逸。
使用指针和拷贝哪个效率高
存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。变量的存储位置确实和效率编程有关系。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。 然而,如果编译器不能确保变量在函数 return之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。如果一个变量被取地址,那么它就有可能被分配到堆上。然而,还要对这些变量做逃逸分析,如果函数return之后,变量不再被引用,则将其分配到栈上。
传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递很可能会产生逃逸,会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。
如果需要 分析了程序的堆使用情况,并且需要减少GC时间,那么可以试图减少内存逃逸,将频繁分配的变量移出堆可能会有一些好处。也可以利用对象池来服用对象来减少内存都分配和回收。
reference
http://www.agardner.me/golang/garbage/collection/gc/escape/analysis/2015/10/18/go-escape-analysis.html
https://docs.google.com/document/d/1CxgUBPlx9iJzkz9JWkb6tIpTe5q32QDmz8l0BouG0Cw/preview