Go 内存逃逸分析
什么是内存逃逸
在 C、C++ 中,如果需要将对象分配到堆区,我们使用 malloc 或者 new 手动分配内存,并在对象使用完毕后,手动调用 free 或者 delete 释放内存
但在 Go 中,没有这个烦恼:
- Go 编译器会分析对象到底应该被分配在栈区,还是堆区
- Go 的 GC 会自动回收不再使用的对象,释放内存
那么,编译器是如何判断一个对象分配的位置呢?内存逃逸分析就是用来做这个事情的
内存逃逸会造成什么影响
内存逃逸,主要还是会产生性能方面的问题
分配内存
一个对象,如果分配在栈区,那么分配内存时,只需要 brk 系统调用,移动栈顶指针即可;
如果分配在堆区,那么需要 mmap 系统调用,去 OS 申请一块内存,效率不如 brk
此外,brk 会额外申请一部分内存,减少系统调用次数,进而减少变态开销
释放内存
一个对象,如果分配在栈区,那么在函数调用完成后,会自动的将该对象占用的栈空间释放(这里需要一定的汇编基础来理解)
这意味着,我们不需要自己去释放这个对象占用的空间,这会减轻 GC 的压力(STW 时间更短)
常见内存逃逸场景
指针
package main
type Foo struct {
A int
B int
}
func Bar() *Foo {
f := new(Foo)
return f
}
func main() {
Bar()
}
在编译时,加上 -gcflags=-m 启用逃逸分析,输出如下:
Sky_Lee@SkyLeeMBP test % go build -gcflags=-m main.go
# command-line-arguments
./main.go:8:6: can inline Bar
./main.go:13:6: can inline main
./main.go:14:5: inlining call to Bar
./main.go:9:10: new(Foo) escapes to heap
./main.go:14:5: new(Foo) does not escape
可以看到,在 return f 这里发生了内存逃逸:f 逃逸到了堆
为什么不能将 f 分配到栈?
如果 f 分配在栈区,在 Bar 调用结束后,f 的生命周期就结束了,会自动释放 f 占用的空间,那么外部的函数还要使用这个返回的 f,会产生未定义的行为
空接口(any)
package main
import "fmt"
type Foo struct {
A int
B int
}
func Bar() *Foo {
f := new(Foo) // line 9
return f
}
func main() {
f := Bar() // line 17
fmt.Println(f)
}
编译时输出:
Sky_Lee@SkyLeeMBP test % go build -gcflags=-m main.go
# command-line-arguments
./main.go:10:6: can inline Bar
./main.go:17:10: inlining call to Bar
./main.go:18:13: inlining call to fmt.Println
./main.go:11:10: new(Foo) escapes to heap
./main.go:17:10: new(Foo) escapes to heap
./main.go:18:13: ... argument does not escape
可以发现,在 17 行也发生了逃逸,这是因为 fmt.Println() 的参数是 any 类型,编译时无法判断其实际类型,无法确定其占用空间,只能分配在堆区
如果传递的参数是 f.A,那么 17 行不会发生逃逸,但 f.A 仍会逃逸
栈空间不足
一个进程的栈空间大小是有限的(使用 ulimit -s 查看),通常为 8192k
当一个对象的大小超过栈空间的大小,那只能分配在堆区
函数闭包
package main
import "fmt"
type Bar = func ()
func Foo() Bar {
n := 0
return func() {
n++
fmt.Printf("n: %d\n", n)
}
}
func main() {
}
编译输出:
Sky_Lee@SkyLeeMBP test % go build -gcflags=-m main.go
# command-line-arguments
./main.go:7:6: can inline Foo
./main.go:11:13: inlining call to fmt.Printf
./main.go:15:6: can inline main
./main.go:8:2: moved to heap: n
./main.go:9:9: func literal escapes to heap
./main.go:11:13: ... argument does not escape
./main.go:11:25: n escapes to heap
对象 n 发生了逃逸,因为 n 的生命周期已经与返回的 Bar 一致了,只有 Bar 被销毁,n 才会随着销毁,因此,必须将 n 分配到堆区
传值还是传指针
package main
type Foo struct {
A int
}
func Bar0(f *Foo) {
}
func Bar1(f Foo) {
}
func Test0() {
f0 := Foo{}
f1 := Foo{}
Bar0(&f0)
Bar1(f1)
}
func Test1() {
f0 := Foo{}
f1 := Foo{}
go Bar0(&f0) // line: 23
go Bar1(f1)
}
func main() {
}
输出:
Sky_Lee@SkyLeeMBP test % go build -gcflags=-m main.go
# command-line-arguments
./main.go:7:6: can inline Bar0
./main.go:10:6: can inline Bar1
./main.go:13:6: can inline Test0
./main.go:16:6: inlining call to Bar0
./main.go:17:6: inlining call to Bar1
./main.go:27:6: can inline main
./main.go:7:11: f does not escape
./main.go:21:2: moved to heap: f0
这里不再分析为什么 Test1 的 f0 需要分配在堆区
从性能角度考虑:传值还是传指针
- 传值:存在一次拷贝带来的性能开销
- 传指针:仅拷贝指针本身,拷贝带来的性能影响忽略不计,但是可能逃逸到堆区
那么,在设计函数时,到底传值还是传指针呢?
- 如果对象本身不大,拷贝带来的性能影响较小,传值,减小 GC 负担
- 如果对象占用空间较大,无法忽略拷贝的性能开销,传指针