【Golang】关于Go语言中逃逸分析
一、堆内存与栈内存
-
堆(Heap):一般来讲是人为手动进行管理,手动申请、分配、释放。堆适合不可预知大小的内存分配,这也意味着为此付出的代价是分配速度较慢,而且会形成内存碎片。
-
栈(Stack):由编译器进行管理,自动申请、分配、释放。一般不会太大,因此栈的分配和回收速度非常快;我们常见的函数参数(不同平台允许存放的数量不同),局部变量等都会存放在栈上。
Go 程序会在 2 个地方为变量分配内存,一个是全局的堆(heap)空间用来动态分配内存,另一个是每个 goroutine 的栈(stack)空间。与 Java、Python 等语言类似,Go 语言实现垃圾回收(Garbage Collector)机制,因此呢,Go 语言的内存管理是自动的,通常开发者并不需要关心内存分配在栈上,还是堆上。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异是非常大的。
在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收,如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。在栈上分配和回收内存的开销很低,只需要 2 个 CPU 指令:PUSH 和 POP,一个是将数据 push 到栈空间以完成分配,pop 则是释放空间,也就是说在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而内存的 I/O 通常能够达到 30GB/s,因此在栈上分配内存效率是非常高的。
通俗比喻的说,栈
就如我们去饭馆吃饭,只需要点菜(发出申请)--》吃吃吃(使用内存)--》吃饱就跑剩下的交给饭馆(操作系统自动回收),而堆
就如在家里做饭,大到家,小到买什么菜,每一个环节都需要自己来实现,但是自由度会大很多。
在堆上分配内存,一个很大的额外开销则是垃圾回收。Go 语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。
标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:
- 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
- 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。
标记清除算法的一个典型耗时是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。
Go 语言垃圾回收的原理就不在这里赘述了,总之,堆内存分配导致垃圾回收的开销远远大于栈空间分配与释放的开销。
二、逃逸分析
2.1、什么是逃逸分析
在 C 语言中,可以使用 malloc
和 free
手动在堆上分配和回收内存。
Go 语言中,堆内存是通过垃圾回收机制自动管理的,无需开发者指定。那么,Go 编译器怎么知道某个变量需要分配在栈上,还是堆上呢?编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,简单来说就是分析在程序的哪些地方可以访问到该指针。
再往简单的说,Go是通过在编译器里做逃逸分析(escape analysis)来决定一个对象放栈上还是放堆上,不逃逸的对象放栈上,可能逃逸的放堆上;即我发现变量
在退出函数后没有用了,那么就把丢到栈上,毕竟栈上的内存分配和回收比堆上快很多;反之,函数内的普通变量经过逃逸分析
后,发现在函数退出后变量
还有在其他地方上引用,那就将变量
分配在堆上。做到按需分配(哪里的人民需要我,我就往哪去~~,一个党员的呐喊)。
2.2、指针逃逸
指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。
// main_pointer.go package main import "fmt" type Demo struct { name string } func createDemo(name string) *Demo { d := new(Demo) // 局部变量 d 逃逸到堆 d.name = name return d } func main() { demo := createDemo("demo") fmt.Println(demo) }
这个例子中,函数 createDemo
的局部变量 d
发生了逃逸。d 作为返回值,在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上,随着函数结束而回收,只能分配在堆上。
编译时可以借助选项 -gcflags=-m
,查看变量逃逸的情况:
$ go build -gcflags=-m main_pointer.go ./main_pointer.go:10:6: can inline createDemo ./main_pointer.go:17:20: inlining call to createDemo ./main_pointer.go:18:13: inlining call to fmt.Println ./main_pointer.go:10:17: leaking param: name ./main_pointer.go:11:10: new(Demo) escapes to heap ./main_pointer.go:17:20: new(Demo) escapes to heap ./main_pointer.go:18:13: demo escapes to heap ./main_pointer.go:18:13: main []interface {} literal does not escape ./main_pointer.go:18:13: io.Writer(os.Stdout) escapes to heap <autogenerated>:1: (*File).close .this does not escape
new(Demo) escapes to heap
即表示 new(Demo)
逃逸到堆上了。
2.3、interface{} 动态类型逃逸
在 Go 语言中,空接口即 interface{}
可以表示任意的类型,如果函数参数为 interface{}
,编译期间很难确定其参数的具体类型,也会发生逃逸。
例如上面例子中的局部变量 demo
:
func main() { demo := createDemo("demo") fmt.Println(demo) }
demo
是 main 函数中的一个局部变量,该变量作为实参传递给 fmt.Println()
,但是因为 fmt.Println()
的参数类型定义为 interface{}
,因此也发生了逃逸。
fmt
包中的 Println
函数的定义如下:
func Println(a ...interface{}) (n int, err error) { return Fprintln(os.Stdout, a...) }
如果我们将上面的例子修改为:
func test(demo *Demo) { fmt.Println(demo.name) } func main() { demo := createDemo("demo") test(demo) }
这种情况下,局部变量 demo
不会发生逃逸,但是 demo.name
仍旧会逃逸。
2.4、栈空间不足
操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a
命令查看机器上栈允许占用的内存的大小。
$ ulimit -a -s: stack size (kbytes) 8192 -n: file descriptors 12800 ...
因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。
对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。
对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。我们来做一个实验:
func generate8191() { nums := make([]int, 8191) // < 64KB for i := 0; i < 8191; i++ { nums[i] = rand.Int() } } func generate8192() { nums := make([]int, 8192) // = 64KB for i := 0; i < 8192; i++ { nums[i] = rand.Int() } } func generate(n int) { nums := make([]int, n) // 不确定大小 for i := 0; i < n; i++ { nums[i] = rand.Int() } } func main() { generate8191() generate8192() generate(1) }
generate8191()
创建了大小为 8191 的 int 型切片,恰好小于 64 KB(64位机器上,int 占 8 字节),不包含切片内部字段占用的内存大小。generate8192()
创建了大小为 8192 的 int 型切片,恰好占用 64 KB。generate(n)
,切片大小不确定,调用时传入。
编译结果如下:
$ go build -gcflags=-m main_stack.go # command-line-arguments ./main_stack.go:9:14: generate8191 make([]int, 8191) does not escape ./main_stack.go:16:14: make([]int, 8192) escapes to heap ./main_stack.go:23:14: make([]int, n) escapes to heap
make([]int, 8191)
没有发生逃逸,make([]int, 8192)
和make([]int, n)
逃逸到堆上,也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。
2.5、闭包
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
例如:
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()
的退出而回收,因此将会逃逸到堆上。
$ go build -gcflags=-m main_closure.go # command-line-arguments ./main_closure.go:6:2: moved to heap: n
三、为何需要逃逸分析
了解完堆
和栈
各自的优缺点后,我们就可以更好的知道逃逸分析
存在的目的了:
-
减少
gc
压力,栈上的变量,随着函数退出后系统直接回收,不需要gc
标记后再清除。 -
减少内存碎片的产生。
-
减轻分配堆内存的开销,提高程序的运行速度。
四、如何确定是否逃逸
在Go
中通过逃逸分析日志来确定变量是否逃逸,开启逃逸分析日志:
go run -gcflags '-m -l' main.go
-m
会打印出逃逸分析的优化策略,实际上最多总共可以用 4 个-m
,但是信息量较大,一般用 1 个就可以了。-l
会禁用函数内联,在这里禁用掉内联
能更好的观察逃逸情况,减少干扰。
五、如何利用逃逸分析提升性能
传值 VS 传指针
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。
不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。
- 作者:踏雪无痕
- 出处:http://www.cnblogs.com/chenpingzhao/
- 本文版权归作者和博客园共有,如需转载,请联系 pingzhao1990#163.com