GO的逃逸分析
- GO语言是如何进行内存分配的呢?其设计初衷和实现原理是什么呢?
**栈:**在Go中,栈的内存是由编译器自动进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁。
一个goroutine对应一个栈,栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系,每一帧对应一个尚未返回的函数调用,它本身也是以栈形式存放数据。
堆:与栈不同的是,应用程序在运行时只会存在一个堆。
我们可以简单理解为:我们用GO语言开发过程中,要考虑的内存管理只是针对堆内存而言的。
程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。
堆和栈的对比
加锁
- 栈不需要加锁:栈是每个goroutine独有的,这就意味着栈上的内存操作是不需要加锁的。
- 堆有时需要加锁:堆上的内存,有时需要加锁防止多线程冲突
延伸知识点:为什么堆上的内存有时需要加锁?而不是一直需要加锁呢?
因为Go的内存分配策略学习了TCMalloc的线程缓存思想,他为每个处理器P分配了一个mcache,从mcache分配内存也是无锁的
性能
- 栈内存管理 性能好:栈上的内存,它的分配与释放非常高效的。简单地说,它只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。只需要借助于栈相关寄存器即可完成。
- 堆内存管理 性能差:对于程序堆上的内存回收,还需要通过标记清除阶段,例如Go采用的三色标记法。
- 栈缓存性能更好
- 堆缓存性能较差
原因是:栈内存能更好地利用CPU的缓存策略,因为栈空间相较于堆来说是更连续的。
逃逸分析
Go编译器会尽可能将变量分配到到栈上:编译器通过逃逸分析技术去选择堆或者栈,逃逸分析的基本思想如下:检查变量的生命周期是否是完全可知的,如果通过检查,则在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。
- 不同于JAVA JVM的运行时逃逸分析,Go的逃逸分析是在编译期完成的:编译期无法确定的参数类型必定放到堆中;
- 如果变量在函数外部存在引用,则必定放在堆中;
- 如果变量占用内存较大时,则优先放到堆中;
- 如果变量在函数外部没有引用,则优先放到栈中;
go build -gcflags '-m -m -l'
编译过程
在遇到函数调用、方法调用、使用 defer 或者 go 关键字时都会执行 cmd/compile/internal/gc.state.callResult
和 cmd/compile/internal/gc.state.call
生成调用函数的 SSA 节点,这些在开发者看来不同的概念在编译器中都会被实现成静态的函数调用
首先,从 AST 到 SSA 的转化过程中,编译器会生成将函数调用的参数放到栈上的中间代码,处理参数之后才会生成一条运行函数的命令 ssa.OpStaticCall
:
- 当使用 defer 关键字时,插入
runtime.deferproc
函数; - 当使用 go 关键字时,插入
runtime.newproc
函数符号; - 在遇到其他情况时会插入表示普通函数对应的符号;
什么情况会逃逸?
- 函数return了一个指针
- 空接口逃逸,如果函数参数为interface{} 实际传入的参数很可能会逃逸,因为interface{}类型往往会用反射来确定类型;比如调用fmt.Println(x), Println接受空指针类型,那么x就会发生逃逸;
- 大变量逃逸(局部变量太大)