Go 逃逸分析
简介
Go 程序会在两个地方为变量分配内存。一个是全局的堆
空间用来动态分配内存。另一个是每个Goroutine的栈
空间。Go语言实现了垃圾回收机制,虽然不用担心内存是分配到栈空间,还是堆空间,但不同的分配方式,存在着不同的性能差异。
- 在函数中申请一个对象,如果分配到栈中,函数执行结束后自动回收。如果分配到堆中,则在函数结束后某个时间点进行垃圾回收
- 在栈上分配和回收内存的开销很低。在堆上分配内存,一个很大的额外开销则是垃圾回收。Go 语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。
- Go 编译器确定某个变量是分配在栈上还是堆上的过程,被称为逃逸分析。逃逸分析由编译器完成,作用于编译阶段
逃逸出现情况
-
指针逃逸
当函数中创建了一个对象,并返回了这个对象的地址。当函数退出时,因为指针的存在,对象的内存不能随着函数结束而回收,因此对象只能逃逸分配到堆中。
1package main 2 3import "fmt" 4 5func getPointer() *struct{} { 6 a := new(struct{}) 7 return a 8} 9 10func main() { 11 p := getPointer() 12 fmt.Println(p) 13}
1-- 通过选项 -gcflags=-m,查看变量逃逸的情况 2-- go build -gcflags=-m test.go 3 4$ go build -gcflags=-m test.go 5# command-line-arguments 6.\test.go:5:6: can inline getPointer 7.\test.go:11:17: inlining call to getPointer 8.\test.go:12:13: inlining call to fmt.Println 9.\test.go:6:10: new(struct {}) escapes to heap 10.\test.go:11:17: new(struct {}) escapes to heap 11.\test.go:12:13: ... argument does not escape
-
闭包
当函数内部的局部变量被匿名函数捕获并在函数外被调用时,这些局部变量就会逃逸到堆上
1package main 2 3import "fmt" 4 5func closureEscape() func() int { 6 x := 10 7 // 返回一个闭包,该闭包捕获了外部函数的局部变量x 8 return func() int { 9 x += 5 10 return x 11 } 12} 13 14func main() { 15 // 调用closureEscape函数,并将返回的闭包赋值给变量f 16 f := closureEscape() 17 18 // 在main函数外部,继续使用闭包f,这时闭包中的局部变量x就逃逸到堆上 19 fmt.Println(f()) // 输出:15 20 fmt.Println(f()) // 输出:20 21}
1-- 通过选项 -gcflags=-m,查看变量逃逸的情况 2-- go build -gcflags=-m test.go 3 4$ go build -gcflags=-m test.go 5# command-line-arguments 6.\test.go:5:6: can inline closureEscape 7.\test.go:8:9: can inline closureEscape.func1 8.\test.go:16:20: inlining call to closureEscape 9.\test.go:8:9: can inline main.func1 10.\test.go:19:15: inlining call to main.func1 11.\test.go:19:13: inlining call to fmt.Println 12.\test.go:20:15: inlining call to main.func1 13.\test.go:20:13: inlining call to fmt.Println 14.\test.go:6:2: moved to heap: x 15.\test.go:8:9: func literal escapes to heap 16.\test.go:16:20: func literal does not escape 17.\test.go:19:13: ... argument does not escape 18.\test.go:19:15: ~R0 escapes to heap 19.\test.go:20:13: ... argument does not escape 20.\test.go:20:15: ~R0 escapes to heap
-
变量占用内存较大 / 大小不确定
由于栈所能分配的空间有限,当分配的内存超过了Go 编译器的阈值时,所分配的内存就会逃逸到堆上。
同时,如果分配的大小不确定,编译器不确定后续是否是大内存,此时也将变量在堆上分配。
1package main 2 3import "math/rand" 4 5func genLowM() { 6 nums := make([]int, 10) 7 for i := 0; i < 10; i++ { 8 nums[i] = rand.Int() 9 } 10} 11 12func genHigtM() { 13 nums := make([]int, 10000) 14 for i := 0; i < 10000; i++ { 15 nums[i] = rand.Int() 16 } 17} 18 19func generate(n int) { 20 nums := make([]int, n) // 不确定大小 21 for i := 0; i < n; i++ { 22 nums[i] = rand.Int() 23 } 24} 25 26func main() { 27 genLowM() 28 genHigtM() 29 generate(1) 30}
1$ go build -gcflags=-m test.go 2# command-line-arguments 3.\test.go:6:14: make([]int, 10) does not escape 4.\test.go:13:14: make([]int, 10000) escapes to heap 5.\test.go:20:14: make([]int, n) escapes to heap
-
动态类型(interface{})逃逸
如果变量是
interface{}
类型,编译期无法确定其具体的参数类型,所以内存分配到堆中。1package main 2 3import ( 4 "fmt" 5) 6 7func main() { 8 a := "1" 9 fmt.Println(a) 10} 11 12// fmt.Println() 的参数是interface{}类型
1$ go build -gcflags=-m test.go 2# command-line-arguments 3.\test.go:9:13: inlining call to fmt.Println 4.\test.go:9:13: ... argument does not escape 5.\test.go:9:13: a escapes to heap
如何利用逃逸分析提高性能
在函数参数传递时,传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆上,增加垃圾回收(GC)的负担。如果当对象频繁创建和删除时,传递指针会导致GC的开销影响性能。
对于需要修改原对象值,或占用较大内存的对象,需要选择传递指针。
对于只读且占用内存较小的对象,直接传值可以获得更好的性能