Go 借鉴了 Google 的 TCMalloc(高性能的、用于 c++ 的内存分配器)。其核心思想是内存池 + 多级对象管理 ,能加快分配速度,降低资源竞争。
几个关键数据结构
在 Go 里用于内存管理的对象结构主要是:mcache、mcentral、mheap、arenas&&mspan。
其中,mspan 是一个基础结构,分配内存时,基本以它为单位。
mcache、mcentral、mheap 起到了内存池的作用,会被预分配内存,当有对应大小的对象需要分配时会先到它们这一层请求。如果这一层内存池不够用时,会按照【mcache -> mcentral-> mheap -> 操作系统】顺序一层一层的往上申请内存。
mcache
与P(可看做cpu)绑定的线程级别的本地缓存。mcentral
全局空间的缓存,收集了各种大小(67种)的span列表mheap
是一个全局变量,会在系统初始化的时候初始化,分配内存的堆分配器,以8kb进行页管理mspan
由mheap管理的页面,记录了所分配的块大小和起始地址等arenas
是动态分配的堆区,它将分配到的内存以 8k 为一页进行管理。
fixalloc:固定尺寸的堆外对象空闲列表分配器,用来管理分配器的存储
对象结构关系如下:
mspan&&arenas
先来看看 mspan 这个基础结构体。首先,当 Go 在程序初始化的时候,会将申请到的虚拟内存划分为以下三个部分:
arenas 也就是动态分配的堆区,它将分配到的内存以 8k 为一页进行管理。
然而 “页” 这个单位还是太细了,因此再抽象出 mspan 这一层来管理,mspan 表示一组连续的页面。
mspan 记录了这组连续页面的起止地址、页数量、以及类型规格。
关于 mspan 的类型规格有 67 种,每一种都被定义了一个固定大小,当有对象需要分配内存时,就会挑选合适规格的 mspan 分配给对象。
bitmap 主要用来标记 arena 区域中哪些地址保存了对象, GC 扫描信息以及对象指针信息。
总体上来讲,spans 和 bitmap 区域可以看做是 arenas 区域的元数据信息,辅助内存管理。
mheap && mcentral
mheap 在 Go 里是一个全局对象,用来管理大于 32K 对象的内存分配。
mcentral 维护了各个规格的 mspan。当它的下级 mcache 内存不足时,则会到 mcentral 这里来申请 mspan。由于 mcentral 有各个规格类型的 mspan,因此当有不同规格的分配请求时,并不会产生并发竞争的问题。只有当同类型规格的 mspan 并发请求分配时,才会有加锁操作。
mcache
mcache 是提供给 P 的本地内存池。(关于 GPM 模型可以看参考:Go语言的GPM调度器是什么?存是不需要竞争的。
mcache 上还有微型分配器,当要分配更小元素:即 <= 16B 时,会在一个 8byte 的 mspan 上分配多个的对象,这样就能更好的利用内存空间。
内存分配逻辑
- 如果object size>32KB, 则直接使用mheap来分配空间,mheap 中有一个
freelarge
- 如果object size<16Byte, 则通过mcache的tiny分配器来分配(tiny可看作是一个指针offset);
- 如果object size在上面两者之间,首先尝试通过sizeclass对应的分配器分配;
- 如果mcache的分配器没有空闲的span分配, 则向mcentral申请空闲块;
- 如果mcentral也没空闲块,则向mheap申请并进行切分;
- 如果mheap也没合适的span,则向系统申请新的内存空间。
内存回收逻辑
- 如果object size>32KB, 直接将span返还给mheap的自由链;
- 如果object size<32KB, 查找object对应sizeclass, 归还到mcache自由链;
- 如果mcache自由链过长或内存过大,将部分span归还到mcentral;
- 如果某个范围的mspan都已经归还到mcentral,则将这部分mspan归还到mheap页堆;
- 而mheap不会定时将内存归还到系统,但会归还虚拟地址到物理内存的映射关系,当系统需要的时候可以回收这部分内存,否则暂时先留着给Go使用。
这种设计之所以快,主要有以下几个优势:
- 内存分配大多时候都是在用户态完成的,不需要频繁进入内核态。
- 每个 P 都有独立的 span cache,多个 CPU 不会并发读写同一块内存,进而减少 CPU L1 cache 的 cacheline 出现 dirty 情况,增大 cpu cache 命中率。
- 内存碎片的问题,Go 是自己在用户态管理的,在 OS 层面看是没有碎片的,使得操作系统层面对碎片的管理压力也会降低。
- mcache 的存在使得内存分配不需要加锁。
当然这不是没有代价的,Go 需要预申请大块内存,这必然会出现一定的浪费,不过好在现在内存比较廉价,不用太在意。
总体上来看,Go 内存管理也是一个金字塔结构:
(1).内存碎片
内存碎片是系统在内存管理过程中,不可避免的出现一块块无法被使用的内存空间,这是内存管理的产物,可以分为内部碎片和外部碎片。
(2).内部碎片
分配给某些进程的内存区域中,如果有些部分没有用上,(动态分配,按需分配,对于进程来说,没有空的)。
一般都是因为字节对齐,如上面介绍 Tiny 对象分配的部分;为了字节对齐,会导致一部分内存空间直接被放弃掉,不做分配使用。
再比如申请 28B 大小的内存空间,系统会分配 32B 的空间给它,这也导致了其中 4B 空间是被浪费掉的。这就是内部碎片。(3).外部碎片
是指内存中的一些空闲分区(并没有分配给进程)由于太小而难以利用。
一般是因为内存的不断分配释放,导致一些释放的小内存块分散在内存各处,无法被用以分配。
总结一下发生逃逸的结论:
- 首先明确一点,Golang中所有的数据都是按值传递,这点和C语言是一样的。所谓的map、slice和chan等是引用类型,但是依然是值传递,其本质原因是,这些结构的内部都有指针,复制的时候,内部都是复制的指针,因此表现的是传值。
- Go 有两个地方可以分配内存:一个全局堆空间用来动态分配内存,另一个是每个 goroutine 都有的自身栈空间。
- 在函数调用中,对于指针的情况,只要指向的地址的所有者只有一个,那么必然是栈回收;而一旦存在地址存在不确定变化时,则转换成堆的数据。比如slice情况,因为slice会扩容或者缩容,因此造成不确定情况。
- Go 更倾向于在栈空间上分配内存: 一个 Go 程序大部分的内存分配都是在栈空间上的。它的代价很低,因为只需要两个 CPU 指令:一个是把数据 push 到栈空间上以完成分配,另一个是从栈空间上 pop 出去。
什么是内存逃逸?
答案:当发生内存逃逸时,对象从栈中逃逸到了堆中,就需要用户自己回收了【其实是内存回收自动】
堆和栈是内存存储的一种结构
- 堆(heap) 用于动态分配内存,由程序员申请分配和释放,在go里面内存是自动回收的,有自己的一套垃圾回收机制(gc)进行回收,gc的好处在于不必过多关注于内存的管理引发的一系列内存泄漏的问题,但不好也在于gc也伴随着性能的消耗,GC内容参考:go的内存管理和内存
- 栈(stack)是一种线性的存储结构,栈区的内存一般由编译器分配和释放,一般存储着函数的入参以及局部变量,这些参数和局部变量会随着整个函数生命周期结束而被销毁。
栈在分配和回收内存的开销很低,只需要2个CPU指令:PUSH和POP,而堆方面一个很大的开销在于垃圾回收。
go怎么知道一个对象是应该放在堆内存,还是栈内存之上呢?
答案:编译器通过逃逸分析技术去选择对象分配在堆还是栈,逃逸分析的基本思想如下:golang程序变量会携带有一组校验数据,检查变量的生命周期是否是完全可知的,如果通过检查,则可以在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。
Go语言虽然没有明确说明逃逸分析规则,但是有以下几点准则,可以参考的:
- 逃逸分析是在编译时完成的,这是不同于jvm的运行时逃逸分析;
- 如果变量在函数外部没有引用,则优先放到栈中;
- 如果变量在函数外部存在引用,则必定放在堆中;
- 栈上分配内存比在堆中分配内存有更高的效率
- 栈上分配的内存不需要GC处理
- 堆上分配的内存使用完毕会交给GC处理
- 逃逸分析目的是决定内存分配地址是栈还是堆
所以传递值不一定比传递指针慢,因为值是栈中处理,速度快,而指针是逃逸到堆中,处理慢,gc多,所以自己掂量啊。
引起变量逃逸到堆上的典型情况:
- 在方法内把局部变量指针返回,局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
- 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个
goroutine
会在 channel
上接收数据。所以编译器没法知道变量什么时候才会被释放。 - 在一个切片上存储指针或带指针的值。 一个典型的例子就是
[]*string
。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。 - slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
- 动态类型逃逸,在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。