Golang内存逃逸

内存管理

内存管理主要包括两个动作:分配与释放。逃逸分析服务于内存分配,而内存的释放由GC负责。

堆与栈

在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收,如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。

在栈上分配和回收内存的开销很低,只需要 2 个 CPU 指令:PUSH 和 POP,一个是将数据 push 到栈空间以完成分配,pop 则是释放空间,也就是说在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而内存的 I/O 通常能够达到 30GB/s,因此在栈上分配内存效率是非常高的。

在堆上分配内存,一个很大的额外开销则是垃圾回收。Go 语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。

逃逸分析

C 语言,使用 malloc 和 free 手动在堆上分配和回收内存。Go 语言,堆内存是通过垃圾回收机制自动管理。

Go 编译器怎么知道某个变量需要分配在栈上,还是堆上呢?编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。

指针逃逸

在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main  

import "fmt"

type Demo struct {
name string
}

func createDemo(name string) *Demo {
d := new(Demo) // 局部变量 d 逃逸到堆
d.name = name
return d
}

// go build -gcflags=-m escape.go
func main() {
demo := createDemo("demo")
fmt.Println(demo)
}
1
2
3
4
5
6
7
8
9
╰─❯ go build -gcflags=-m escape.go
# command-line-arguments
./escape.go:9:6: can inline createDemo
./escape.go:17:20: inlining call to createDemo
./escape.go:18:13: inlining call to fmt.Println
./escape.go:9:17: leaking param: name
./escape.go:10:10: new(Demo) escapes to heap
./escape.go:17:20: new(Demo) escapes to heap
./escape.go:18:13: ... argument does not escape

new(Demo) escapes to heap 即表示 new(Demo) 逃逸到堆上了。

interface{} 动态类型逃逸

在 Go 语言中,空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

1
2
3
4
func main() {  
demo := createDemo("demo")
fmt.Println(demo)
}
1
./main_pointer.go:18:13: demo escapes to heap

demo 是 main 函数中的一个局部变量,该变量作为实参传递给 fmt.Println(),但是因为 fmt.Println() 的参数类型定义为 interface{},因此也发生了逃逸。

栈空间不足

操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a 命令查看机器上栈允许占用的内存的大小。

因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出(stack overflow)。

Go,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。

对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。

make([]int, 8191) 没有发生逃逸,make([]int, 8192) 和make([]int, n) 逃逸到堆上,也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。

闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
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() 的退出而回收,因此将会逃逸到堆上。

利用逃逸分析提升性能

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。

一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。

参考

1 Go 逃逸分析 | Go 语言高性能编程 | 极客兔兔