一、堆内存与栈内存

  • 堆(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、什么是逃逸分析

mallocfree

Go 语言中,堆内存是通过垃圾回收机制自动管理的,无需开发者指定。那么,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)
}
createDemod
-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 heapnew(Demo)

2.3、interface{} 动态类型逃逸

interface{}interface{}
demo
func main() {
	demo := createDemo("demo")
	fmt.Println(demo)
}
demofmt.Println()fmt.Println()interface{}
fmtPrintln
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)
}
demodemo.name

2.4、栈空间不足

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()generate8192()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()inIncrease()
$ go build -gcflags=-m main_closure.go 
# command-line-arguments
./main_closure.go:6:2: moved to heap: n 

三、为何需要逃逸分析

堆栈逃逸分析
gcgc

四、如何确定是否逃逸

Go
go run -gcflags '-m -l' main.go
-m-m-l内联

五、如何利用逃逸分析提升性能

传值 VS 传指针

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

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

不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。