目录
Go程序为变量分配内存分为两途径:
- 1,全局的堆空间动态分配内存
- 2,每个goroutine的栈空间
一般来说开发者并不需要关心内存分配在栈上 or 还是堆上。但从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异还是非常大的。
在栈上分配和回收内存的开销很低,只需要2个CPU 指令:PUSH、POP。
前者是将数据push到栈空间以完成分配,后者则是释放空间,也就是说在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而在堆上分配,一个很大的额外开销则是垃圾回收。
在Go中,堆内存通过垃圾回收机制自动管理,Go的垃圾回收采用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。
标记清除算法的一个典型操作是在标记期间,需要STW,即暂停程序(Stop the world),标记结束之后,用户程序才可以继续执行。
堆内存分配导致垃圾回收的开销远大于栈空间分配与释放的开销。
那么 Go编译器怎么知道某个变量需要分配在栈 or 堆上呢?
编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。
go在编译的时候会进行逃逸分析,目的是决定一个对象放栈上还是堆上,不逃逸的对象放栈上,可能逃逸的放堆上。
最大的好处是减少gc的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除。
因为通过逃逸分析就可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好。
编译go代码时加
-gcflags "-m -l"
参数即可。
- func f(x, y int) *int {
- n := new(int) // new(int) escapes to heap
- *n = x * y
- return n
- }
- _ = f(10, 20)
上述代码中在函数结束时返回一个指针,此时进行编译,new(int)就会导致变量n逃逸到堆上。
此时n作为函数f的返回值会在main中继续使用,因此n指向的内存不能分配在栈上,会随着函数结束而被回收。
- _ = make([]int, 1000, 8191) // make([]int, 1000, 8191) does not escape 此时<64KB (int占用8字节)
- _ = make([]int, 1000, 8193) // make([]int, 1000, 8193) escapes to heap 此时>64KB
- _ = make([]int, 8193) // make([]int, 8193) escapes to heap
- _ = make([]int, 1000, 10000) // make([]int, 1000, 10000) escapes to heap
也就是说,你每次make时创建不同的长度,其实编译器都会做不同的处理,不同情况可能会导致开销变大;
当切片占用内存超过一定大小 或无法确定当前切片长度时,其内存将在堆上分配。
其达到多少会逃逸到堆上受操作系统对内核线程栈空间的大小限制。
- func f1() {
- s := 10
- _ = make([]int, s) // make([]int, s) escapes to heap
- }
-
- f1() // 因为变量s可能被更改,所以编译器认为应该分配到 heap
其它情况比如动态类型,入参为interface{},编译器无法确定到底是什么类型,也会发生逃逸。
就像fmt.Printf(),它的障眼法可不少。
Go语言支持闭包机制,如下:
- func f2() func() int {
- a, b := 1, 2
- return func() int { // func literal escapes to heap
- return a + b
- }
- }
-
- f2()
本来a和b作为函数局部变量应该分配到stack中,但是由于f()函数返回了一个闭包函数,该闭包函数访问了外部变量a和b, 此时若函数f2 return,实际a和b还是被引用,无法回收,因此编译器认为a和b应该分配到堆上。