Go逃逸分析(escape analysis)是指Go编译器会在编译阶段判断程序中的对象是位于堆区还是位于栈区。C/C++在给对象做内存分配时,对象位于堆区还是栈区是很容易辨认的:使用malloc/new动态分配内存空间的对象,都位于堆区;而局部变量都位于栈区。但是对于Go而言,定义的对象是位于堆区还是栈区是由编译器来决定。位于栈区的对象在函数调用返回后可以直接自动回收,但是位于堆区的对象就必须由GC统一回收
在C/C++的世界里,局部变量位于栈区,如果函数返回局部变量的指针给外部使用,这个指针就会变为野指针,在外部使用时就有可能发生内存越界。但在Go的世界里,有了逃逸分析,返回函数局部变量就变成可能。
逃逸策略
在函数中申请新的对象,编译器会根据该对象是否被函数外部引用来决定是否逃逸:
- 如果函数外部没有引用,则优先放到栈中;
- 如果函数外部存在引用,则优先放在堆中。
- 如果函数内部使用的变量所需内存过大,则放在堆中。
- 动态参数因为编译器难以确认其类型,安全起见都放在堆中。
逃逸场景
我们可以通过go build -gcflags=-m
编译参数来分析逃逸情况。
指针逃逸
第一个案例,我们在main中new了一个变量
func main() {
a := new(int)
*a = 10
}
逃逸分析看出,即使我们显式调用new,但因为变量a没有被外部调用,因此a也是分配到栈上的,因此没有发生逃逸。
junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
# chan
./main.go:5:6: can inline main
./main.go:6:10: new(int) does not escape
第二个案例,函数内new创建对象,函数返回对象指针,但外部幷未使用该指针。
type Student struct {
Name string
Age int
}
func GetStudentInfo(name string, age int) *Student {
stu := new(Student)
stu.Name = name
stu.Age = age
return stu
}
func main() {
GetStudentInfo("Ken", 10)
}
逃逸分析看出,new(Student)发生了逃逸,Student对象分配到了堆区。所以不管返回的对象的指针是否有被外部使用,一旦作为了函数返回值,就一定发生了逃逸。
junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
# chan
./main.go:10:6: can inline GetStudentInfo
./main.go:17:6: can inline main
./main.go:18:16: inlining call to GetStudentInfo
./main.go:10:21: leaking param: name
./main.go:11:12: new(Student) escapes to heap
./main.go:18:16: new(Student) does not escape
第三个案例,这一次函数不用new创建对象,而是创建局部变量对象,函数返回对象指针,但外部幷未使用该指针。
type Student struct {
Name string
Age int
}
func GetStudentInfo(name string, age int) *Student {
stu := Student{}
stu.Name = name
stu.Age = age
return &stu
}
func main() {
GetStudentInfo("Ken", 10)
}
从逃逸分析看出,stu本来是局部变量,但是作为返回值返回时这个变量被重新放入堆区存储,因此是发生了逃逸,即使该变量并未被外部引用。
junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
# chan
./main.go:10:6: can inline GetStudentInfo
./main.go:17:6: can inline main
./main.go:18:16: inlining call to GetStudentInfo
./main.go:10:21: leaking param: name
./main.go:11:2: moved to heap: stu
栈空间不足逃逸
这个案例是在函数中定义一个大对象,这里用了make slice来创建容量为100的slice,里面元素类型为Student。
type Student struct {
Name string
Age int
Grade int
}
func MakeStudents(grade int) {
s := make([]Student, 100, 100)
for index, _ := range s {
s[index].Grade = grade
}
}
func main() {
MakeStudents(6)
}
逃逸分析看出,当slice容量为100时,并没有发生逃逸,s指向的空间是放在栈上的。
junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
# chan
./main.go:18:6: can inline main
./main.go:12:11: make([]Student, 100, 100) does not escape
当我们把slice大小增加到10000时,情况就不一样了,因为此时栈容量已经不足。
type Student struct {
Name string
Age int
Grade int
}
func MakeStudents(grade int) {
s := make([]Student, 10000, 10000)
for index, _ := range s {
s[index].Grade = grade
}
}
func main() {
MakeStudents(6)
}
从逃逸分析看出,此时发生了逃逸。
junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
# chan
./main.go:18:6: can inline main
./main.go:12:11: make([]Student, 10000, 10000) escapes to heap
动态类型逃逸
下面这里案例,函数参数是interface类型,fmt.Println(a …interface{})。
func main() {
s := "hello"
fmt.Println(s)
}
从逃逸分析看出,s发生逃逸。其实这也很符合正常思维:因为是动态变量,编译器在编译过程中无法确定其真实类型,因此直接把该变量放入堆区存放是最稳妥的方式。
junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
# chan
./main.go:9:13: inlining call to fmt.Println
./main.go:9:13: s escapes to heap
./main.go:9:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
闭包引用
闭包让你可以在一个内层函数中访问到其外层函数的作用域。下面这个案例就是闭包引用。
func Increase() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
in := Increase()
fmt.Println(in()) // 1
fmt.Println(in()) // 2
}
Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。
junshideMacBook-Pro:chan junshili$ go build -gcflags=-m
# chan
./main.go:9:9: can inline Increase.func1
./main.go:17:13: inlining call to fmt.Println
./main.go:18:13: inlining call to fmt.Println
./main.go:8:2: moved to heap: n
./main.go:9:9: func literal escapes to heap
./main.go:17:16: in() escapes to heap
./main.go:17:13: []interface {} literal does not escape
./main.go:18:16: in() escapes to heap
./main.go:18:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
传值还是传指针?
指针传递可以减少底层值的复制,可以提高效率。但是如果复制的对象太小,由于指针传递会产生逃逸,则可能会使用堆分配,这会增加GC的负担。GC是个很重的操作,需要stop the world,程序会发生卡顿,逃逸的对象越多,GC时间会越长,所以传递指针不一定是高效的。一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。