Golang内存管理
内存管理方案
page、span、元素
Go语言的内存分配算法源自于TCMalloc,是Google开发的内存分配算法库,TCMalloc的内存分配单位叫page,也就是用固定大小的page来执行内存获取、分配、释放等操作。一个page的大小为8KB
go语言的内存管理的基本单位叫span,每个span用于管理特定大小的内存。go语言将内存分成了大大小小67个级别的span,各级span与其元素大小如下所示:每个span的大小都是page的整数倍。
// class bytes/obj bytes/span objects waste bytes
// 1 8 8192 1024 0
// 2 16 8192 512 0
// 3 32 8192 256 0
// 4 48 8192 170 32
// 5 64 8192 128 0
// ........
// 64 27264 81920 3 128
// 65 28672 57344 2 0
// 66 32768 32768 1 0
- span的级别是以span中的元素大小为依据,同一span中的每个元素大小相同;
- 每个span的大小和span中的元素个数都是不固定的,因此span的大小是不固定的,但都是page的整数倍;
- 当具体的对象需要分配不同的内存时,并不是直接分配span,而是分配span中的元素;
- 每个对象在分配时都需要对齐到指定的大小;
- 0级代表特殊的大对象,其大小是不固定的。
span的数据结构,字段没有展示全面,位置:src/runtime/mheap.go
type mspan struct {
_ sys.NotInHeap
next *mspan // next span in list, or nil if none
prev *mspan // previous span in list, or nil if none
startAddr uintptr // address of first byte of span aka s.base()
npages uintptr // number of pages in span
nelems uintptr // 块个数(多少个块可供分配)
allocBits *gcBits // 分配位图,和allocCache是一致的
gcmarkBits *gcBits // 垃圾回收时的标记位图
freeindex uintptr
allocCache uint64 // 分配位图,每一位代表一个块是否已分配
allocCount uint16 // 已分配的块个数
spanclass spanClass // size class and noscan (uint8)
elemsize uintptr // 块大小
}
假如一个class为5的span,从上表可以得出mspan中的一些值如下:
- npages(管理的页数):因为这个级别的span的大小为8K,所以只有一个page,为1
- nelems(span中元素的个数):128
- elemsize(span中每个元素的大小):64
三级对象管理
为了能够方便的对span进行管理,加速span对象的访问和分配,Go语言采取了三级管理结构:mcache、mcentral、mheap。三级内存对象管理示意图:

下面从最底层mache到高层mheap看看每层都负责什么样的工作
mcache
协程直接从mcache获取内存,无需加锁
每个逻辑处理器P都存储了一个本地span缓存,称作mcache,由于同一时间只可能有一个协程运行在逻辑处理器P上,因此无需加锁。mcache包含所有规格大小的mspan。
mcache的结构如下:src/runtime/mcache.go
type mcache struct {
.....
alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
.....
}
numSpanClasses=134,即class数量的2倍。
alloc数组中每个元素代表了一种class类型的span列表,每种class都有两组列表:
- noscan,没有指针,不需要GC扫描
- scan,有指针,需要进行GC扫描
mcache和mspan的关系如下:

mcache初始状态下没有span,在使用过程中会动态从mcentral中获取并缓存。
mcentral
- 除class0外,mcache的span都来自于mcentral;
- 某个协程需要内存时从mcentral申请,释放内存时回收进mcentral;
- mcentral是被所有逻辑处理器共享;
- mcentral同样包含两个mspan链表:
- empty mspanList:没有空闲对象或span已经被mcache缓存的span链表
- nonempty mspanList:有空闲对象的span链表
mcentral的结构如下:src/runtime/mcentral.go
type mcentral struct {
lock mutex //互斥锁
spanclass spanClass // span class ID
nonempty mSpanList // non-empty 指还有空闲块的span列表
empty mSpanList // 指没有空闲块的span列表
nmalloc uint64 // 已累计分配的对象个数
}
协程从mcentral获取span的步骤如下:
- 加锁
- 从nonempty列表获取可用的span,并将其从链表中删除
- 将取出的span放入empty链表
- 将span返回给协程
- 解锁
- 协程将该span缓存进cache
mheap
从mcental的结构可见,每个mcentral对象只管理特定class规格的span。事实上每种class都会对应一个mcentral,这个mecntral的集合存放于mheap数据结构中。
- 管理mcentral,所有级别的mcentral构成一个数组,由mheap管理;
- 大对象直接通过mheap进行分配;
- 建立了span与具体线性地址空间的联系,保存了分配的位图信息,是管理内存的最核心单元;
- 对heap进行操作必须全局加锁,mecache、mcentral可以看作是某种形式的缓存。
mheap的结构如下(只列出来少数字段):src/runtime/mheap.go
type mheap struct {
lock mutex
spans []*mspan
central [134]struct { // 每种class对应的两个mcentral(scan和noscan)
mcentral mcentral
pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
}
}
从数据结构可见,mheap管理着全部的central,即所有的内存。事实上Golang就是通过一个mheap类型的全局变量进行内存管理的。
四级内存块管理
经过以上的分析,go语言使用mheap实现了对虚拟内存线性地址空间的精准管理:
在分块上go语言又将堆内存分成了HeapArena、chunk、span、page,4种内存块进行管理。
- HeapArena内存块最大,大小与平台相关,在UNIX64位系统种占据64MB;
- 每个chunk占据512KB;
- span根据级别大小的不同而不同,但必须是page的倍数;
- 每个page占8KB;

管理线性地址空间的位图结构叫**基数树(radix tree)**,其结构和一般的基数树结构不太一样,会有这个名字很大一部分是由于父节点包含了子节点的若干信息,该结构如下图所示:

该树中每个节点都对应一个pallocSum,最底层的叶子节点对应的pallocSum包含一个chunk(64*page)的信息,除叶子节点外的节点都包含连续8个子节点的内存信息。例如倒数第2层的节点包含连续8个叶子节点(8*chunk)的内存信息。
pallocSum是一个简单的uint64,包含了这个区域中连续空闲内存页的信息,分为3部分:
- 开头(start):占21bit,表示开头有多少个连续内存页
- 中间(max):占22bit,表示最多有多少个连续内存页
- 末尾(end):占21bit,表示末尾有多少个连续内存页
对于最顶层的节点,由于max位为22bit,因此一颗完整的基数树最大代表2^21^个page为16G内存
另外go语言还存储了一个特别的字段searchAddr,在searchAddr前面的字段一定是已经分配过的。因此在查找时不用从根节点开始。
微小对象分配过程
内存分配时,按照对象大小不同,划分为:微小对象(tiny),小对象,大对象。微小对象的分配流程最长,逻辑链路最复杂。微小对象的分配流程其实已经包含了小对象、大对象的分配流程。
go语言将小于16字节的对象划分为微小对象,也就是属于class2级别及以下的span中的元素都属于微小对象。
分配时按照2,4,8的规则进行字节对齐。
- tiny分配首先要查看之前分配的元素中是否有空余空间,
- 如果当前要分配的元素空间不够,尝试从mcahe中查找span中的下一个可用元素
mcache分配
- 查找空闲元素空间时,首先从mcache中找到对应级别的mspan;
- mspan中拥有allocCache字段为uint64,其作为一个位图,用于标记span中的元素是否被分配,因此其最多一次缓存64个元素;
- allocCache使用小端模式(最后一个bit位表示第一个元素的分配情况)标记span中的元素是否被分配。1表示元素未被分配,0表示元素已被分配;
- 如果span中的元素大于64,有一个专门的freeindex标识当前span中的元素分配到了哪里;
- 当allocCache中的bit位都被标记为0,需要移动freeindex并更新allocCache,一直到span中元素的末尾为止。
mcentral分配
- 如果当前的span中没有可以使用的元素,就从mcentral中加锁查找
- 分别遍历empty和nonempty这两个链表,查找是否有可用的span。为什么需要遍历没有空闲元素的empty列表?这是因为:有些span虽然被垃圾回收器标记为空闲了,但是还没来得及清理,这些span在清扫后仍然是可以使用的;
- 如果在mcentral中查找到有空闲元素的span,则将其赋值到mcache中,并更新allocCache,同时将span添加到mcentral链表中的empty链表中。
mheap分配
缓存查找
如果在mcentral中找不到可以使用的span,就需要在mheap中查找,go1.12采用了treap结构进行内存管理,treap是一种引入随机数的二叉搜索树,其实现简单,引入的随机数及必要时的旋转保证了比较好的平衡性。
为了避免加锁,在go1.14之后,每个逻辑处理器P中都维护了一份page cache
mheap会首先查找每个逻辑处理器中的pageCache字段的cache,cache是一个位图,unit64,每一位代表了一个page(8k)。因此一共可以提供64*8k=512KB的连续虚拟内存,在cache中,1代表未分配的内存,0代表已分配的内存
基数树查找
如果要分配的page过大或者在逻辑处理器P的cache中没有找到可用的page,就需要对mheap加锁,并在整个mheap管理的虚拟地址空间的位图中查找是否有可用的page。
基数树的特性是:要分配的内存越大,它能够越快的查找到当前的基数树中是否有满足需求的空间。
操作系统内存申请
在基数树中找不到可用的连续内存时,需要从操作系统中获取内存。从操作系统中获取内存时平台独立的,例如在unix操作系统中,最终使用了mmap系统调用。
每一次向操作系统申请内存,必须为heapArena的倍数,heapArena是和平台有关的内存大小,在64位unix系统中,其大小为64MB。
小对象、大对象分配
小对象:小于32KB的对象,go语言先计算小对象对应于哪一个等级的span,并在指定等级的span中查找。此后和微小对象的分配一样:mcache->mcentral->mheap位图查找->操作系统分配
大对象:大于32KB的对象,内存分配不经过mcache和mcrntral,直接通过mheap进行分配:mheap基数树查找->操作系统分配
说明
本文参考了《Go语言底层原理剖析》《Go专家编程》这两本书,以及部分源码,重新梳理进行的总结。没有看所有的源码对细节进行深究。
随着go版本的升级,有些细节也发生了变化。比如这两本书参考的都是go1.12,span一共有67个class。而在go1.20中,span一共划分了68个class,多了一个对象大小为24byte的class。
但总体的思想应该是一致的。