Golang 逃逸分析
# Go 逃逸分析
Golang 版本:go version go1.19.3 linux/amd64
# 堆内存与栈内存
Go 是一种带有垃圾回收(Garbage Collector)机制的语言,它的内存管理是自动,不需要开发者手工管理。Go 程序跟其他编程语言一样,会在两个地方分配内存,一个是全局的堆(heap)空间用来动态分配内存,另一个是每个 goroutine 的栈(stack)空间。其中,如果分配在栈中,会随着函数的执行结束而自动回收;如果分配在堆中,则函数执行结束后不会自动回收,需要交给 GC(垃圾回收)来处理。
因此,从性能角度来看,在栈上分配内存和在堆上分配内存,性能差异是非常大的。
# 什么是逃逸分析
所谓逃逸分析(Escape analysis)是指由编译器决定内存分配的位置,不需要开发者指定。逃逸分析由编译器完成,作用于编译阶段。
# 逃逸策略
当函数中新申请了对象,编译器会根据该对象是否被函数外部引用来决定是否逃逸:
- 如果函数外部没有引用,则优先放在栈中;
- 如果函数外部存在引用,则必定放在堆中;
- 如果函数外部没有引用,但是对象内存过大超过栈的存储能力,也会放在堆中。
# 逃逸场景
# 指针逃逸
函数可以返回指针类型,例如在函数里创建一个局部变量指针,并将该变量作为返回值。这种情况下,函数执行完了,但是因为指针的存在,对象的内存不能随函数的结束而回收,因此只能分配到堆上。
package main
import "fmt"
type Person struct {
name string
}
func NewPerson(name string) *Person {
p := &Person{ // 局部变量 p 逃逸到堆
name: name,
}
return p
}
func main() {
person := NewPerson("tom")
fmt.Println(person)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这个例子中,函数 NewPerson
的局部变量 p
发生了逃逸,p
本身为指针,其指向的内存地址不会是栈而是堆。
通过编译参数-gcflag=-m
可以查看编译过程中的逃逸分析:
# go build -gcflags=-m main.go
# command-line-arguments
./main.go:7:6: can inline NewPerson
./main.go:14:6: can inline main
./main.go:15:11: inlining call to NewPerson
./main.go:7:16: leaking param: name
./main.go:8:7: &Person{...} escapes to heap
./main.go:15:11: &Person{...} does not escape
2
3
4
5
6
7
8
# interface{}
动态类型逃逸
空接口即 interface{}
可以表示任意的类型,很多函数参数为interface
类型,比如fmt.Println(a ...interface{})
,编译期间很难确定其参数的具体类型,也会产生逃逸。
package main
import "fmt"
func main() {
fmt.Println("hello world")
}
2
3
4
5
6
7
# go build -gcflags=-m main.go
# command-line-arguments
./main.go:5:6: can inline main
./main.go:6:13: inlining call to fmt.Println
./main.go:6:13: ... argument does not escape
./main.go:6:14: "hello world" escapes to heap
2
3
4
5
6
fmt.Println
的参数类型为any
即interface{}
,所以发生了逃逸。
# 栈空间不足
当栈空间不足以存放当前对象或无法判断当前切片长度时会将对象分配到堆中。
操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -s
命令查看机器上栈允许占用的内存的大小。
# ulimit -s
8192
2
递归深度过深时,可能会因为超过了栈空间大小而导致栈溢出。对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。
package main
func Slice8192() {
_ = make([]int, 8192) // = 64KB
}
func Slice8193() {
_ = make([]int, 8193) // > 64KB
}
func SliceUnknown(n int) {
_ = make([]int, n) // 不确定大小
}
func main() {
Slice8192()
Slice8193()
SliceUnknown(1)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Slice8192()
创建了大小为 8192 的 int 型切片,恰好占用 64 KB(64位机器上,int 占 8 字节),不包含切片内部字段占用的内存大小。Slice8193()
创建了大小为 8193 的 int 型切片,恰好大于 64 KB。SliceUnknown(n)
,切片大小不确定,调用时传入。
编译结果如下:
# go build -gcflags=-m main.go
# command-line-arguments
...
./main.go:4:10: make([]int, 8192) does not escape
./main.go:9:10: make([]int, 8193) escapes to heap
./main.go:13:10: make([]int, n) escapes to heap
...
2
3
4
5
6
7
make([]int, 8192)
没有发生逃逸,make([]int, 8193)
和make([]int, n)
逃逸到堆上,也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。
# 闭包引用对象逃逸
下面是一个Fibonacci
函数:
package main
import "fmt"
func Fibonacci() func() int {
a, b := 0, 1
return func() int {
a, b = b, a+b
return a
}
}
func main() {
f := Fibonacci()
for i := 0; i < 10; i++ {
fmt.Printf("Fibonacci: %d\n", f())
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Fibonacci()
返回值是一个闭包函数,该闭包函数访问了外部变量a
和b
,那变量 n 变量a
和b
将会一直存在,直到 f
被销毁。很显然,变量a
和b
占用的内存不能随着函数 Fibonacci()
的退出而回收,因此将会逃逸到堆上。
编译结果如下:
# go build -gcflags=-m main.go
# command-line-arguments
./main.go:5:6: can inline Fibonacci
./main.go:7:9: can inline Fibonacci.func1
./main.go:14:16: inlining call to Fibonacci
./main.go:7:9: can inline main.func1
./main.go:17:34: inlining call to main.func1
./main.go:17:13: inlining call to fmt.Printf
./main.go:6:2: moved to heap: a
./main.go:6:5: moved to heap: b
./main.go:7:9: func literal escapes to heap
./main.go:14:16: func literal does not escape
./main.go:17:13: ... argument does not escape
./main.go:17:34: ~R0 escapes to heap
2
3
4
5
6
7
8
9
10
11
12
13
14
# 利用逃逸分析提升性能
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。
# 总结
- 栈上分配内存比在堆上分配内存有更高的效率
- 栈上分配的内存不需要 GC 处理
- 堆上分配的内存使用完后需要 GC 处理
- 逃逸分析目的是决定分配地址是栈还是堆
- 逃逸分析在编译阶段完成