Go逃逸分析(escape analysis)是指Go编译器会在编译阶段判断程序中的对象是位于堆区还是位于栈区。C/C++在给对象做内存分配时,对象位于堆区还是栈区是很容易辨认的:使用malloc/new动态分配内存空间的对象,都位于堆区;而局部变量都位于栈区。但是对于Go而言,定义的对象是位于堆区还是栈区是由编译器来决定。位于栈区的对象在函数调用返回后可以直接自动回收,但是位于堆区的对象就必须由GC统一回收

在C/C++的世界里,局部变量位于栈区,如果函数返回局部变量的指针给外部使用,这个指针就会变为野指针,在外部使用时就有可能发生内存越界。但在Go的世界里,有了逃逸分析,返回函数局部变量就变成可能。

逃逸策略

在函数中申请新的对象,编译器会根据该对象是否被函数外部引用来决定是否逃逸:

  • 如果函数外部没有引用,则优先放到栈中;
  • 如果函数外部存在引用,则优先放在堆中。
  • 如果函数内部使用的变量所需内存过大,则放在堆中。
  • 动态参数因为编译器难以确认其类型,安全起见都放在堆中。

逃逸场景

我们可以通过go build -gcflags=-m编译参数来分析逃逸情况。

指针逃逸

第一个案例,我们在main中new了一个变量

  1. func main() {
  2. a := new(int)
  3. *a = 10
  4. }

逃逸分析看出,即使我们显式调用new,但因为变量a没有被外部调用,因此a也是分配到栈上的,因此没有发生逃逸。

  1. junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
  2. # chan
  3. ./main.go:5:6: can inline main
  4. ./main.go:6:10: new(int) does not escape

第二个案例,函数内new创建对象,函数返回对象指针,但外部幷未使用该指针。

  1. type Student struct {
  2. Name string
  3. Age int
  4. }
  5. func GetStudentInfo(name string, age int) *Student {
  6. stu := new(Student)
  7. stu.Name = name
  8. stu.Age = age
  9. return stu
  10. }
  11. func main() {
  12. GetStudentInfo("Ken", 10)
  13. }

逃逸分析看出,new(Student)发生了逃逸,Student对象分配到了堆区。所以不管返回的对象的指针是否有被外部使用,一旦作为了函数返回值,就一定发生了逃逸。

  1. junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
  2. # chan
  3. ./main.go:10:6: can inline GetStudentInfo
  4. ./main.go:17:6: can inline main
  5. ./main.go:18:16: inlining call to GetStudentInfo
  6. ./main.go:10:21: leaking param: name
  7. ./main.go:11:12: new(Student) escapes to heap
  8. ./main.go:18:16: new(Student) does not escape

第三个案例,这一次函数不用new创建对象,而是创建局部变量对象,函数返回对象指针,但外部幷未使用该指针。

  1. type Student struct {
  2. Name string
  3. Age int
  4. }
  5. func GetStudentInfo(name string, age int) *Student {
  6. stu := Student{}
  7. stu.Name = name
  8. stu.Age = age
  9. return &stu
  10. }
  11. func main() {
  12. GetStudentInfo("Ken", 10)
  13. }

从逃逸分析看出,stu本来是局部变量,但是作为返回值返回时这个变量被重新放入堆区存储,因此是发生了逃逸,即使该变量并未被外部引用。

  1. junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
  2. # chan
  3. ./main.go:10:6: can inline GetStudentInfo
  4. ./main.go:17:6: can inline main
  5. ./main.go:18:16: inlining call to GetStudentInfo
  6. ./main.go:10:21: leaking param: name
  7. ./main.go:11:2: moved to heap: stu

栈空间不足逃逸

这个案例是在函数中定义一个大对象,这里用了make slice来创建容量为100的slice,里面元素类型为Student。

  1. type Student struct {
  2. Name string
  3. Age int
  4. Grade int
  5. }
  6. func MakeStudents(grade int) {
  7. s := make([]Student, 100, 100)
  8. for index, _ := range s {
  9. s[index].Grade = grade
  10. }
  11. }
  12. func main() {
  13. MakeStudents(6)
  14. }

逃逸分析看出,当slice容量为100时,并没有发生逃逸,s指向的空间是放在栈上的。

  1. junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
  2. # chan
  3. ./main.go:18:6: can inline main
  4. ./main.go:12:11: make([]Student, 100, 100) does not escape

当我们把slice大小增加到10000时,情况就不一样了,因为此时栈容量已经不足。

  1. type Student struct {
  2. Name string
  3. Age int
  4. Grade int
  5. }
  6. func MakeStudents(grade int) {
  7. s := make([]Student, 10000, 10000)
  8. for index, _ := range s {
  9. s[index].Grade = grade
  10. }
  11. }
  12. func main() {
  13. MakeStudents(6)
  14. }

从逃逸分析看出,此时发生了逃逸。

  1. junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
  2. # chan
  3. ./main.go:18:6: can inline main
  4. ./main.go:12:11: make([]Student, 10000, 10000) escapes to heap

动态类型逃逸

下面这里案例,函数参数是interface类型,fmt.Println(a …interface{})。

  1. func main() {
  2. s := "hello"
  3. fmt.Println(s)
  4. }

从逃逸分析看出,s发生逃逸。其实这也很符合正常思维:因为是动态变量,编译器在编译过程中无法确定其真实类型,因此直接把该变量放入堆区存放是最稳妥的方式。

  1. junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
  2. # chan
  3. ./main.go:9:13: inlining call to fmt.Println
  4. ./main.go:9:13: s escapes to heap
  5. ./main.go:9:13: []interface {} literal does not escape
  6. <autogenerated>:1: .this does not escape

闭包引用

闭包让你可以在一个内层函数中访问到其外层函数的作用域。下面这个案例就是闭包引用。

  1. func Increase() func() int {
  2. n := 0
  3. return func() int {
  4. n++
  5. return n
  6. }
  7. }
  8. func main() {
  9. in := Increase()
  10. fmt.Println(in()) // 1
  11. fmt.Println(in()) // 2
  12. }

Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。

  1. junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
  2. # chan
  3. ./main.go:9:9: can inline Increase.func1
  4. ./main.go:17:13: inlining call to fmt.Println
  5. ./main.go:18:13: inlining call to fmt.Println
  6. ./main.go:8:2: moved to heap: n
  7. ./main.go:9:9: func literal escapes to heap
  8. ./main.go:17:16: in() escapes to heap
  9. ./main.go:17:13: []interface {} literal does not escape
  10. ./main.go:18:16: in() escapes to heap
  11. ./main.go:18:13: []interface {} literal does not escape
  12. <autogenerated>:1: .this does not escape

传值还是传指针?

指针传递可以减少底层值的复制,可以提高效率。但是如果复制的对象太小,由于指针传递会产生逃逸,则可能会使用堆分配,这会增加GC的负担。GC是个很重的操作,需要stop the world,程序会发生卡顿,逃逸的对象越多,GC时间会越长,所以传递指针不一定是高效的。一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。