4.2 内存逃逸分析
1. Stack and Heap
Golang 程序会在两个区域为变量分配内存:
- 栈(stack),每个 goroutine 持有自身独有的栈空间
- 堆(heap)
在栈上分配和回收内存开销很低,仅需两个CPU指令:PUSH
和POP
。
在堆上分配内存,很大的开销来自于 GC。
标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:
- 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
- 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。
标记清除算法的一个典型耗时是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。
2. 逃逸分析
逃逸分析(escape analysis) 是编译器决定内存的分配位置的方式。
2.1 指针逃逸
在函数中创建了一个对象,函数返回了对象的指针,此时对象无法分配在栈上,函数返回时将被回收,所以只能分配在堆上。
type s struct {
name string
}
func getS() *s {
return &s{}
}
func escape() {
a := getS()
_ = a
}
$go build -gcflags "-m" .\escape.go
.\escape.go:7:6: can inline getS
.\escape.go:11:6: can inline escape
.\escape.go:12:11: inlining call to getS
.\escape.go:8:9: &s{} escapes to heap
.\escape.go:12:11: &s{} does not escape
-gcflags -m
:查看编译器优化决策
可以看到&s{}
被逃逸到堆上。
2.3 动态类型逃逸
interface{}
可以存储任意类型,编译期难以判断具体类型,此时会发生逃逸。
func escapeAny() {
b := 1
fmt.Println(b)
}
func readAny(v interface{}) {
c := v
_ = c
}
// escape analysis
.\escape.go:20:14: b escapes to heap
.\escape.go:23:14: v does not escape
可以看到此时b
发生了逃逸。
2.4 栈空间不足
操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a
命令查看机器上栈允许占用的内存的大小。
因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。
对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。
若局部变量超过一定大小或大小不确定时,将发生逃逸。
func localEscape() {
s1 := make([]int, 0)
s2 := make([]int, 1024)
s3 := make([]int, 2048)
s4 := make([]int, 10000)
n := 1
s5 := make([]int, n)
_ = s1
_ = s2
_ = s3
_ = s4
_ = s5
}
.\escape.go:31:12: make([]int, 0) does not escape
.\escape.go:32:12: make([]int, 1024) does not escape
.\escape.go:33:12: make([]int, 2048) does not escape
.\escape.go:34:12: make([]int, 10000) escapes to heap
.\escape.go:36:12: make([]int, n) escapes to heap
2.5 闭包
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
— 闭包
func Increase() func() int {
n := 0
return func() int {
n++
return n
}
}
func closure() {
incre := Increase()
incre()
}
.\escape.go:46:2: moved to heap: n
因为函数Increase
返回了闭包函数,闭包函数访问了外部变量n
,此时n
将发生逃逸。
3. 根据逃逸分析提升性能
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。
传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
所以在一般情况下:
对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。
对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。