堆内存与栈内存

Go 程序会在 2 个地方为变量分配内存,一个是全局的堆(heap)空间用来动态分配内存,另一个是每个 goroutine 的栈(stack)空间。与 Java、Python 等语言类似,Go 语言实现垃圾回收(Garbage Collector)机制,因此,Go 语言的内存管理是自动的,通常开发者不需要关心内存分配在栈上,还是堆上。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异是非常大的。

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

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

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

函数参数是值传递的,且在调用的时立即执行值拷贝的。所以无论传递什么参数都会被copy到函数的参数变量的内存地址中,堆或者栈上,具体是堆还是栈上涉及到逃逸问题

什么是逃逸分析

逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为

确定一个变量是在堆上还是在栈上 ?

  1. 是否有在其他地方(非局部)被引用。只要有可能被引用了,那么它一定分配到堆上。否则分配到栈上。
  2. 即使没有被外部引用,但对象过大,无法存放在栈区上。依然有可能分配到堆上。

比如这样的例子

  1. func main() {
  2. var i int
  3. fmt.Printf("main: %p\n", &i)
  4. foo(i)
  5. }
  6. func foo(i int) {
  7. fmt.Printf("foo : %p\n", &i)
  8. }
  9. // 输出的变量地址不一样
  10. main: 0xc0000382b0
  11. foo : 0xc0000382b8

所以对于复杂结构应该尽量的传递指针减少copy时的开销。

指针传递的同时也带来变量逃逸,和GC压力,也是一把双刃剑,好在大部分情况下不需要特别的对GC进行调优。所以,在make it simple的理念下,在需要时再针对性调优是个不错的选择。

什么时候我们应该传递值,什么时候应该传递指针,这主要取决于copy开销和是否需要在函数内部对变量值进行更改。

指针逃逸

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

  1. // main_pointer.go
  2. package main
  3. import "fmt"
  4. type Demo struct {
  5. name string
  6. }
  7. func createDemo(name string) *Demo {
  8. d := new(Demo) // 局部变量 d 逃逸到堆
  9. d.name = name
  10. return d
  11. }
  12. func main() {
  13. demo := createDemo("demo")
  14. fmt.Println(demo)
  15. }

这个例子中,函数 createDemo 的局部变量 d 发生了逃逸。d 作为返回值,在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上,随着函数结束而回收,只能分配在堆上。

编译时可以借助选项 -gcflags=-m,查看变量逃逸的情况:

  1. go build -gcflags=-m main_pointer.go

2.3 interface{} 动态类型逃逸

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

  1. func main() {
  2. demo := createDemo("demo")
  3. fmt.Println(demo)
  4. }

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

对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小最也不会超过操作系统的限制。
对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。

当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。

1. Go 语言局部变量分配在栈还是堆?

  • 视逃逸分析结果而定,Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析,当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆。

2. 发生逃逸的三种情况

  • 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
  • 被已经逃逸的变量引用的指针,一定发生逃逸;
  • 被指针类型的slice、map和chan引用的指针,一定发生逃逸;

3. 必然不会逃逸

  • 指针被未发生逃逸的变量引用;
  • 仅仅在函数内对变量做取址操作,而未将指针传出;

4. 可能发生逃逸,也可能不会发生逃逸:

  • 将指针作为入参传给别的函数;这里还是要看指针在被传入的函数中的处理过程,如果发生了上边的三种情况,则会逃逸;否则不会逃逸;