0. 简介

程序中的数据都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈(Stack)堆(Heap)。函数调用的参数、返回值和局部变量大部分会分配在栈上,这部分由编译器管理。堆内存的管理方式视语言而定:

  • C/C++等编程语言的堆内存由工程师主动申请和释放;
  • Go、Java等编程语言由工程师和编译器/运行时共同管理,其内存由内存分配器分配,由垃圾回收器回收。

本文就介绍一下Go语言的内存分配器。

1. Go内存分配设计原理

Go内存分配器的设计思想来源于TCMalloc,全称是Thread-Caching Malloc,核心思想是把内存分为多级管理,利用缓存的思想提升内存使用效率,降低锁的粒度。

在堆内存管理上分为三个内存级别:

  • 线程缓存(MCache):作为线程独立的内存池,与线程的第一交互内存,访问无需加锁;
  • 中心缓存(MCentral):作为线程缓存的下一级,是多个线程共享的,所以访问时需要加锁;
  • 页堆(MHeap):中心缓存的下一级,在遇到32KB以上的对象时,会直接选择页堆分配大内存,而当页堆内存不够时,则会通过系统调用向系统申请内存。

1.1 内存管理基本单元mspan

//go:notinheap
type mspan struct {
   next *mspan     // next span in list, or nil if none
   prev *mspan     // previous span in list, or nil if none
   list *mSpanList // For debugging. TODO: Remove.
   startAddr uintptr // address of first byte of span aka s.base()
   npages    uintptr // number of pages in span
   freeindex uintptr
   allocBits  *gcBits
   gcmarkBits *gcBits
   allocCache uint64
   ...
}

runtime.mspan是Go内存管理的基本单元,其结构体中包含的nextprev指针,分别指向前后的runtime.mspan,所以其串联后的结构是一个双向链表。

startAddr表示此mspan的起始地址,npages表示管理的页数,每页大小8KB,这个页不是操作系统的内存页,一般是操作系统内存页的整数倍。

其它字段:

  • freeindex — 扫描页中空闲对象的初始索引;
  • allocBits 和 gcmarkBits — 分别用于标记内存的占用和回收情况;
  • allocCache — allocBits 的补码,可以用于快速查找内存中未被使用的内存;

注意使用//go:notinheap标记次结构体mspan为非堆上类型,保证此类型对象不会逃逸到堆上。

图示:

跨度类

mspan中有一个字段是spanclass,称为跨度类,是对mspan大小级别的划分,每个mspan能够存放指定范围大小的对象,32KB以内的小对象在Go中,会对应不同大小的内存刻度Size Class,Size Class和Object Size是一一对应的,前者指序号 0、1、2、3,后者指具体对象大小 0B、8B、16B、24B

//go:notinheap
type mspan struct {
   ...
   spanclass   spanClass     // size class and noscan (uint8)
   ...
}

Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在runtime.class_to_sizeruntime.class_to_allocnpages等变量中:

class bytes/obj bytes/span objects tail waste max waste
1 8 8192 1024 0 87.50%
2 16 8192 512 0 43.75%
3 24 8192 341 0 29.24%
4 32 8192 256 0 46.88%
5 48 8192 170 32 31.52%
6 64 8192 128 0 23.44%
7 80 8192 102 32 19.07%
67 32768 32768 1 0 12.50%

上表展示了对象大小从 8B 到 32KB,总共 67 种跨度类的大小、存储的对象数以及浪费的内存空间,以表中的第四个跨度类为例,跨度类为 5 的runtime.mspan中对象的大小上限为 48 字节、管理 1 个页、最多可以存储 170 个对象。因为内存需要按照页进行管理,所以在尾部会浪费 32 字节的内存,当页中存储的对象都是 33 字节时,最多会浪费 31.52% 的资源:

((48−33)∗170+32)/8192=0.31518

除了上述 67 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象。

1.2 线程缓存(mcache)

runtime.mcache是Go语言中的线程缓存,它会与线程上的处理器意义绑定,用于缓存用户程序申请的微小对象。每一个线程缓存都持有numSpanClasses个(68∗2)个mspan,存储在mcachealloc字段中:

//go:notinheap
type mcache struct {
   ...
   alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
   ...
}

1.3 中心缓存(mcentral)

每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的内存管理单元,访问中心缓存中的内存管理单元需要使用互斥锁。

如图上所示,是 runtime.mcentral 中的 spanSet 的内存结构,index 字段是一个uint64类型数字的地址,该uint64的数字按32位分为前后两半部分head和tail,向spanSet中插入和获取mspan有其提供的push和pop函数,以push函数为例,会根据index的head,对spanSetBlock数据块包含的mspan的个数512取商,得到spanSetBlock数据块所在的地址,然后head对512取余,得到要插入的mspan在该spanSetBlock数据块的具体地址。之所以是512,因为spanSet指向的spanSetBlock数据块是一个包含512个mspan的集合。

由全部spanClass规格的runtime.mcentral共同组成的缓存结构如下:

1.4 页堆(mheap)

//go:notinheap
type mheap struct {
   ...
   arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
   ...
   central [numSpanClasses]struct {
      mcentral mcentral
      pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
   }
   ...
}

runtime.mheap是内存分配的核心结构体,其最重要的两个字段如上。

在Go中其被作为全局变量mheap_存储:

var mheap_ mheap

页堆中包含一个长度为numSpanClasses个(68∗2)个的runtime.mcentral数组,其中 68 个为跨度类需要 scan 的中心缓存,另外的 68 个是 noscan (没有指针,无需扫描)的中心缓存。

arenas是heapArena的二维数组的集合。如下:

2. 内存分配

堆上所有的对象内存分配都会通过runtime.newobject进行分配,运行时根据对象大小将它们分为微对象、小对象和大对象:

  • 微对象(0, 16B):先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;多个小于16B的无指针微对象的内存分配请求,会合并向Tiny微对象空间申请,微对象的 16B 内存空间从 spanClass 为 4 或 5(无GC扫描)的mspan中获取。
  • 小对象[16B, 32KB]:先向mcache申请,mcache内存空间不够时,向mcentral申请,mcentral不够,则向页堆mheap申请,再不够就向操作系统申请。
  • 大对象(32KB, +∞):大对象直接向页堆mheap申请。

对于内存的释放,遵循逐级释放的策略。当ThreadCache的缓存充足或者过多时,则会将内存退还给CentralCache。当CentralCache内存过多或者充足,则将低命中内存块退还PageHeap。