图解Go语言内存分配

Posted GoCN

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了图解Go语言内存分配相关的知识,希望对你有一定的参考价值。

Go语言内置运行时(就是runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。

Golang运行时的内存分配算法主要源自 Google 为 C 语言开发的 TCMalloc算法,全称 Thread-CachingMalloc。核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

为了更好的阅读体验,手动贴上文章目录:

基础概念

申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小。

图解Go语言内存分配

arena区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成 8KB大小的页,一些页组合起来称为 mspan

图解Go语言内存分配

内存管理单元

每个 mspan按照它自身的属性 SizeClass的大小分割成若干个 object,每个 object可存储一个对象。并且会使用一个位图来标记其尚未使用的 object。属性 SizeClass决定 object大小,而 mspan只会分配给和 object尺寸大小接近的对象,当然,对象的大小要小于 object大小。还有一个概念: SpanClass,它和 SizeClass的含义差不多,

 
   
   
 

  1. Size_Class = Span_Class / 2


这是因为其实每个 SizeClass有两个 mspan,也就是有两个 SpanClass。其中一个分配给含有指针的对象,另一个分配给不含有指针的对象。这会给垃圾回收机制带来利好,之后的文章再谈。

如下图, mspan由一组连续的页组成,按照一定大小划分成 object

图解Go语言内存分配

Go1.9.2里 mspanSizeClass共有67种,每种 mspan分割的object大小是8*2n的倍数,这个是写死在代码里的:

 
   
   
 

  1. // path: /usr/local/go/src/runtime/sizeclasses.go



  2. const _NumSizeClasses = 67



  3. var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536,1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}


根据 mspanSizeClass可以得到它划分的 object大小。 比如 SizeClass等于3, object大小就是32B。 32B大小的object可以存储对象大小范围在17B~32B的对象。而对于微小对象(小于16B),分配器会将其进行合并,将几个对象分配到同一个 object中。

数组里最大的数是32768,也就是32KB,超过此大小就是大对象了,它会被特别对待,这个稍后会再介绍。顺便提一句,类型 SizeClass为0表示大对象,它实际上直接由堆内存分配,而小对象都要通过 mspan来分配。

对于mspan来说,它的 SizeClass会决定它所能分到的页数,这也是写死在代码里的:

 
   
   
 

  1. // path: /usr/local/go/src/runtime/sizeclasses.go



  2. const _NumSizeClasses = 67



  3. var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}


比如当我们要申请一个 object大小为 32Bmspan的时候,在classtosize里对应的索引是3,而索引3在 class_to_allocnpages数组里对应的页数就是1。

mspan结构体定义:

 
   
   
 

  1. // path: /usr/local/go/src/runtime/mheap.go



  2. type mspan struct {



  3.    //链表前向指针,用于将span链接起来



  4.    next *mspan



  5.    //链表前向指针,用于将span链接起来



  6.    prev *mspan





  7.    startAddr uintptr



  8.    // 管理的页数



  9.    npages uintptr



  10.    // 块个数,表示有多少个块可供分配



  11.    nelems uintptr



  12.    //分配位图,每一位代表一个块是否已分配



  13.    allocBits *gcBits



  14.    // 已分配块的个数



  15.    allocCount uint16



  16.    // class表中的class ID,和Size Classs相关



  17.    spanclass spanClass  



  18.    // class表中的对象大小,也即块大小



  19.    elemsize uintptr



  20. }


我们将 mspan放到更大的视角来看:

图解Go语言内存分配

假设最左边第一个 mspanSizeClass等于10,根据前面的 class_to_size数组,得出这个 msapn分割的 object大小是144B,算出可分配的对象个数是 8KB/144B=56.89个,取整56个,所以会有一些内存浪费掉了,Go的源码里有所有 SizeClassmspan浪费的内存的大小;再根据 class_to_allocnpages数组,得到这个 mspan只由1个 page组成;假设这个 mspan是分配给无指针对象的,那么 spanClass等于20。

这样,左起第一个 mspan的各个字段参数就如下图所示:

图解Go语言内存分配

内存管理组件

内存分配由内存分配器完成。分配器由3种组件构成: mcache, mcentral, mheap

mcache

mcache:每个工作线程都会绑定一个mcache,本地缓存可用的 mspan资源,这样就可以直接给Goroutine分配,因为不存在多个Goroutine竞争的情况,所以不会消耗锁资源。

mcache的结构体定义:

 
   
   
 

  1. //path: /usr/local/go/src/runtime/mcache.go



  2. type mcache struct {



  3.    alloc [numSpanClasses]*mspan



  4. }



  5. numSpanClasses = _NumSizeClasses << 1


mcacheSpanClasses作为索引管理多个用于分配的 mspan,它包含所有规格的 mspan。它是 _NumSizeClasses的2倍,也就是 67*2=134,为什么有一个两倍的关系,前面我们提到过:为了加速之后内存回收的速度,数组里一半的 mspan中分配的对象不包含指针,另一半则包含指针。

对于无指针对象的 mspan在进行垃圾回收的时候无需进一步扫描它是否引用了其他活跃的对象。 后面的垃圾回收文章会再讲到,这次先到这里。

图解Go语言内存分配

mcache在初始化的时候是没有任何 mspan资源的,在使用过程中会动态地从 mcentral申请,之后会缓存下来。当对象小于等于32KB大小时,使用 mcache的相应规格的 mspan进行分配。

mcentral

mcentral:为所有 mcache提供切分好的 mspan资源。每个 central保存一种特定大小的全局 mspan列表,包括已分配出去的和未分配出去的。 每个 mcentral对应一种 mspan,而 mspan的种类导致它分割的 object大小不同。当工作线程的 mcache中没有合适(也就是特定大小的)的 mspan时就会从 mcentral获取。

mcentral被所有的工作线程共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源。结构体定义:

 
   
   
 

  1. //path: /usr/local/go/src/runtime/mcentral.go



  2. type mcentral struct {



  3.    // 互斥锁



  4.    lock mutex



  5.    // 规格



  6.    sizeclass int32



  7.    // 尚有空闲object的mspan链表



  8.    nonempty mSpanList



  9.    // 没有空闲object的mspan链表,或者是已被mcache取走的msapn链表



  10.    empty mSpanList



  11.    // 已累计分配的对象个数



  12.    nmalloc uint64



  13. }


用图来表示:

图解Go语言内存分配

empty表示这条链表里的 mspan都被分配了 object,或者是已经被 cache取走了的 mspan,这个 mspan就被那个工作线程独占了。而 nonempty则表示有空闲对象的 mspan列表。每个 central结构体都在 mheap中维护。

简单说下 mcachemcentral获取和归还 mspan的流程:

  • 获取 加锁;从 nonempty链表找到一个可用的 mspan;并将其从 nonempty链表删除;将取出的 mspan加入到 empty链表;将 mspan返回给工作线程;解锁。

  • 归还 加锁;将 mspanempty链表删除;将 mspan加入到 nonempty链表;解锁。

mheap

mheap:代表Go程序持有的所有堆空间,Go程序使用一个 mheap的全局对象 _mheap来管理堆内存。

mcentral没有空闲的 mspan时,会向 mheap申请。而 mheap没有资源时,会向操作系统申请新内存。 mheap主要用于大对象的内存分配,以及管理未切割的 mspan,用于给 mcentral切割成小对象。

同时我们也看到, mheap中含有所有规格的 mcentral,所以,当一个 mcachemcentral申请 mspan时,只需要在独立的 mcentral中使用锁,并不会影响申请其他规格的 mspan

mheap结构体定义:

 
   
   
 

  1. //path: /usr/local/go/src/runtime/mheap.go



  2. type mheap struct {



  3.    lock mutex



  4.    // spans: 指向mspans区域,用于映射mspan和page的关系



  5.    spans []*mspan





  6.    bitmap uintptr





  7.    arena_start uintptr





  8.    arena_used  uintptr





  9.    arena_end   uintptr



  10.    central [67*2]struct {



  11.        mcentral mcentral



  12.        pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte



  13.    }



  14. }



用图来表示:


图解Go语言内存分配

内存分配流程

上一篇文章中我们提到了,变量是在栈上分配还是在堆上分配,是由逃逸分析的结果决定的。通常情况下,编译器是倾向于将变量分配到栈上的,因为它的开销小,最极端的就是"zero garbage",所有的变量都会在栈上分配,这样就不会存在内存碎片,垃圾回收之类的东西。

Go的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于16B)、一般对象(大于16B,小于等于32KB)、大对象(大于32KB)。

大体上的分配流程:

  • >32KB 的对象,直接从mheap上分配;

  • <=16B 的对象使用mcache的tiny分配器分配;

  • (16B,32KB] 的对象,首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;

    • 如果mcache没有相应规格大小的mspan,则向mcentral申请

    • 如果mcentral没有相应规格大小的mspan,则向mheap申请

    • 如果mheap中也没有合适大小的mspan,则向操作系统申请

总结

Go语言的内存分配非常复杂,它的一个原则就是能复用的一定要复用。源码很难追,后面可能会再来一篇关于内存分配的源码阅读相关的文章。简单总结一下本文吧。

文章从一个比较粗的角度来看Go的内存分配,并没有深入细节。一般而言,了解它的原理,到这个程度也可以了。

  • Go在程序启动时,会向操作系统申请一大块内存,之后自行管理。

  • Go内存管理的基本单元是mspan,它由若干个页组成,每种mspan可以分配特定大小的object。

  • mcache, mcentral, mheap是Go内存管理的三大组件,层层递进。mcache管理线程在本地缓存的mspan;mcentral管理全局的mspan供所有线程使用;mheap管理Go的所有动态分配内存。

  • 极小对象会分配在一个object中,以节省资源,使用tiny分配器分配内存;一般小对象通过mspan分配内存;大对象则直接由mheap分配内存。


更好的阅读体验,电脑端打开原文阅读。

参考资料

【简单易懂,非常清晰】https://yq.aliyun.com/articles/652551

【内存分配器的初始化过程,分配流程图很详细】https://www.jianshu.com/p/47691d870756

【全局的图】https://swanspouse.github.io/2018/08/22/golang-memory-model/

【雨痕 Go1.5源码阅读】https://github.com/qyuhen/book

【图不错】https://www.jianshu.com/p/47691d870756

【整体感】https://juejin.im/post/59f2e19f5188253d6816d504

【源码解读】http://legendtkl.com/2017/04/02/golang-alloc/

【重点推荐 深入到晶体管了 图很好】https://www.linuxzen.com/go-memory-allocator-visual-guide.html

【总体描述对象分配流程】http://gocode.cc/project/4/article/103

【实际Linux命令】https://mikespook.com/2014/12/%E7%90%86%E8%A7%A3-go-%E8%AF%AD%E8%A8%80%E7%9A%84%E5%86%85%E5%AD%98%E4%BD%BF%E7%94%A8/

【整体流程图 对象分配函数调用链路】http://blog.newbmiao.com/2018/08/20/go-source-analysis-of-memory-alloc.html

【源码讲解 非常细致】https://www.cnblogs.com/zkweb/p/7880099.html

【源码阅读】https://zhuanlan.zhihu.com/p/34930748



Gopher China 2019 最新资讯





 



在评论区留言即可为你喜欢的 Go 语言大神打 call,戳下方阅读原文即可获得与大神面对面交流的机会,点击下方“阅读原文”报名本次 Gopher China 大会!


Gopher China  2019大会正式开启企业团购通道


以上是关于图解Go语言内存分配的主要内容,如果未能解决你的问题,请参考以下文章

Go 内存分配

Go 内存分配

go语言学习笔记 — 基础 — 基本语法 — 常量与变量 — 变量的生命周期:变量逃逸分析 —— go编译器自动决定变量的内存分配方式(堆还是栈),提高程序运行效率

Go语言内存管理内存分配

Go 语言内存分配学习笔记

Go 语言内存分配学习笔记