0. 简介

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

  • C/C++等编程言语的堆内存由工程师主动恳求和释放;
  • Go、Java等编程言语由工程师和编译器/运转时一起办理,其内存由内存分配器分配,由废物回收器回收。

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

1. Go内存分配规划原理

Go内存分配器的规划思维来源于TCMalloc,全称是Thread-Caching Malloc,核心思维是把内存分为多级办理,利用缓存的思维提升内存运用效率,下降锁的粒度。

Golang内存管理—内存分配器
图片来自链接,侵删!

如上图所示,是Go的内存办理模型示意图,在堆内存办理上分为三个内存级别:

  • 线程缓存(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— 扫描页中闲暇目标的初始索引;
  • allocBitsgcmarkBits— 别离用于符号内存的占用和回收情况;
  • allocCacheallocBits的补码,可以用于快速查找内存中未被运用的内存;

注意运用//go:notinheap符号次结构体mspan为非堆上类型,保证此类型目标不会逃逸到堆上。

图示:

Golang内存管理—内存分配器

跨度类

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((48−33)∗170+32)/8192=0.31518

Golang内存管理—内存分配器

除了上述 67 个跨度类之外,运转时中还包括 ID 为 0 的特殊跨度类,它可以办理大于 32KB 的特殊目标。

1.2 线程缓存(mcache)

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

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

其图示如下:

Golang内存管理—内存分配器
图片来源于链接,侵删!

1.3 中心缓存(mcentral)

每个中心缓存都会办理某个跨度类的内存办理单元,它会同时持有两个runtime.spanSet,别离存储包括闲暇目标和不包括闲暇目标的内存办理单元,拜访中心缓存中的内存办理单元需要运用互斥锁。

//go:notinheap
type mcentral struct {
   spanclass spanClass
   partial [2]spanSet // list of spans with a free object
   full    [2]spanSet // list of spans with no free objects
}
Golang内存管理—内存分配器

如图上所示,是 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一起组成的缓存结构如下:

Golang内存管理—内存分配器

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∗268*2)个的runtime.mcentral数组,其间 68 个为跨度类需要scan的中心缓存,别的的 68 个是noscan(没有指针,无需扫描)的中心缓存。

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

Golang内存管理—内存分配器

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。

3. 参考文献

7.1 内存分配器

一文搞懂Go1.20内存分配器