C通过malloc来动态申请内存,其中内存分配器采用的是glibc提供的ptmalloc2。Go语言也实现了自己的内存分配器,原理和Google的TCmalloc(Thread Cache malloc,线程缓存分配器)类似,毕竟师出同门,因此Go借鉴了TCmalloc的思想,开发出Go的标准内存分配器。

Go的内存分配器的内存分配策略其实就是,首先向系统申请一大片内存幷自己维护,每个线程拥有幷各自维护自己的私有内存,私有内存不足时再向全局申请。

Go语言的内存分配策略跟glibc的malloc内存分配机制的总体思想差不多,基本思路都是预分配+内存分层管理,避免频繁直接向操作系统申请空间。直接频繁向操作系统申请内存有两个缺点:

  1. 向操作系统申请内存是个很重的操作,因为这是一个系统调用,需要从用户态陷入到内核态,待内存分配后再返回用户态。
  2. 频繁向操作系统申请小内存可能造成内存碎片。

因此,现代内存分配器都会在用户空间进行内存管理,利用自己的内存分配算法来处理进程线程的空间申请需求,尽可能地减少直接向操作系统申请分配内存。内存管理一般都采取分层管理的策略,即将提前申请好的大内存空间分为不同大小的内存块,幷用链表串联起这些不同大小的内存块,当用户申请内存时,内存管理器就会根据所需空间从内存块链表中取出最合适的内存块空间交付给用户。这种分层管理的策略被广泛使用,Go同样依照该策略作为其核心分配策略。当然,Go内存分配器还结合了TCmalloc的思想,使用线程cache来加速内存分配。接下来就开始详细介绍Go的内存分配器的具体实现。

mheap

以64位系统为例,Go程序启动时会向操作系统申请一大片内存(不是真实分配,而是惰性分配,真正使用时再真实分配空间),我们可以称之为预分配。再次强调,此时的大片内存申请不是真的创建出这么大的虚拟内存,而是只是在页表中创建出这么大的映射关系。

截屏2021-07-24 下午12.05.28.png

从上图可以看出,预分配的内存包含三部分:spans,bitmap,arena。

  • arena:一共512GB,arena即所谓的堆区,Go程序理论最多可向Go内存管理器申请了512G的内存空间,为了方便管理,arena被划分成一个个page,图中的P指的是Page,一个Page是8KB,所以arena一共包含512GB/8KB个Page。
  • spans:用于管理arena,spans里面存放的都是指向mspan结构体的指针,一共512M,其大小是由page的个数决定的,即512GB/8KB*8B(指针大小)= 512MB。
  • bitmap:bitmap区域标识arena区域哪些地址保存了对象,并且用2bit标志位表示对象是否包含指针、GC标记信息。1个Byte有8位,因此1个Byte可以表示4个对象信息(1个对象信息一个指针大小,即8Byte)。所以bitmap区域的大小是512GB/(4*8B)=16GB。

截屏2021-07-24 下午12.05.45.png

https://github.com/golang/go/blob/go1.12.7/src/runtime/mheap.go#L316

mheap的结构体定义如下,其实这个mheap结构反应的就是上面spans,bitmap,arena内存管理结构的代码实现。

  1. type mheap struct {
  2. // other fields
  3. lock mutex
  4. free [_MaxMHeapList]mspan // free lists of given length, 1M 以下
  5. freelarge mspan // free lists length >= _MaxMHeapList, >= 1M
  6. busy [_MaxMHeapList]mspan // busy lists of large objects of given length
  7. busylarge mspan // busy lists of large objects length >= _MaxMHeapList
  8. central [_NumSizeClasses]struct { // _NumSizeClasses = 67
  9. mcentral mcentral
  10. // other fields
  11. }
  12. // other fields
  13. }

这里注意以下几个问题:

  1. mheap里面带有lock,这意味着向mheap申请空间时需要加锁操作;
  2. mheap里面字段都是mspan组成的,因此mspan是内存管理的基本单位。

下图反应了mspan在内存管理中的作用。spans中的每个S表示每个mspan指针,指向了mspan实例,每个mspan管理着arena的一个page。通过下图我们可以看到mspan的结构体定义,这里需要关注几个字段:

  • startAddr指针,是起始地址,也即所管理页Page的地址。
  • nelems, 块个数,表示有多少个块可供分配。这个nelems是跟spanclass绑定关联的,比如我们这个mspan的classsize为16B,那么nelems=8KB/16B=500,即这个mspan管理的这个page中含有512个内存块。
  • allocBits,分配位图,每一位代表一个块是否已分配。比如nelems=512,那么allocBits就有512个bit,每个bit标记该内存块是否被分配。
  • allocCount,已分配块的个数。
  • spanclass,class表中的class ID,和Size Classs相关。
  • elemsize,class表中的对象大小,也即块大小。

截屏2021-07-24 下午12.06.01.png

mspan实例可以通过next指针串联成链表,组合柜再指向对应的arena地址作为该内存块的起始地址。下图表示了mspan跟整个预分配内存的关系。
截屏2021-07-24 下午12.06.12.png

mcentral

mheap结构体中包含mcentral字段,mcentral用于管理内存块的分层管理。我们前面说到,内存分配器的一般策略是分层管理,即把一大片内存切为多个层次的内存块,幷使用链表把这些大小相同的内存块串联起来。mcentral就是用于管理这些内存块链表的。

  1. // Central list of free objects of a given size.
  2. type mcentral struct {
  3. lock mutex // 分配时需要加锁
  4. sizeclass int32 // 哪种 sizeclass
  5. nonempty mspan // 还有可用的空间的 span 链表
  6. empty mspan // 没有可用的空间的 span 列表
  7. }

截屏2021-07-24 下午12.06.20.png

sizeclass相同的span内存块会以链表的形式组织在一起,这里的sizeclass表示span内存块的大小,比如当分配一块大小为 n 的内存时,系统计算 n 应该使用哪种 sizeclass,然后根据 sizeclass 的值去找到一个可用的 span 来用作分配。比如程序申请9B的内存空间,则内存分配器将会从sizeclass=2的span链表中取出内存块交付给程序。

截屏2021-07-24 下午12.06.30.png

Go中定义的sizeclass 一共有 67 种,下面列举了66中class,其实还有一种class=0的情况,即sizeclass > 32 KB的情形,因此一共是67中sizeclass。

  1. // class bytes/obj bytes/span objects tail waste max waste
  2. // 1 8 8192 1024 0 87.50%
  3. // 2 16 8192 512 0 43.75%
  4. // 3 32 8192 256 0 46.88%
  5. // 4 48 8192 170 32 31.52%
  6. // 5 64 8192 128 0 23.44%
  7. // 6 80 8192 102 32 19.07%
  8. // 7 96 8192 85 32 15.95%
  9. // 8 112 8192 73 16 13.56%
  10. // 9 128 8192 64 0 11.72%
  11. // 10 144 8192 56 128 11.82%
  12. // 11 160 8192 51 32 9.73%
  13. // 12 176 8192 46 96 9.59%
  14. // 13 192 8192 42 128 9.25%
  15. // 14 208 8192 39 80 8.12%
  16. // 15 224 8192 36 128 8.15%
  17. // 16 240 8192 34 32 6.62%
  18. // 17 256 8192 32 0 5.86%
  19. // 18 288 8192 28 128 12.16%
  20. // 19 320 8192 25 192 11.80%
  21. // 20 352 8192 23 96 9.88%
  22. // 21 384 8192 21 128 9.51%
  23. // 22 416 8192 19 288 10.71%
  24. // 23 448 8192 18 128 8.37%
  25. // 24 480 8192 17 32 6.82%
  26. // 25 512 8192 16 0 6.05%
  27. // 26 576 8192 14 128 12.33%
  28. // 27 640 8192 12 512 15.48%
  29. // 28 704 8192 11 448 13.93%
  30. // 29 768 8192 10 512 13.94%
  31. // 30 896 8192 9 128 15.52%
  32. // 31 1024 8192 8 0 12.40%
  33. // 32 1152 8192 7 128 12.41%
  34. // 33 1280 8192 6 512 15.55%
  35. // 34 1408 16384 11 896 14.00%
  36. // 35 1536 8192 5 512 14.00%
  37. // 36 1792 16384 9 256 15.57%
  38. // 37 2048 8192 4 0 12.45%
  39. // 38 2304 16384 7 256 12.46%
  40. // 39 2688 8192 3 128 15.59%
  41. // 40 3072 24576 8 0 12.47%
  42. // 41 3200 16384 5 384 6.22%
  43. // 42 3456 24576 7 384 8.83%
  44. // 43 4096 8192 2 0 15.60%
  45. // 44 4864 24576 5 256 16.65%
  46. // 45 5376 16384 3 256 10.92%
  47. // 46 6144 24576 4 0 12.48%
  48. // 47 6528 32768 5 128 6.23%
  49. // 48 6784 40960 6 256 4.36%
  50. // 49 6912 49152 7 768 3.37%
  51. // 50 8192 8192 1 0 15.61%
  52. // 51 9472 57344 6 512 14.28%
  53. // 52 9728 49152 5 512 3.64%
  54. // 53 10240 40960 4 0 4.99%
  55. // 54 10880 32768 3 128 6.24%
  56. // 55 12288 24576 2 0 11.45%
  57. // 56 13568 40960 3 256 9.99%
  58. // 57 14336 57344 4 0 5.35%
  59. // 58 16384 16384 1 0 12.49%
  60. // 59 18432 73728 4 0 11.11%
  61. // 60 19072 57344 3 128 3.57%
  62. // 61 20480 40960 2 0 6.87%
  63. // 62 21760 65536 3 256 6.25%
  64. // 63 24576 24576 1 0 11.45%
  65. // 64 27264 81920 3 128 10.00%
  66. // 65 28672 57344 2 0 4.91%
  67. // 66 32768 32768 1 0 12.50%

mheap 将从 OS 那里申请过来的内存初始化成一个大 span(sizeclass=0)。然后根据需要从这个大 span 中切出小 span,放在 mcentral 中来管理。如果 mcentral 中的 span 不够用了,会再次从 OS 那里申请内存重复上述步骤。

mcache

  1. type mcache struct {
  2. alloc [numSpanClasses]*mspan
  3. }

这里numSpanClasses=67*2=134。为了加速之后内存回收的速度,数组里一半的mspan中分配的对象不包含指针,另一半则包含指针,在GC扫描中,包含指针的一组才需要扫描,不含指针的一组不需要扫描,这是典型的空间换时间的策略。

mcentral 结构中有一个 lock 字段,因为并发情况下,很有可能多个线程同时从 mcentral 那里申请内存的,必须要用锁来避免冲突。

之前提到的预分配、内存切块等内存分配策略大多数内存分配器都会实现,Go的内存管理除了实现以上功能外,还引入了tcmalloc中的thread cache机制,为了分配对象时有更好的性能, 各个P中都有span的缓存(mcache)。因为同一时间只能有一个线程访问同一个P, 所以P中的数据不需要锁。没有了锁的干扰,这将会大幅提升内存分配速度。

Goroutine 申请内存时,首先从其所在的 P 的 mcache 中分配,如果 mcache 没有可用 span,再从 mcentral 中获取,并填充到 mcache 中。需要注意的是,mcache在初始化时并没有任何的span,在使用过程中会动态从central中获取并缓存下来。根据使用情况,每种class的span的个数都不相同。

截屏2021-07-24 下午12.06.40.png

Tiny对象分配

mcentral中粒度最小的span内存块的大小为8B,但实际应用中,我们往往也会向系统申请小于8B的内存空间。比如我们向系统申请4B的内存空间,如果内存分配器直接分配sizeclass=1的内存块给我们,即8B空间,那么这个内存块的使用率只有50%,造成内存资源浪费。为了解决Tiny对象分配的问题,所以 Go 尽量不使用 sizeclass=1 的 span, 而是将 < 16B 的对象为统一视为 tiny 对象(tinysize)。分配时,从 sizeclass=2 的 span 中获取一个 16B 的 object 用以分配。如果存储的对象小于 16B,这个空间会被暂时保存起来 (mcache.tiny 字段),下次分配时会复用这个空间,直到这个 object 用完为止。

截屏2021-07-24 下午12.06.46.png

如果要存储的数据里有指针,即使 <= 8B 也不会作为 tiny 对象对待,而是正常使用 sizeclass=1 的 span。

大对象的分配

最大的 sizeclass 最大只能存放 32K 的对象。如果一次性申请超过 32K 的内存,系统会直接绕过 mcache 和 mcentral,直接从 mheap 上获取。

Go内存管理总结

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

  • 大于32KB 的对象,直接从mheap上分配;
  • <=16B 且不含指针的对象使用mcache的tiny分配器分配;
  • <=16B 且含指针的对象首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;
  • (16B,32KB] 的对象,首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;
  • 如果mcache没有相应规格大小的mspan,则向mcentral申请
  • 如果mcentral没有相应规格大小的mspan,则向mheap申请
  • 如果mheap中也没有合适大小的mspan,则向操作系统申请

Go的内存管理器分为以下几大部分:

  • Go程序启动时申请一大块内存,幷划分为spans,bitmap,arena。
  • arena区域按页划分为一个个小内存块。
  • span管理一个或多个页。
  • mcentral管理者多个span供线程申请使用。
  • mcache作为线程私有资源,资源来源于mcental。