Go语言内置运行时(就是runtime),不同于传统的内存分配方式,go为自主管理,最开始是基于tcmalloc架构,后面逐步迭新。自主管理可实现更好的内存使用模式,如内存池、预分配等,从而避免了系统调用所带来的性能问题。

1. 基本策略

  • 每次从操作系统申请一大块内存,然后将其按特定大小分成小块,构成链表(组织方式是一个单链表数组,数组的每个元素是一个单链表,链表中的每个元素具有相同的大小。);
  • 为对象分配内存时从大小合适的链表提取一小块,避免每次都向操作系统申请内存,减少系统调用。
  • 回收对象内存时将该小块重新归还到原链表,以便复用;若闲置内存过多,则归还部分内存到操作系统,降低整体开销。

1.1 内存块

  span:即上面所说的操作系统分配的大块内存,由多个地址连续的页组成;

  object:由span按特定大小切分的小块内存,每一个可存储一个对象;

  按照用途,span面向内部管理,object面向对象分配。

关于span

  内存分配器按照页数来区分不同大小的span,如以页数为单位将span存放到管理数组中,且以页数作为索引;

  span大小并非不变,在没有获取到合适大小的闲置span时,返回页数更多的span,然后进行剪裁,多余的页数构成新的span,放回管理数组;

  分配器还可以将相邻的空闲span合并,以构建更大的内存块,减少碎片提供更灵活的分配策略。

分配的内存块大小

  在$GOROOT/src/runtime/malloc.go文件下可以找到相关信息。

  用于存储对象的object,按8字节倍数分为n种。如,大小为24的object可存储范围在17~24字节的对象。在造成一些内存浪费的同时减少了小块内存的规格,优化了分配和复用的管理策略。

  分配器还会将多个微小对象组合到一个object块内,以节约内存。

  分配器初始化时,会构建对照表存储大小和规格的对应关系,包括用来切分的span页数。

   如果对象大小超出特定阈值限制,会被当做大对象(large object)特别对待。

  这里的对象分类:

  • 小对象(tiny): size < 16byte;
  • 普通对象: 16byte ~ 32K;
  • 大对象(large):size > 32K;

1.2 内存分配器

分配器分为三个模块

  cache:每个运行期工作线程都会绑定一个cache,用于无锁object分配(Central组件其实也是一个缓存,但它缓存的不是小对象内存块,而是一组一组的内存page(一个page占4k大小))。

  central:为所有cache提供切分好的后备span资源。

  heap:管理闲置span,需要时间向操作系统申请新内存(堆分配器,以8192byte页进行管理)。

  一个线程有一个cache对应,这个cache用来存放小对象。所有线程共享Central和Heap。 

虚拟地址空间

  内存分配和垃圾回收都依赖连续地址,所以系统预留虚拟地址空间,用于内存分配,申请内存时,系统承诺但不立即分配物理内存。虚拟地址分成三个区域:

  • 页所属span指针数组                     spans 512MB              spans_mapped
  • GC标记位图                                bitmap 32GB              bit_map
  • 用户内存分配区域                        arena  512GB              arena_start  arena_used  arena_end

  三个数组组成一个高性能内存管理结构。使用arena地址向操作系统申请内存,其大小决定了可分配用户内存上限;bitmap为每个对象提供4bit 标记位,用以保存指针、GC标记等信息;创建span时,按页填充对应spans空间。这些区域的相关属性保存在heap里,其中包括递进的分配位置mapped/used。

各个模块关系图如下:

1.3 内存分配流程

从对象的角度:

  1、计算待分配对象规格大小(size class);

  2、cache.alloc数组中找到对应规格的apan;

  3、span.freelist提取可用object,若该span.freelist为空从central获取新sapn;

  4、若central.nonempty为空,从heap.free/freelarge获取,并切分成object 链表;

  5、如heap没有大小合适的闲置span,向操作系统申请新内存块。

释放流程:

  1、将标记为可回收的object交还给所属span.freelist;

  2、该span被放回central,可供任意cache重新获取使用;

  3、如span已回收全部object,则将其交还给heap,以便重新切分复用;

  4、定期扫描heap里长期闲置的span,释放其占用内存。

  (注:以上不包括大对象,它直接从heap分配和回收)

   cache为每个工作线程私有且不被共享,是实现高性能无锁分配内存的核心。central是在多个cache中提高object的利用率,避免浪费。回收操作将span交还给central后,该span可被其他cache重新获取使用。将span归还给heap是为了在不同规格object间平衡。

2. 内存分配器初始化

  初始化流程:

大概流程:

  1、创建对象规格大小对照表;

  2、计算相关区域大小,并尝试从某个指定位置开始保留地址空间;

  3、在heap里保存区域信息,包括起始位置和大小;

  4、初始化heap其他属性。

  看一下保留地址操作细节:

  函数mmap()要求操作系统内核创建新的虚拟存储器区域,可指定起始位置和长度。

3. 内存分配

  编译器有责任尽可能使用寄存器和栈来存储对象,有助于提升性能,减少垃圾回收器的压力。

  以new函数为例看一下内存分配

   在默认有内联优化的时候:                                                                                                                                                                                    

  内联优化是避免栈和抢占检查这些成本的经典优化方法。

  在没有内联优化的时候new函数会调用newobject在堆上分配内存。要在两个栈帧间传递对象,因此会在堆上分配而不是返回一个失效栈帧里的数据。而当内联后它实际上就成了main栈帧内的局部变量,无须去堆上操作。

  GO语言支持逃逸分析(eseape, analysis), 它会在编译期通过构建调用图来分析局部变量是否会被外部调用,从而决定是否可以直接分配在栈上。

  编译参数-gcflags "-m" 可输出编译优化信息,其中包括内联和逃逸分析。性能测试时使用go-test-benchemem参数可以输出堆分配次数统计。

3.1 newobject分配内存的过程

  内置new函数的实现

代码基本思路:

  1. 判定对象是大对象、小对象还是微小对象。

  2. 如果是微小对象:

    直接从 mcache 的alloc 找到对应 classsize 的 mspan;

    若当前mspan有足够空间,分配并修改mspan的相关属性(nextFreeFast函数中实现);

    若当前mspan的空间不足,则从 mcentral重新获取一块 对应 classsize的 mspan,替换原先的mspan,然后分配并修改mspan的相关属性;

  对于微小对象,它不能是指针,因为多个微小对象被组合到一个object里,显然无法应对辣鸡扫描。其次它从span.freelist获取一个16字节的object,然后利用偏移量来记录下一次分配的位置。

  3. 如果是小对象,内存分配逻辑大致同微小对象:

    首先查表,以确定 需要分配内存的对象的 sizeclass,并找到 对应 classsize的 mspan;

    若当前mspan有足够的空间,分配并修改mspan的相关属性(nextFreeFast函数中实现);

    若当前mspan没有足够的空间,从 mcentral重新获取一块对应 classsize的 mspan,替换原先的mspan,然后分配并修改mspan的相关属性;

largeAlloc

  再看看 mheap_.allo()函数的实现: