# Go堆内存管理
1. Go内存模型层级结构
Golang内存管理模型与TCMalloc的设计极其相似。基本轮廓和概念也几乎相同,只是一些规则和流程存在差异。
2. Go内存管理的基本概念
Go内存管理的许多概念在TCMalloc中已经有了,含义是相同的,只是名字有一些变化。
2.1 Page
与TCMalloc中的Page相同,x64架构下1个Page的大小是8KB。Page表示Golang内存管理与虚拟内存交互内存的最小单元。操作系统虚拟内存对于Golang来说,依然是划分成等分的N个Page组成的一块大内存公共池。
2.2 mspan
与TCMalloc中的Span一致。mspan概念依然延续TCMalloc中的Span概念,在Golang中将Span的名称改为mspan,1个mspan为多个Page(go中为8KB的内存大小)。1个mspan对应1个或多个大小相同的object,mspan主要用于分配对象的区块,下图简单说明了Span的内部结构。
mspan结构体如下:
type mspan struct {next *mspan // 在mspan链表中,指向后一个mspanprev *mspan // 在mspan链表中,指向前一个mspanlist *mSpanList // 供debug使用startAddr uintptr // mspan起始地址npages uintptr // 当前mspan对应的page数manualFreeList gclinkptr // mSpanManual状态mspan中的可用对象链表// freeindex是slot索引,标记下一次分配对象时应该开始搜索的地址, 分配后freeindex会增加// 每一次分配都从freeindex开始扫描allocBits,直到它遇到一个表示空闲对象的0// 在freeindex之前的元素都是已分配的, 在freeindex之后的元素有可能已分配, 也有可能未分配freeindex uintptrnelems uintptr // 当前span中object数量.// allocCache是从freeindex位置开始的allocBits缓存allocCache uint64// allocBits用于标记哪些元素是已分配的, 哪些元素是未分配的。// 使用freeindex + allocBits可以在分配时跳过已分配的元素, 把对象设置在未分配的元素中.allocBits *gcBits// 用于在gc时标记哪些对象存活, 每次gc以后allocBits都会与gcmarkBits保持一致gcmarkBits *gcBits// 清理代数,每GC1次sweepgen会+2// sweepgen=currrent sweepgen - 2:该span需要被清扫// sweepgen=currrent sweepgen - 1:该span正在被清扫// sweepgen=currrent sweepgen:该span已被清扫,带使用// sweepgen=currrent sweepgen + 1:该span在清扫开始前,仍然被缓存,需要被清扫// sweepgen=currrent sweepgen + 3:该span已被清扫,仍然被缓存sweepgen uint32divMul uint32 // for divide by elemsizeallocCount uint16 // 已分配对象的数量spanclass spanClassstate mSpanStateBoxneedzero uint8 // 在分配前需要清零elemsize uintptr // 对象大小limit uintptr // span数据末尾speciallock mutex // specials链表的锁specials *special // 根据object偏移量排序的special链表.}
mspan的allocBits是一个bitmap,用于标记哪些元素是已分配的, 哪些元素是未分配的。通过使用allocBits已经可以达到O(1)的分配速度,但是go为了极限性能,对其做了一个缓存allocCache,allocCache是从freeindex开始的allocBits缓存。
2.3 Size Class
Golang内存管理针对衡量内存的概念又更加详细了很多,这里面介绍一些基础的有关内存大小的名词及算法。
-
Object Class
是指协程应用逻辑一次向Go内存申请的对象Object大小。Object是Golang内存管理模块针对内存管理更加细化的内存管理单元。一个Span在初始化时会被分成多个Object。比如Object Size是8B(8字节)大小的Object,所属的Span大小是8KB(8192字节),那么这个Span就会被平均分割成1024(8192/8=1024)个Object。
逻辑层从Golang内存模型取内存,实则是分配一个Object出去。为了更好的让读者理解,这里假设了几个数据来标识Object Size 和Span的关系 ,如下图所示。
Page是Golang内存管理与操作系统交互时,衡量内存容量的基本单元
Object是用来存储一个变量数据的内存空间, 是Golang内存管理为对象分配存储内存的基本单元
Size Class
是指Object大小的级别。比如Object Size在1Byte~8Byte之间的Object属于Size Class 1级别,Object Size 在8B~16Byte之间的属于Size Class 2级别。本质上,golang的Size Class与TCMalloc中size class都是表示一块内存的所属规格。
go中共存在
_NumSizeClasses = 68
个Size Class(0~68),所以也对应着68个Object Class
Span Class
是Golang内存管理额外定义的规格属性,也是针对Object大小来进行划分的。但是为了优化GC Mark阶段,go内部让一个Size Class对应2个Span Class,其中一个Span为存放需要GC扫描的对象(包含指针的对象, scan span),另一个Span为存放不需要GC扫描的对象(不包含指针的对象, noscan span)。
通过设置两种span,让GC扫描对象的时候,对于noscan的span可以不去查看bitmap区域来标记子对象。也就是说进行扫描的时候,直接判定该span中的对象不会存在引用对象,不再进行更深层的扫描,这样可以大幅提升GC Mark的效率。
具体Span Class与Size Class的逻辑结构关系如下图所示。
其中Size Class和Span Class的对应关系计算方式可以参考Golang源代码,如下:
//usr/local/go/src/runtime/mheap.gotype spanClass uint8 //……(省略部分代码)func makeSpanClass(sizeclass uint8, noscan bool) spanClass {return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))}//……(省略部分代码)
makeSpanClass()函数为通过Size Class来得到对应的Span Class,其中第二个形参noscan表示当前对象是否需要GC扫描
,不难看出来Span Class 和Size Class的对应关系公式如下表所示:
| 对象 | Size Class 与 Span Class对应公式 |
| ---------------------------- | -------------------------------- |
| 需要GC扫描是否存在引用对象 | Span Class = Size Class * 2 + 0 |
| 不需要GC扫描是否存在引用对象 | Span Class = Size Class * 2 + 1 |
Golang源码里列举了详细的Size Class和Object大小、存放Object数量,以及每个Size Class对应的Span内存大小关系,我们这里只展示部分:
//usr/local/go/src/runtime/sizeclasses.gopackage runtime// [class]: Size Class// [bytes/obj]: Object Size,一次对外提供内存Object的大小// [bytes/span]: 当前Object所对应Span的内存大小// [objects]: 当前Span一共有多少个Object// [tail waste]: 当前Span平均分N份Object后,会有多少内存浪费。 ===> [bytes/span]%[bytes/obj]// [max waste]: 当前Size Class最大可能浪费的空间所占百分比。 ===> ((本级Object Size – (上级Object Size + 1))*本级Object数量) + [tail waste])/ 本级Span Size// 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 32 8192 256 0 46.88%// 4 48 8192 170 32 31.52%// 5 64 8192 128 0 23.44%// 6 80 8192 102 32 19.07%// 7 96 8192 85 32 15.95%// 8 112 8192 73 16 13.56%// 9 128 8192 64 0 11.72%// 10 144 8192 56 128 11.82%// ......
由以上源码可见, 并没有列举Size Class为0的规格刻度内存。对于Span Class为0和1的,也就是对应Size Class为0的规格刻度内存,mcache实际上是没有分配任何内存的。因为Golang内存管理对内存为0的数据申请做了特殊处理,如果申请的数据大小为0将直接返回一个固定内存地址,不会走Golang内存管理的正常逻辑,详见以下源码
//usr/local/go/src/runtime/malloc.go// Al Allocate an object of size bytes. // Sm Small objects are allocated from the per-P cache's free lists. // La Large objects (> 32 kB) are allocated straight from the heap. func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { // ……(省略部分代码)if size == 0 {return unsafe.Pointer(&zerobase)}//……(省略部分代码)}
上述代码可以看见,如果申请的size为0,则直接return一个固定地址**zerobase
**。所以在68种Size Class中,执行newobject时,会申请内存的Size Class为67种。在Golang中如[0]int、 struct{}所需要内存大小均是0,这也是为什么很多开发者在通过Channel做同步时,发送一个struct{}数据,因为不会申请任何内存,能够适当节省一部分内存空间。
golang中[0]int、 struct{}等,全部的0内存对象分配,返回的都是一个固定的地址。
max waste为当前Size Class最大可能浪费的空间所占百分比计算方式,详见下图
2.4 MCache
mcache与TCMalloc中的ThreadCache类似,但也有所不同。
相同点:都保存的是各种大小的Span,并按Span class分类,小对象直接从此分配内存,起到了缓存的作用,并且可以无锁访问
不同点:TCMalloc中是1个线程1个ThreadCache,Go中是1个P拥有1个mcache,两者绑定关系的区别如下图所示
如果将上图的mcache展开,来看mcache的内部构造,则具体的结构形式如下图6所示
当其中某个Span Class的MSpan已经没有可提供的Object时,MCache则会向MCentral申请一个对应的MSpan。mcache在初始化时是没有任何mspan资源的,在使用过程中会动态地申请,不断地去填充 alloc[numSpanClasses]*mspan,通过双向链表连接。
下面具体看一下mcache在源码中的定义:
//go:notinheaptype mcache struct { tiny uintptr //<16byte 申请小对象的起始地址tinyoffset uintptr //从起始地址tiny开始的偏移量local_tinyallocs uintptr //tiny对象分配的数量 alloc [numSpanClasses]*mspan // 分配的mspan list,其中numSpanClasses=67*2,索引是splanclassIdstackcache [_NumStackOrders]stackfreelist //栈缓存local_largefree uintptr // 大对象释放字节数local_nlargefree uintptr // 释放的大对象数量local_nsmallfree [_NumSizeClasses]uintptr // 每种规格小对象释放的个数flushGen uint32 //扫描计数}
MCache中每个Span Class都只会对应一个MSpan对象,不同Span Class的MSpan的总体长度不同,参考runtime/sizeclasses.go的标准规定划分。比如对于Span Class为4的MSpan来说,存放内存大小为1Page,即8KB。每个对外提供的Object大小为16B,共存放512个Object。其他Span Class的存放方式类似。
通过源码可以看到MCache通过alloc[numSpanClasses]mspan管理了很多不同规格不同类型的span,golang对于*[16B,32KB]
**的对象会使用这部分span进行内存分配,所有在这区间大小的对象都会从alloc这个数组里寻找。
var sizeclass uint8//确定规格if size <= smallSizeMax-8 {sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]} else {sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]}size = uintptr(class_to_size[sizeclass])spc := makeSpanClass(sizeclass, noscan)//alloc中查到span := c.alloc[spc]
而对于更小的对象,我们叫它tiny对象,golang会通过tiny和tinyoffset组合寻找位置分配内存空间,这样可以更好的节约空间,源码如下:
off := c.tinyoffset//根据不同大小内存对齐if size&7 == 0 {off = round(off, 8)} else if size&3 == 0 {off = round(off, 4)} else if size&1 == 0 {off = round(off, 2)}if off+size <= maxTinySize && c.tiny != 0 {// tiny+偏移量x = unsafe.Pointer(c.tiny + off)c.tinyoffset = off + sizec.local_tinyallocs++mp.mallocing = 0releasem(mp)return x}// 空间不足从alloc重新申请空间用于tiny对象分配span := c.alloc[tinySpanClass]
2.5 MCentral
MCentral与TCMalloc中的Central概念依然相似。向MCentral申请Span是同样是需要加锁的。
当MCache的某个级别Span的内存被分配光时,它会向MCentral申请1个当前级别的Span。
Goroutine、MCache、MCentral、MHeap互相交换的内存单位是不同,其中协程逻辑层与MCache的内存交换单位是Object,MCache与MCentral、MCentral与MHeap的内存交换单位是Span,MHeap与操作系统的内存交换单位是Page。
MCentral与TCMalloc中的Central不同的是:CentralCache是每个级别的Span有1个链表,mcache是每个级别的Span有2个链表。如下图所示。
MCentral属于MHeap,MCentral是各个规格的mcentral集合,实际上1个mcentral对应1个Span Class,即Span Class个mcentral小内存管理单元。对应源码为:
type mheap struct {......central [numSpanClasses]struct {mcentral mcentralpad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte}......}
-
NonEmpty Span List
表示还有可用空间的Span链表。链表中的所有Span都至少有1个空闲的Object空间。如果MCentral上游MCache退还Span,会将退还的Span加入到NonEmpty Span List链表中。
-
Empty Span List
表示没有可用空间的Span链表。该链表上的Span都不确定是否存在空闲的Object空间。如果MCentral提供给一个Span给到上游MCache,那么被提供的Span就会加入到Empty List链表中。
注意 在Golang 1.16版本之后,MCentral中的NonEmpty Span List 和 Empty Span List
均由链表管理改成集合管理,分别对应Partial Span Set 和 Full Span Set。虽然存储的数据结构有变化,但是基本的作用和职责没有区别。
下面是MCentral层级中其中一个Size Class级别的MCentral的定义Golang源代码(V1.14版本):
//usr/local/go/src/runtime/mcentral.go , Go V1.14// Central list of free objects of a given size.// go:notinheaptype mcentral struct {lock mutex //申请MCentral内存分配时需要加的锁spanclass spanClass //当前哪个Size Class级别的// list of spans with a free object, ie a nonempty free list// 还有可用空间的Span 链表nonempty mSpanList // list of spans with no free objects (or cached in an mcache)// 没有可用空间的Span链表,或者当前链表里的Span已经交给mcacheempty mSpanList // nmalloc is the cumulative count of objects allocated from// this mcentral, assuming all spans in mcaches are// fully-allocated. Written atomically, read under STW.// nmalloc是从该mcentral分配的对象的累积计数// 假设mcaches中的所有跨度都已完全分配。// 以原子方式书写,在STW下阅读。nmalloc uint64}
在GolangV1.16版本的相关MCentral结构代码如下:
//usr/local/go/src/runtime/mcentral.go , Go V1.16+//…type mcentral struct {// mcentral对应的spanClassspanclass spanClasspartial [2]spanSet // 维护全部空闲的Span集合full [2]spanSet // 维护存在非空闲的Span集合}//…
新版本的改进是将List变成了两个Set集合,Partial集合与NonEmpty Span List责任类似,Full集合与Empty Span List责任类似。可以看见Partial和Full都是一个[2]spanSet类型,也就每个Partial和Full都各有两个spanSet集合,这是为了给GC垃圾回收来使用的,其中一个集合是已扫描的,另一个集合是未扫描的。
2.6 MHeap
Golang内存管理的MHeap依然是继承TCMalloc的PageHeap设计。MHeap的上游是MCentral,MCentral中的Span不够时会向MHeap申请。MHeap的下游是操作系统,MHeap的内存不够时会向操作系统的虚拟内存空间申请。访问MHeap获取内存依然是需要加锁的。
MHeap是对内存块的管理对象,是通过Page为内存单元进行管理。那么用来详细管理每一系列Page的结构称之为一个HeapArena,它们的逻辑层级关系如下图所示。
一个HeapArena占用内存64MB,其中里面的内存的是一个一个的mspan,当然最小单元依然是Page,图中没有表示出mspan,因为多个连续的page就是一个mspan。所有的HeapArena组成的集合是一个arenas [1]*[4M]*heapArena数组,运行时使用arenas 管理所有的内存。
mheap是Golang进程全局唯一的,所以访问依然加锁。图中又出现了mcentral,因为mcentral本也属于mheap中的一部分。只不过会优先从MCentral获取内存,如果没有mcentral会从Arenas中的某个heapArena获取Page。
heapArena结构体如下:
type heapArena struct { bitmap [heapArenaBitmapBytes]byte // 用于标记当前这个HeapArena的内存使用情况,1. 对应地址中是否存在过对象、对象中哪些地址包含指针,2. 是否被GC标记过。主要用于GCspans [pagesPerArena]*mspan // 存放heapArena中的span指针地址pageInUse [pagesPerArena / 8]uint8 // 保存哪些spans处于mSpanInUse状态pageMarks [pagesPerArena / 8]uint8 // 保存哪些spans中包含被标记的对象pageSpecials [pagesPerArena / 8]uint8 // 保存哪些spans是特殊的checkmarks *checkmarksMap // debug.gccheckmark statezeroedBase uintptr //该arena第一页的第一个字节地址}
根据heapArena结构体,我们可以了解到mheap内存空间的逻辑视图如下所示:
其中arena区域就是我们通常说的heap, go从heap分配的内存都在这个区域中。
其中spans区域用于表示arena区中的某一页(Page)属于哪个span,spans区域中一个指针(8 byte)对应了arena区域中的一页(在go中一页=8KB)。所以spans的大小是 512GB / 页大小(8KB) * 指针大小(8 byte) = 512MB。spans区域和arenas区域的对应关系如下图所示:
其中每个HeapArean包含一个bitmap,其作用是用于标记当前这个HeapArena的内存使用情况。
1个bitmap的逻辑结构图如下所示:
1个bitmap是8bit,每一个指针大小的内存都会有两个bit分别表示是否应该继续扫描和是否包含指针,这样1个byte就会对应arena区域的四个指针大小的内存。当前HeapArena中的所有Page均会被bitmap所标记,bitmap的主要作用是服务于GC垃圾回收模块。
bitmap中的byte和arena的对应关系从末尾开始, 也就是随着内存分配会向两边扩展
MHeap里面相关的数据结构和指针依赖关系,可以参考下图:
mheap结构体如下:
type mheap struct {lock mutex //必须在系统堆栈上获得,否则当G持有锁时,堆栈增长,可能会自我死锁pages pageAlloc // page分配器数据结构sweepgen uint32 // 记录span的sweep及cache状态sweepDrained uint32 // 所有的span都已被清扫,或都正在被清扫sweepers uint32 // 启动的swepper数量allspans []*mspan // 曾经创建的所有mspans地址的切片,allspans的内存是手动管理的,可以随着堆的增长而重新分配和移动。// 一般来说,allspans受到mheap_.lock的保护,它可以防止并发访问以及释放后备存储。// 在STW期间的访问可能不会持有锁,但必须确保访问周围不能发生分配(因为这可能会释放支持存储)。pagesInUse uint64 // pages所属的spans处于状态mSpanInUse; 原子式更新pagesSwept uint64 // 本周期内被清扫的pages数; 原子式更新pagesSweptBasis uint64 // 被用作Proportional sweep模式原点的pagesSwept; 原子式更新sweepHeapLiveBasis uint64 // gcController.heapLive的值,作为扫描率的原点;带锁写入,不带锁读取。sweepPagesPerByte float64 // Proportional sweep比例; 写时有锁,读时无锁// TODO(austin): pagesInUse should be a uintptr, but the 386 compiler can't 8-byte align fields.scavengeGoal uint64 // 维持的总的保留堆内存量(运行时试图通过向操作系统返回内存来维持该内存量,该内存量由heapRetained衡量)。reclaimIndex uint64 // 下一个要回收的page在allArenas中的索引reclaimCredit uintptr// arenas是*heapArena的map. 它指向整个可用的虚拟地址空间的每一个arena帧的堆的元数据。// 这是一个两级映射,由一个L1映射和可能的许多L2映射组成。当有大量的arena时,这可以节省空间arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArenaheapArenaAlloc linearAlloc // 用于分配heapArena对象的预留空间。这只在32位上使用,我们预先保留这个空间以避免与堆本身交错。arenaHints *arenaHint // arenaHints是一个地址列表,用于标记哪里的heap arenas需要扩容arena linearAlloc // 是一个预先保留的空间,用于分配heap arenas。只用在32位操作系统allArenas []arenaIdx // 所有arena序号集合,可以根据arenaIdx算出对应arenas中的那一个heapArenasweepArenas []arenaIdx // sweepArenas是在扫描周期开始时对所有Arenas的快照,通过禁用抢占可以安全读取markArenas []arenaIdx // markArenas是在标记周期开始时对所有Arenas的快照,由于allArenas只可向后追加,并且标记不会修改该切片内容,所以可以安全读取//curArena是堆当前正在扩容的区域,curArena总是与physPageSize对齐curArena struct {base, end uintptr}// central 是存放small size classes的列表central [numSpanClasses]struct {mcentral mcentral// pad确保mcentrals间隔CacheLinePadSize字节,以便每个mcentral.lock得到它自己的缓存行pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte}spanalloc fixalloc // allocator for span*cachealloc fixalloc // allocator for mcache*specialfinalizeralloc fixalloc // allocator for specialfinalizer*specialprofilealloc fixalloc // allocator for specialprofile*specialReachableAlloc fixalloc // allocator for specialReachablespeciallock mutex // lock for special record allocators.arenaHintAlloc fixalloc // allocator for arenaHintsunused *specialfinalizer // never set, just here to force the specialfinalizer type into DWARF}
arenaHint结构体为:
type arenaHint struct {addr uintptr // 为指向的对应heapArena首地址。down bool // 为当前的heapArena是否可以扩容。next *arenaHint // 指向下一个heapArena所对应的ArenaHint首地址。}
3. 内存分配规则
介绍完内存管理基本概念,我们再来总结一下内存分配规则,流程图如下:
3.1 Tiny对象分配流程
-
判断对象大小是否小于maxSmallSize=32KB,如果小于32KB则进入Tiny对象或小对象申请流程,否则进入大对象申请流程。
-
判断对象大小是否小于maxTinySize=16B并且对象中是否包含指针,如果大于16B或包含指针,则进入小对象申请流程,否则进入Tiny对象申请流程
-
Tiny对象申请流程后,会先获取mcache目前的tinyoffset,再根据申请tiny对象的大小及mcache.tinyoffset值,进行内存对齐,计算出满足内存对齐后的对象插入位置offset
-
如果从插入位置offset插入对象后,不超出16B,并且存在待分配的tiny空间,则将对象填充到该tiny空间,并将地址返回给M,结束内存申请
-
如果当前的tiny空间不足,则通过nextFreeFast(span)查找span中一个可用对象地址,存在则返回地址,并结束内存申请
-
如果span中不存在一个可用对象,则调用mcache.nextFree(tinySpanClass)从mcentral申请1个相同规格的msapn。申请成功则结束流程
3.2 小对象分配流程
-
进入小对象申请流程后,通过mcache.alloc(spc)获取1个指定规格的mspan
-
通过nextFreeFast(span)查找span中一个可用对象地址,存在则返回地址给协程逻辑层P,P得到内存空间,流程结束
-
如果不存在可用对象,则通过mcache.nextFree(tinySpanClass)中mcache.refill(spc)从mcentral申请1个相同规格的msapn
4.mcache.refill(spc)中,会首先尝试通过mcentral的noempty list获取mspan,获取不到则在尝试通过mcentral的empty list获取mspan(1.16之后,通过mcentral.cacheSpan()从partial set获取mspan,获取不到则从full set获取可回收的mspan)。mcache成功获取mcentral返回的mspan后,返回可用对象地址,结束申请流程
-
mcache中empty List(1.16之后,full set)也没有可回收的mspan,则会调用mcache.grow()函数,从mheap中申请内存
-
mheap收到内存请求从其中一个heapArena从取出一部分pages返回给mcentral;当mheap没有足够的内存时,mheap会向操作系统申请内存,将申请的内存也保存到heapArena中的mspan中。mcentral将从mheap获取的由Pages组成的mspan添加到对应的span class链表或集合中
-
最后协程业务逻辑层得到该对象申请到的内存,流程结束
3.3 大对象分配流程
-
进入大对象分配流程后,会调用mcache.allocLarge()方法申请大对象
-
mcache.allocLarge()中主要的mspan申请链路为:mheap.alloc -> mheap.allocSpan,mheap.allocSpan为申请mspan的核心方法。mheap.allocSpan会首先判断申请的page数是否小于P.pageCache的最大page数,如果P.pageCache满足需要,则会从P.mspancache获取mspan地址给P,流程结束
-
P.pageCache不足,则对mheap加锁,从mheap.pageAlloc这种Radix tree(基数树)数据结构中查找可用的page,协程逻辑层P得到内存,流程结束
-
mheap.pageAlloc中查找不存在可用的page,则调用mheap.grow()向操作系统申请内存。申请成功后,再次从mheap.pageAlloc中查找可以page,P得到内存后,流程结束