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