逃逸分析 什么是逃逸分析 在C
、C++
这类需要手动管理内存的编程语言中,对象或结构体具体分配到堆还是栈上是由工程师自主决定的,如果能够精准地为每一个变量分配合理的空间,那么程序的运行效率和内存使用效率一定是最高的,但是手动分配会导致以下两个问题:
不需要分配到堆上的对象分配到堆上 —— 浪费内存空间、且可能忘记释放导致内存泄漏
需要分配到堆上的对象分配到栈上 —— 悬挂指针、影响内存安全
悬挂指针 在C
语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误
如以下代码
1 2 3 4 5 int *foo ( void ) { int t = 3 ; return &t; }
当 foo
函数返回后,它的本地变量会被编译器回收,调用方获取的是一个悬挂指针,不确定当前指针指向的值是否还能正常使用(可能都已经被回收了)
什么是逃逸分析?
在编译器优化中,逃逸分析 是用来决定指针动态作用域的方法,是一种静态分析下方法
Go
语言的编译器使用逃逸分析决定变量应该在栈上分配还是在堆上分配
Go
语言中的逃逸分析是编译器执行静态代码分析后,对内存管理进行的优化和简化,它可以决定一个变量是分配到栈上还是堆上
逃逸策略 如果一个函数返回一个变量的引用,那么它就会发生逃逸
编译器根据变量是否被外部引用来决定是否逃逸
如果函数外部没有引用,则优先放到栈中(不是一定放在栈中)
如果函数外部存在引用,则必定放在堆中
解读
通过逃逸分析,从代码层面分析后尽量将那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也减少GC
的压力,提高程序的运行速度
如何查看逃逸分析结果 比如以下代码栗子🌰
1 2 3 4 5 6 7 8 9 10 11 12 13 package mainimport "fmt" func foo () *int { t := 3 return &t; } func main () { x := foo() fmt.Println(*x) }
查看编译时的输出结果 使用以下命令编译可以知道逃逸分析的结果
1 go build -gcflags '-m -l' main.go
-gcflags
:是Go
编译器选项,具体有哪些选项及含义可以使用以下命令查看
命令输出
1 2 3 4 5 usage: compile [options] file.go... ... -l disable inlining -m print optimization decisions ...
-l
:关闭内联优化
-m
:打印优化决策结果
查看汇编代码 也可以通过汇编代码知道逃逸分析的结果
1 go tool compile -S escape_analysis.go
这里说明t
是通过runtime.newobject()
在堆上分配的对象
常见的逃逸场景 指针逃逸 最典型的一种场景,当函数外部存在对函数内变量的引用的时候,就会发生逃逸,将变量分配到堆上(不然不就出现悬挂指针了嘛)
看一段平时最常见的指针逃逸代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package maintype Student struct { Name string Age int } func NewStudent (name string , age int ) *Student { return &Student{ Name: name, Age: age, } } func main () { NewStudent("Crayon" , 24 ) }
这段代码的意义很简单,就是提供了一个NewStudent
的创建者函数,有时候为了避免大结构体作为函数出入参时的值拷贝的性能开销,会使用指针类型的结构体
NewStudent
函数内部创建了一个Student
结构体,并将该结构体指针返回给外部,Go
编译器认为这是一种指针逃逸行为,需要将变量分配到堆上以避免悬挂指针的问题的产生,尽管函数外部(main
函数)中目前还没有使用到NewStudent
函数返回的结构体指针
栈空间不足逃逸 栈的空间是有限的,所以当变量所需要的内存大小超过栈所能分配的空间的时候,就会将变量分配到堆上
1 2 3 4 5 6 7 8 9 10 11 12 13 package mainfunc Slice () { s := make ([]int , 0 , 1000 ) for i, _ := range s { s[i] = i } } func main () { Slice() }
查看编译的结果,此时并不会发生逃逸
但是我们加大切片的容量大小,就会发生逃逸
1 2 3 4 5 6 7 8 9 10 11 12 13 package mainfunc Slice () { s := make ([]int , 0 , 10000 ) for i, _ := range s { s[i] = i } } func main () { Slice() }
将切片容量扩大到10000,此时再执行编译,可以看到发生了逃逸
容量未知也会导致导致 当切片的容量为变量(即编译期还是未知的)的时候,也会发生逃逸
1 2 3 4 5 6 7 8 9 10 11 12 package mainimport "math/rand" func Slice () { n := rand.Intn(10 ) _ = make ([]int , n) } func main () { Slice() }
闭包引用对象逃逸 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package mainfunc Fibonacci () func () int { a, b := 0 , 1 return func () int { a, b = b, a+b return a } } func main () { f := Fibonacci() for i := 0 ; i < 10 ;i++ { f() } }
可见函数内部a
、b
都发生了逃逸
动态类型逃逸? 这块应该是有一定的误区,至少我在1.17
版本测试时,空接口类型的参数不会发生逃逸
1 2 3 4 5 6 7 8 9 10 package mainfunc test (v interface {}) interface {} { return v } func main () { s := "Crayon" test(s) }
这里test
函数的入参是空接口(动态类型),但是并没有发生逃逸
但是使用fmt.Println
函数时就会发生逃逸
1 2 3 4 5 6 7 8 9 10 11 12 13 package mainimport "fmt" func test (v interface {}) interface {} { return v } func main () { s := "Crayon" fmt.Println(s) }
使用fmt.Println
打印变量就发生了逃逸
分析fmt.Println
的源码,参考其他文章发现一个有意思的点
那就是reflect
系列的方法,如reflect.ValueOf
,调用该类方法会触发逃逸
1 2 3 4 5 6 7 8 9 10 11 package mainimport "reflect" func main () { s := "Crayon" reflect.TypeOf(s) reflect.TypeOf(s).Kind() reflect.ValueOf(s) reflect.ValueOf(s).Kind() }
reflect.ValueOf
导致逃逸?这里先看下reflect.ValueOf
函数,点进源码就能直接看到为什么逃逸了
显示的调用escapes
函数让变量逃逸,这里是有意为之,具体为什么有待深入研究下,这里就不往下展开了
reflect.TypeOf
导致逃逸?从实验的结果来看,只是调用reflect.TypeOf
函数并不会触发逃逸,而是在接着调用Type
接口(reflect.TypeOf
函数的返回值)的方法的时候才会发生逃逸
查看TypeOf
的源码,简单画了一下涉及的几个结构体/接口的类图
Type
是一个顶层的接口,rtype
是Type
接口的一个实现,emptyInterface
和rtype
则是组合的关系,存在一个typ
的成员变量引用rtype
toType
的实现很简单,就是将具体实现转化成更抽象的接口类型
场景复现 分别模拟了reflect.TypeOf
函数的源码行为以及结构体抽象成接口的方法调用的场景
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 package mainimport "unsafe" type MyInterface interface { Do() } type Pointer struct {} func (s *Pointer) Do() {} func Pointer2MyInterface (s *Pointer) MyInterface { return s } type emptyInterface struct { typ *Pointer word unsafe.Pointer } func TypeOf (i interface {}) MyInterface { eface := (*emptyInterface)(unsafe.Pointer(&i)) return Pointer2MyInterface(eface.typ) } func TypeOf0 (i interface {}) *Pointer { eface := (*emptyInterface)(unsafe.Pointer(&i)) return eface.typ } func TypeOf1 (i interface {}) Pointer { eface := (*emptyInterface)(unsafe.Pointer(&i)) return *eface.typ } func main () { p1 := Pointer{} TypeOf(p1) TypeOf(p1).(*Pointer).Do() TypeOf(p1).Do() TypeOf0(p1) TypeOf0(p1).Do() TypeOf1(p1) pp1 := TypeOf1(p1) pp1.Do() p2 := &Pointer{} Pointer2MyInterface(p2) p3 := &Pointer{} Pointer2MyInterface(p3).Do() p4 := &Pointer{} Pointer2MyInterface(p4).(*Pointer).Do() }
逃逸分析结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $ go tool compile -m -l escape_analysis.go escape_analysis.go:12:7: s does not escape escape_analysis.go:16:26: leaking param: s to result ~r1 level=0 escape_analysis.go:25:13: leaking param: i to result ~r1 level=0 escape_analysis.go:30:14: leaking param: i to result ~r1 level=0 escape_analysis.go:35:14: i does not escape escape_analysis.go:42:11: p1 does not escape escape_analysis.go:43:11: p1 does not escape escape_analysis.go:44:11: p1 escapes to heap escape_analysis.go:46:12: p1 does not escape escape_analysis.go:47:12: p1 does not escape escape_analysis.go:49:12: p1 does not escape escape_analysis.go:50:19: p1 does not escape escape_analysis.go:53:11: &Pointer{} does not escape escape_analysis.go:56:11: &Pointer{} escapes to heap escape_analysis.go:59:11: &Pointer{} does not escape <autogenerated>:1: leaking param: .this
结论 从逃逸分析的结果可以说明,接口方法的调用会导致逃逸
大概的原因可能是接口属于动态的类型,只要实现了接口的结构体都可能是结构体的实现,而编译期间我们并不能知道调用方法的变量属于哪类实现,为避免指针引用以及栈空间大小可能不足的问题,就直接将变量逃逸分配到堆上了
slice、map、chan元素为指针类型,元素逃逸 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package mainfunc main () { s1 := make ([]*string , 0 , 10 ) str1 := "str1" s1 = append (s1, &str1) s2 := make ([]string , 0 , 10 ) str2 := "str2" s2 = append (s2, str2) m1 := make (map [*string ]string , 8 ) mkey1 := "mkey1" mvalue1 := "mvalue1" m1[&mkey1] = mvalue1 m2 := make (map [string ]*string , 8 ) mkey2 := "mkey2" mvalue2 := "mvalue2" m2[mkey2] = &mvalue2 m3 := make (map [*string ]*string , 8 ) mkey3 := "mkey3" mvalue3 := "mvalue3" m3[&mkey3] = &mvalue3 m4 := make (map [string ]string , 8 ) mkey4 := "mkey4" mvalue4 := "mvalue4" m4[mkey4] = mvalue4 ch1 := make (chan *string , 10 ) chStr1 := "chStr1" ch1 <- &chStr1 ch2 := make (chan string , 10 ) chStr2 := "chStr2" ch2 <- chStr2 }
逃逸分析结果
1 2 3 4 5 6 7 8 9 10 11 12 13 $ go tool compile -m -l escape_analysis.go escape_analysis.go:5:5: moved to heap: str1 escape_analysis.go:13:5: moved to heap: mkey1 escape_analysis.go:19:5: moved to heap: mvalue2 escape_analysis.go:23:5: moved to heap: mkey3 escape_analysis.go:24:5: moved to heap: mvalue3 escape_analysis.go:33:5: moved to heap: chStr1 escape_analysis.go:4:15: make([]*string, 0, 10) does not escape escape_analysis.go:8:15: make([]string, 0, 10) does not escape escape_analysis.go:12:15: make(map[*string]string, 8) does not escape escape_analysis.go:17:15: make(map[string]*string, 8) does not escape escape_analysis.go:22:15: make(map[*string]*string, 8) does not escape escape_analysis.go:27:15: make(map[string]string, 8) does not escape
结论:指针类型元素会发生逃逸
案例分析 案例一 1 2 3 4 5 6 7 8 9 10 11 package maintype S struct {}func main () { var x S _ = identity(x) } func identity (x S) S { return x }
查看分析
没有发生逃逸
由于Go
语言中参数是值传递,输入输出都是值拷贝,所以此处不会发生逃逸,变量x
还是分配在栈上
案例二 1 2 3 4 5 6 7 8 9 10 11 12 13 package maintype S struct {}func main () { var x S y := &x _ = identity(y) } func identity (z *S) *S { return z }
查看分析
没有发生逃逸
变量x
被变量y
引用,但是都没有超出main
函数的作用域,所以没有发生逃逸
而identity
函数输入直接作为返回值返回,没有将变量z
的引用返回到函数外部,所以变量z
没有发生逃逸
案例三 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package maintype S struct { M *int } func main () { var i int refStruct(i) } func refStruct (y int ) (z S) { z.M = &y return z }
查看分析
发生逃逸
变量y
属于refStruct
函数的局部变量(形参),将y
的引用赋给z.M
这个结构体内的变量再返回给函数外部,函数外部想要访问M
(获取M
引用地址对应的值),y
就不能分配在栈上,必须逃逸分配到堆上
总结
栈上分配内存比在堆中分配内存有更高的效率
栈上分配的内存不需要GC
处理,回收的效率高(用完即回收)
堆上分配的内存使用完毕会交给GC
处理
逃逸分析的目的是决定内存分配在栈还是堆上,正确的内存方式能够提高分配效率和回收效率,提升程序性能
Go
语言逃逸分析在编译阶段完成
参考资料
Go 语言的栈内存和逃逸分析 | Go 语言设计与实现 (draveness.me)
逃逸分析是怎么进行的 | Go 程序员面试笔试宝典 (golang.design)
golang的fmt包引发的变量逃逸到堆的问题_golang fmt.sprintf out of memory-CSDN博客
golang变量逃逸分析小探 - 声zzz (reusee.github.io)
通过实例理解Go逃逸分析 - 知乎 (zhihu.com)
通过实例理解Go逃逸分析 | Tony Bai