堆内存与栈内存
Go 在 2 个位置为变量分配内存,全局堆(heap)空间,和每个 goroutine 的栈(stack)空间。
Go 语言实现 GC 机制,因此开发者不需要关心内存分配在栈上,还是堆上。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异是非常大的。
在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收;如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。
在栈上分配和回收内存的开销很低,只需要 2 个 CPU 指令:PUSH 和 POP,也就是说,在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而内存的 I/O 通常能够达到 30GB/s。
在堆上分配内存主要的开销是垃圾回收。Go 语言使用标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。
逃逸分析
Go 语言中堆内存由 GC 机制自动管理的。编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。
指针
函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,该对象的内存不能随着函数结束而回收,因此只能分配在堆上。
1package main
2
3import "fmt"
4
5type Demo struct {
6 name string
7}
8
9func createDemo(name string) *Demo {
10 d := new(Demo) // 局部变量 d 逃逸到堆
11 d.name = name
12 return d
13}
14
15func main() {
16 demo := createDemo("demo")
17 fmt.Println(demo)
18}
其中,局部变量 d
发生了逃逸。d 作为返回值在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上随着函数结束而回收,只能分配在堆上。
编译时借助选项 -gcflags=-m
可以查看变量逃逸的情况:
1go build -gcflags=-m cmd/main.go
2# command-line-arguments
3cmd/main.go:9:6: can inline createDemo
4cmd/main.go:16:20: inlining call to createDemo
5cmd/main.go:17:13: inlining call to fmt.Println
6cmd/main.go:9:17: leaking param: name
7cmd/main.go:10:10: new(Demo) escapes to heap
8cmd/main.go:16:20: new(Demo) escapes to heap
9cmd/main.go:17:13: ... argument does not escape
interface
空接口即 interface{}
可以表示任意的类型,如果函数参数为 interface{}
,编译期间很难确定其参数的具体类型,也会发生逃逸。
1func main() {
2 demo := createDemo("demo")
3 fmt.Println(demo)
4}
demo
是 main 函数中的一个局部变量,该变量作为实参传递给 fmt.Println()
,但是因为 fmt.Println()
的参数类型定义为 interface{}
,因此也发生了逃逸。
栈空间不足
操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。
可以使用 ulimit -a
命令查看机器上栈允许占用的内存的大小。
1# ulimit -a
2real-time non-blocking time (microseconds, -R) unlimited
3core file size (blocks, -c) 0
4data seg size (kbytes, -d) unlimited
5scheduling priority (-e) 0
6file size (blocks, -f) unlimited
7pending signals (-i) 127151
8max locked memory (kbytes, -l) unlimited
9max memory size (kbytes, -m) unlimited
10open files (-n) 131072
11pipe size (512 bytes, -p) 8
12POSIX message queues (bytes, -q) 819200
13real-time priority (-r) 0
14stack size (kbytes, -s) 8192
15cpu time (seconds, -t) unlimited
16max user processes (-u) 65535
17virtual memory (kbytes, -v) unlimited
18file locks (-x) unlimited
1stack size (kbytes, -s) 8192
因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。
对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。
对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。
1package main
2
3import "math/rand"
4
5func generate8192() {
6 nums := make([]int, 8192) // = 64KB
7 for i := 0; i < 8192; i++ {
8 nums[i] = rand.Int()
9 }
10}
11
12func generate8193() {
13 nums := make([]int, 8193) // > 64KB
14 for i := 0; i < 8193; i++ {
15 nums[i] = rand.Int()
16 }
17}
18
19func generate(n int) {
20 nums := make([]int, n) // 不确定大小
21 for i := 0; i < n; i++ {
22 nums[i] = rand.Int()
23 }
24}
25
26func main() {
27 generate8192()
28 generate8193()
29 generate(1)
30}
1# go build -gcflags=-m main.go
2# command-line-arguments
3cmd/main.go:6:14: make([]int, 8192) does not escape
4cmd/main.go:13:14: make([]int, 8193) escapes to heap
5cmd/main.go:20:14: make([]int, n) escapes to heap
make([]int, 8192)
没有发生逃逸,make([]int, 8193)
和make([]int, n)
逃逸到堆上。
也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。
闭包
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
1func Increase() func() int {
2 n := 0
3 return func() int {
4 n++
5 return n
6 }
7}
8
9func main() {
10 in := Increase()
11 fmt.Println(in()) // 1
12 fmt.Println(in()) // 2
13}
Increase()
返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in
被销毁。很显然,变量 n 占用的内存不能随着函数 Increase()
的退出而回收,因此将会逃逸到堆上。
1./main.go:6:2: moved to heap: n
2./main.go:7:9: func literal escapes to heap
3./main.go:14:16: func literal does not escape
4./main.go:15:13: ... argument does not escape
5./main.go:15:16: ~R0 escapes to heap
6./main.go:16:13: ... argument does not escape
7./main.go:16:16: ~R0 escapes to heap
传值 VS 传指针
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。
传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加 GC 开销。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的、占用内存较小的结构体,直接传值能够获得更好的性能。