关于Go内存分配器的分析文章很多,看到的比较经典的有刘丹冰Aceld的一站式Golang内存管理洗髓经,最近学习了该篇文章和其他相关文章,结合Go1.20最新的源码,复习了下Go内存分配的知识,输出了自己的学习笔记。要学习Go GC实现,需要先搞定内存分配,内存分配是GC垃圾回收的前传。
1. TCMalloc内存分配的核心思想是:多级缓存机制,每个线程Thread自行维护一个无锁的线程本地缓存ThreadCache,小对象优先从本地缓存申请内存,内存不足时向加锁的中央缓存CentralCache申请,中央缓存不足时向页堆PageHeap申请,大对象直接向页堆PageHeap申请。
2. Go内存分配器的设计思想主要来自TCMalloc,也是包含三级缓存机制:每个线程 M 所在的 P 维护一个无锁的线程本地缓存mcache,内存不足时向对应spanClass规格的中央缓存mcentral加锁申请资源,中央缓存不足时向页堆mheap申请,大对象直接向页堆mheap申请。
3. Go内存分配器与操作系统虚拟内存交互的最小单元是Page,即虚拟内存页;多个连续的Page称为一个mspan,mspan 是 Go 内存分配的基本单元;每个mspan有个字段叫spanClass跨度类,是对mspan大小级别的划分,每个mspan能够存放指定范围大小的对象,32KB以内的小对象在Go中,会对应不同大小的内存刻度Size Class,Size Class和Object Size是一一对应的,前者指序号 0、1、2、3,后者指具体对象大小 0B、8B、16B、24B;每一个Size Class根据是否有指针,对应两个spanClass规格的mspan;每个mspan都会有特定的Size Class内存规格、spanClass跨度规格、存储特定Object Size的对象,并且包含特定数量的页数Pages。
4. Go中,mcache线程缓存负责微对象和小对象(<32KB)的分配;弄清楚了mspan的内存结构,就很容易理解mcache,mcache只是包含了全部spanClass规格的mspan的一个内存结构,绑定在本地P上,访问无需加锁。和mcache不一样,每个spanClass规格对应一个mcentral中央缓存,全部spanClass规格的mcentral共同构成了中央缓存层MCentral,访问需要加锁;mcentral主要包含 partial Set 空闲集合和 full Set 非空闲集合,支持并发高效存取mspan。mheap主要由多个64M的heapArena组成,每个heapArena主要包含8192页的一个列表,每页分别对应一个mspan。
5. 微对象的分配逻辑是:多个小于16B的无指针微对象的内存分配请求,会合并向Tiny微对象空间申请,微对象的 16B 内存空间从 spanClass 为 4 或 5(无GC扫描)的mspan中获取。
6. 小对象的分配逻辑是:先向mcache申请,mcache内存空间不够时,向mcentral申请,mcentral不够,则向页堆mheap申请,再不够就向操作系统申请。大对象直接向页堆mheap申请。
Go语言的内存分配器采用了TCMalloc多级缓存分配思想来构建的。
如图1.1所示,是TCMalloc内存分配器的主要模块。根据 TCMalloc : Thread-Caching Malloc 的描述,TCMalloc为每个线程Thread分配一个线程本地缓存。线程本地缓存满足小对象(<= 32KB)的分配。每个线程Thread在申请内存时首先会从这个线程缓存ThreadCache申请,所有线程缓存ThreadCache共享一个中央缓存CentralCache。当线程缓存不足时,ThreadCache会向中央缓存CentralCache申请内存,当中央缓存CentralCache内存不足时,会向页堆PageHeap申请,页堆内存不足,会向操作系统的虚拟内存申请。
线程对于大对象(>32KB)的分配是直接向页堆PageHeap申请,不经过线程缓存ThreadCache和中央缓存CentralCache。
CentralCache由于共享,它的访问是需要加锁的。ThreadCache作为线程独立的第一交互内存,访问无需加锁。CentralCache则作为ThreadCache临时补充缓存。PageHeap也是一次系统调用从虚拟内存中申请的,PageHeap明显是全局的,其访问一定要加锁。
对于内存的释放,遵循逐级释放的策略。当ThreadCache的缓存充足或者过多时,则会将内存退还给CentralCache。当CentralCache内存过多或者充足,则将低命中内存块退还PageHeap。
总之,TCMalloc的核心原理是:把内存分为多级管理,从而降低锁的粒度。每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。
Go内存分配器的设计思想主要来自TCMalloc,接下来详细分析一下Go内存管理模型的相关概念和核心模块。如图2.1所示,是Go的内存管理模型。
要分析Go内存模型,需要先弄清楚内存分配相关的几个基本概念,有Page,mspan,Size Class等。
Page,也叫虚拟内存页,表示Go内存管理与操作系统虚拟内存交互内存的最小单元。一个Page的大小是8KB。操作系统虚拟内存对于Go来说,是划分成等分的N个Page组成的一大块公共内存池。
多个连续的Page称为一个mspan,mspan 是 Go 内存管理的基本单元。Go内存管理组件是以 mspan 为单位向操作系统虚拟申请内存的。每个mspan记录了第一个起始 Page 的地址 startAddr,和一共有多少个连续 Page 的数量 npages。
为了方便 mspan 和 mspan 之间的管理,mspan 集合是以双向链表的形式构建。
runtime.mspan的源码如下:
// src/runtime/mheap.go
type mspan struct {
_ sys.NotInHeap
next *mspan // 下一个span节点
prev *mspan // 上一个span节点
startAddr uintptr // span开始地址
npages uintptr // span包含的页数
freeindex uintptr // 空闲对象的索引
nelems uintptr // span中存放的对象数量
allocCache uint64 // allocBits 的补码,可以用于快速查找内存中未被使用的内存
allocBits *gcBits // 标记内存的占用
gcmarkBits *gcBits // 标记内存的GC回收情况
spanclass spanClass // spanClass规格和是否GC扫描
state mSpanStateBox // 标记mspan的状态,状态可能处于 mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 四种情况,在空闲堆或被分配或处于GC不同阶段,会有不同状态
}
runtime.mspan的主要字段有:
next 和 prev 两个字段,它们分别指向了前一个和后一个 runtime.mspan;
startAddr 和 npages 分别代表mspan管理的堆页的起始地址和数量;
freeindex 和 nelems 分别表示空闲对象的索引和这个mspan中存放的对象数量;
allocBits 和 gcmarkBits 分别用于标记内存的占用和GC回收情况;
allocCache 是 allocBits 的补码,用于快速查找内存中未被使用的内存;
spanclass 表示 mspan所属的大小规格和GC扫描信息,每个mspan都有不同的大小规格,存放小于32KB的不同大小的对象;就像下一小节说的,一个Size Class对应两个spanClass,其中一个Span为存放需要GC扫描的对象(包含指针的对象),另一个Span为存放不需要GC扫描的对象(不包含指针的对象);
state 标记 mspan 的状态,状态可能处于 mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 四种情况,在空闲堆或被分配或处于GC不同阶段,会有不同状态。
mspan有个字段 spanClass,是跨度类,是对mspan大小级别的划分。
1)提到跨度类spanClass,就不得不提内存刻度进行衡量的 Size Class。Go 对于 32KB 以内的小对象,会将这些小对象按不同大小划分为多个内存刻度 Size Class,是不同大小对象Object Size按顺序排序的序号,如0,1,2,3。每个Size Class都对应一个对象大小即 Object Size,如8B、16B、32B等。在申请小对象内存时,Go 会根据使用方申请的对象大小,就近向上取最接近的一个Object Size,找到其所在的序号Size Class,和所代表的spanClass跨度类的mspan。
Go 内存管理模块中一共包含 68 中内存刻度Size Class,每一个Size Class都会存储特定大小即Object Size的对象,并且包含特定数量的页数npages,Size Class 和 Object Size 及页数 npages 的关系,存储在 runtime.class_to_size 和 runtime.class_to_allocnpages 等变量中:
const (
...
_NumSizeClasses = 68
...
)
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}
var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}
2)在Go中,spanClass和Size Class的关系是,一个Size Class对应两个spanClass,其中一个Span为存放需要GC扫描的对象(包含指针的对象),另一个Span为存放不需要GC扫描的对象(不包含指针的对象)。
如图3.3所示,不同对象占用内存大小不同,会申请不同规格的mspan来存放,比如一个对象的大小是14B(向上取最靠近的Object 大小 16B),所属的 mspan 大小是8KB(一页Page),则这个 mspan 会被平均分割为512个Object(8192/16=512),当 Go 程序向 Go内存分配器申请内存,实际上是分配该规格 SizeClass=2(spanClass=4或5)的mspan的一个对象大小的空间出去,剩下512-1=511个对象空间待分配。这里,Size Class和Object Size是一一对应的,只不过一个是序号,一个是具体对象大小。
Size Class和spanClass的对应关系参考如下源码:
// src/runtime/mheap.go
type spanClass uint8 // spanClass的个数
const (
// spanClass的个数是Size Class个数的两倍
numSpanClasses = _NumSizeClasses << 1
)
// 通过Size Class来得到对应的Span Class,第二个形参noscan表示当前对象是否需要GC扫描
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
对makeSpanClass()函数的逻辑解释如下表所示:
mspan类型 | Size Class与spanClass对应公式 |
---|---|
需要GC扫描 | spanClass = Size Class * 2 + 0 |
不需要GC扫描 | spanClass = Size Class * 2 + 1 |
通过Go的源码,可以知道Go内存池固定划分了67个Size Class(算上0是68个),并列举了详细的Size Class和Object大小、存放Object数量,以及每个Size Class对应的Span内存大小关系:
// src/runtime/sizeclasses.go
// 标题Title解释:
// [class]: Size Class
// [bytes/obj]: Object Size,一次对外提供内存Object的大小
// [bytes/span]: 当前Object所对应Span的内存大小
// [objects]: 当前Span一共能存放多少个Object
// [tail waste]: 为当前Span平均分层N份Object,会有多少内存浪费
// [max waste]: 当前Size Class最大可能浪费的空间所占百分比
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
// 8 96 8192 85 32 15.95% 32
// 35 1408 16384 11 896 14.00% 128
// 36 1536 8192 5 512 14.00% 512
// 37 1792 16384 9 256 15.57% 256
// 38 2048 8192 4 0 12.45% 2048
...
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
每列的含义是:
1)class: Size Class规格; 2)bytes/obj: Object Size,一次对外提供内存Object的大小; 3)bytes/span: 当前Object所对应Span的内存大小; 4)objects: 当前Span一共能存放多少个Object; 5)tail waste: 为当前Span平均分层N份Object,会有多少内存浪费; 6)max waste: 当前Size Class最大可能浪费的空间所占百分比。
以Size Class为35的一行为例,Object Size是1408B,对应的mspan的大小是16KB,占2Page,能存放的Object个数是16KB/1408B=11.636,向下取整,该mspan最多能存11个1408B大小的对象。
如果存的对象大小就是1408B,尾部会浪费16KB-1408B*11 = 896B,即是tail waste尾部浪费列的值。
如果存放的对象是该Object Size为1408B的档位能存放的最小的对象,即上一档位的Object Size+1=1280+1=1281B,则该mspan会产生最大浪费,比例是 [(1408-1281)*11 + 896]B /16KB = 2293/16384=14%,即是max waste最大浪费列的值。
将runtime.mspan的内存结构剖析清楚了,再理解runtime.mcache就会非常简单。
如图4.1所示,runtime.mcache是与Go协程调度模型GPM中的P所绑定,而不是和线程M绑定,因为在Go调度的GPM模型中,真正可运行的线程M的数量与P的数量一致,即GOMAXPROCS个,跟P绑定节省了P移动到其他M上去的mcahe的切换开销。每个G使用MCache时不需要加锁就可以获取到内存。
runtime.mcache的源码是:
// src/runtime/mcache.go
type mcache struct {
// 分配tiny对象的参数
tiny uintptr // 申请tiny对象的起始地址
tinyoffset uintptr // 从tiny地址开始的偏移量
tinyAllocs uintptr // tiny对象分配的数量
alloc [numSpanClasses]*mspan // 待分配的mspan列表,通过spanClass索引
}
runtime.mcache的字段主要包含两部分:
tiny,tinyoffset,tinyAllocs是跟tiny微对象分配相关的参数;
alloc 是待分配的mspan列表,不同规格的mspan通过spanClass值索引。
如图4.2所示,是runtime.mcache的内存结构,主要包含两部分的内容,Tiny对象的分配空间,和alloc列表代表的对于小对象的分配空间,其实是由0到135(28*2+1)个spanClass规格大小的mspan组成的列表。
每一个线程缓存runtime.mcache都持有 68 * 2 个 runtime.mspan,这些内存管理单元都存储在结构体的 alloc 字段中。
当mcache中某个Size Class对应的Span的一个个Object被应用分配走后,如果出现当前Size Class的mspan空缺情况,mcache则会向mcentral申请对应的mspan。
runtime.mcentral的源码如下:
// src/runtime/mcentral.go
//给定Size Class规格的中央缓存mcentral
type mcentral struct {
// 该mcentral的spanClass规格大小
spanclass spanClass
partial [2]spanSet // 维护全部空闲的span集合,partial有两个spanSet集合,其中一个是GC已经扫描的,一个是GC未扫描的
full [2]spanSet // 维护已经被使用的span集合,full也有两个spanSet集合,一个GC已扫描,一个未扫描
}
runtime.mcentral有个字段spanclass,代表这个mcentral的类型,不同的Size Class规格的mspan对应有不同的 runtime.mcentral管理。Size Class总共有0,8B,16B,24B,32B 到 32KB 共 68 种,因此 Go 内存分配器的中央缓存模型MCentral 总共有排除掉 0 的 67 种runtime.mcentral。
partial 和 full 分别维护空闲的 mspan 集合,和已经被使用的 mspan 集合。mcache 向 mcentral 申请资源,当然是从 partial 集合获取。partial 和 full 都是一个[2]spanSet类型,也就每个 partial 和 full 都各有两个 spanSet 集合,这是为了给 GC 垃圾回收来使用的,其中一个集合是已扫描的,另一个集合是未扫描的。
spanSet 简单理解是 mspan 的集合,详细理解需要看它的源码:
// src/runtime/mspanset.go
// spanSet是并发安全的存取mspan的集合,本质是个二级数据结构,一级是index字段存储指向spanSetBlock的索引,第二级是spanSetBlock存储指向512个mspan数组的索引
type spanSet struct {
spineLock mutex // 锁,往spanSet放入push和获取pop mspan需要加锁
spine atomicSpanSetSpinePointer // 指向spanSetBlock集合的地址
spineLen atomic.Uintptr // spanSetBlock集合的 length
spineCap uintptr // spanSetBlock集合的 cap
index atomicHeadTailIndex // 指向spanSetBlock集合的头尾指针组成的一个int64位的数字
}
spanSet 是并发安全的存取 mspan 的集合,实际上是个二级数据结构,一级是 index 字段存储指向 spanSetBlock 数据块的索引,第二级是 spanSetBlock 存储指向 512 个 mspan 数组的索引。
如图5.2所示,是 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的集合。
如图5.3所示,是中央缓存模型MCentral的内存结构,是由全部spanClass规格的runtime.mcentral共同组成。
先看runtime.mheap的源码:
// src/runtime/mheap.go
// 页堆mheap
type mheap struct {
lock mutex // mheap的锁
pages pageAlloc // 页分配数据结构
allspans []*mspan // 所有的 spans 都是通过 mheap_ 申请,所有申请过的 mspan 都会记录在 allspans,可以随着堆的增长重新分配和移动
// heapArena二维数组集合
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
...
// arenas的地址集合,用来管理heapArena的增加
arenaHints *arenaHint
...
// 全部规格的mcental集合,中央缓存MCentral本身也是MHeap的一部分
central [numSpanClasses]struct {
mcentral mcentral
pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
}
// 各种分配器
spanalloc fixalloc // span* 分配器
cachealloc fixalloc // mcache* 分配器
specialfinalizeralloc fixalloc // specialfinalizer* 分配器
specialprofilealloc fixalloc // specialprofile* 分配器
specialReachableAlloc fixalloc // specialReachable 分配器
speciallock mutex // 特殊记录分配器的锁
arenaHintAlloc fixalloc // allocator for arenaHints
...
}
runtime.mheap页堆主要包含两种数据结构arenas和central:
arenas是heapArena的二维数组的集合;
central是全部规格的中央缓存runtime.central的集合,中央缓存层MCentral本身也是页堆mheap的一部分。
heapArena类型, 用来存储 heap arena 元数据:
const(
pageSize = 8192 // 1<<13=8K
heapArenaBytes = 67108864 //一个heapArena是64MB
heapArenaWords = heapArenaBytes / 8 // 64位的Linux系统,一个heapArena有8M个word,一个word占8个字节
heapArenaBitmapWords = heapArenaWords / // 一个heapArena的bitmap占用8M/64=131072,即128K
pagesPerArena = heapArenaBytes / pageSize // 一个heapArena包含8192个页
)
type heapArena struct {
// bitmap 中每个bit标记arena中的一个word
bitmap [heapArenaBitmapWords]uintptr
// bitmap的8个比特表示一个字长,这个字长是否包含指针用下面的数组记录
noMorePtrs [heapArenaBitmapWords / 8]uint8
// 记录当前arena中每一页对应到哪一个mspan
spans [pagesPerArena]*mspan
// 位图类型,标记哪些spans 处于 mSpanInUse 状态
pageInUse [pagesPerArena / 8]uint8
// 位图类型,记录哪些 spans 已被标记
pageMarks [pagesPerArena / 8]uint8
// 位图类型,指哪些spans有specials (finalizers or other)
pageSpecials [pagesPerArena / 8]uint8
// 零基地址,标记arena页中首个未被使用的页的地址
zeroedBase uintptr
}
Go1.20的runtime.heapArena和Go1.18之前的有不同的是bitmap的一个比特位表示一个word字,而不是一个字节Byte,在Linux 64系统中,一个heapArena管理的内存大小是64MB,那么用一个比特位指代一个字长,则需要64MB/(8*64)=128K个Bit,而且一个占有8Bword的对象是否有指针是用另一个字段noMorePtrs来记录的,而不是都在bitmap中体现。
runtime.heapArena的最重要的字段依然是spans,表示一页arena对应的是哪个mspan。
如图6.1所示,是runtime.mheap的内存结构。
Go中根据对象大小分为三种对象类型:
对象类型 | 对象大小 |
---|---|
tiny微对象 | < 16B |
small小对象 | [16B, 32KB] |
large大对象 | > 32KB |
不同大小的对象有不同的内存分配机制。
Go 将小于 16 字节的对象划分为微对象,它会使用线程缓存上的微分配器Tiny allocator提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合并存入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize 是可以调整的,在默认情况下,内存块的大小为 16 字节。maxTinySize 的值越大,组合多个对象的可能性就越高,内存浪费也就越严重;maxTinySize 越小,内存浪费就会越少,不过无论如何调整,8 的倍数都是一个很好的选择。
如图7.1所示,微对象的 16B 内存空间从 spanClass 为 4 或 5(无GC扫描)的mspan中获取,因为spanClass为2或3的mspan的Object大小是8B,而不是16B。
如图7.2所示,是以bool变量申请1B内存为例说明的Tiny微对象的分配过程。
微对象分配的具体源码如下:
// src/runtime/malloc.go
// 堆上所有的对象都会通过调用runtime.newobject函数分配内存,该函数会调用runtime.mallocgc()函数
// 根据对象的大小(字节数)来分配内存,微对象、小对象从每个P的mcache本地缓存分配,大对象从堆分配
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
......
// 获取当前G所在的M线程
mp := acquirem()
.....
// 获取M的mcache本地缓存
c := getMCache(mp)
if c == nil {
throw("mallocgc called without a P or outside bootstrapping")
}
var span *mspan
var x unsafe.Pointer
// noscan变量记录对象是否包含指针,true为不包含
noscan := typ == nil || typ.ptrdata == 0
if size <= maxSmallSize { // size是对象的字节数,如果对象小于 32KB
if noscan && size < maxTinySize { // 如果对象小于16B且不包含指针
// 这里有一段注释,说明微分配器 Tiny allocator 的内存空间大小为何是16B.
// Tiny内存空间越大,如32B,小对象(小字符串、数字、布尔等)组合的可能性越大,浪费也越严重,综合考虑,16B是合适的,
// 一组json benchmark测试显示,通过Tiny分配器,可以减少12%的分配次数和20%的堆大小;
// 获取mcache的变量tinyoffset,表示Tiny空间空闲的位移
off := c.tinyoffset
// 内存对齐
if size&7 == 0 { // size 在 (0, 16) 范围,如果低三位是0,则跟8位对齐
off = alignUp(off, 8)
} else if goarch.PtrSize == 4 && size == 12 {
// 地址长度是4,字节数是12,也是跟8位对齐
off = alignUp(off, 8)
} else if size&3 == 0 { // size低二位是0,跟4位对齐
off = alignUp(off, 4)
} else if size&1 == 0 { // size低一位是0,跟2位对齐
off = alignUp(off, 2)
}
if off+size <= maxTinySize && c.tiny != 0 {
// 如果对象的大小和off偏移量加起来仍然比16B小,且Tiny空间存在,则当前对象可以从已有的Tiny空间分配
// 在当前的Tiny空间分配,增加相关变量大小
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x // 分配后直接返回
}
// 从当前P的mcache的spanClass为4或5的mspan中,申请一个新的16B Object的 Tiny空间
span = c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, span, shouldhelpgc = c.nextFree(tinySpanClass)
}
x = unsafe.Pointer(v)
// 将申请的16B内存空间置0,16B由两个长度为uint64地址指向
(*[2]uint64)(x)[0] = 0
(*[2]uint64)(x)[1] = 0
// 非竞态下,如果申请的内存块分配了新对象后,将剩下的给 tiny,用 tinyoffset 记录分配了多少
if !raceenabled && (size < c.tinyoffset || c.tiny == 0) {
c.tiny = uintptr(x)
c.tinyoffset = size
}
size = maxTinySize
} else {
// 小对象分配逻辑
.....
}
} else {
// 大对象分配逻辑
......
}
......
}
对于对象在16B至32KB的内存分配,Go会采用小对象的分配流程。
如图7.3所示是小对象的内存分配流程:
1)首先是Goroutine用户程序向Go内存管理模块申请一个对象所需的内存空间;
2)Go内存管理模块的runtime.mallocgc()函数检查传入的对象大小是否大于32KB,如果是则进入大对象分配逻辑,不是则接着往下走;
3)如果对象小于32KB,接着判断对象是否小于16B,如果是,则进入Tiny微对象分配逻辑,否则进入小对象分配逻辑;
4)根据对象的字节数Size匹配得到对应的Size Class内存规格,再根据Size Class和对象是否包含指针得到spanClass,从mcache的对应spanClass的mspan中获取所需内存空间;
5)在找到的mcache的对应spanClass的mspan中如果有空闲的内存空间,则直接获取并返回;
6)如果定位的mcache的对应spanClass的mspan中没有空闲内存空间,则mcache会向MCentral中央缓存层对应spanClass的mcental申请一个mspan;
7)MCentral层收到mcache的内存申请请求后,优先从相对应的spanClass的partial Set空闲集合中取出mspan,partial Set没有则从full Set非空闲集合中获取,取到了则返回给mcache;
8)mcache得到了mcentral 返回的mspan,补充到自己对应的spanClass列表中,返回第5)步,获取内存空间,结束流程;
9)如果mcentral的partial Set 和 full Set都没有符合条件的mspan,则MCentral层会向MHeap页堆申请内存;
10)MHeap收到内存请求从其中一个heapArena从取出一部分Pages返回给MCentral,当MHeap没有足够的内存时,MHeap会向操作系统申请内存,将申请的内存也保存到heapArena中的mspan中。MCentral将从MHeap获取的由Pages组成的mspan添加到对应的spanClass集合中,作为新的补充,之后再次执行第(7)步。
11)最后,Goroutine用户程序获取了对象所需内存,流程结束。
runtime.mallocgc()函数中关于小对象分配的源码如下:
// Allocate an object of size bytes.
// Small objects are allocated from the per-P cache's free lists.
// Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
......
// 获取线程M
mp := acquirem()
.....
// 获取G所在P的mcache
c := getMCache(mp)
......
// 对象是否包含指针
noscan := typ == nil || typ.ptrdata == 0
......
if size <= maxSmallSize { // 对象小于32KB
if noscan && size < maxTinySize { // 对象小于16B
......
} else { // 对象在(16B, 32KB),进入小对象分配流程
var sizeclass uint8
// 根据对象大小获取对象所属的Size Class
if size <= smallSizeMax-8 {
sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
} else {
sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
}
size = uintptr(class_to_size[sizeclass])
// 根据Size Class和对象是否包含指针,获取所属的spanClass
spc := makeSpanClass(sizeclass, noscan)
// 从mcache中获取对应spanClass的mspan
span = c.alloc[spc]
// 从mpsan分配一个空闲的Object
v := nextFreeFast(span)
if v == 0 {
// mcache没有空闲的Object,则从mcentral申请可用的mspan给到mcache
v, span, shouldhelpgc = c.nextFree(spc)
}
// 这时,mcache中有了空闲的Object空间,获取并返回
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
memclrNoHeapPointers(x, size)
}
}
} else { // 大对象分配流程
......
}
......
return x
}
注释中整体的流程对应前面的流程图,需要继续补充说明的是mcache.nextFree()函数,其作用是在mcache的mspan没有空闲的Object时,会通过该函数从mcentral获取:
// src/runtime/malloc.go
// 从当前的mspan获取下一个空闲的Object空间,如果没有则从mcentral获取,否则从mheap获取
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
s = c.alloc[spc]
shouldhelpgc = false
// 当前mspan找到空闲的Object空间的index索引
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems { // 如果mspan已满
// The span is full.
if uintptr(s.allocCount) != s.nelems {
println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount != s.nelems && freeIndex == s.nelems")
}
// 从mcentral获取可用的mspan,并替换当前的mcache的mspan
c.refill(spc)
shouldhelpgc = true
s = c.alloc[spc]
// 再次到新的mspan里查找空闲的Object索引
freeIndex = s.nextFreeIndex()
}
if freeIndex >= s.nelems {
throw("freeIndex is not valid")
}
// 计算要使用的Object内存块在mspan中的地址,并返回该mspan
v = gclinkptr(freeIndex*s.elemsize + s.base())
s.allocCount++
if uintptr(s.allocCount) > s.nelems {
println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount > s.nelems")
}
return
}
mcache.nextFree()函数会检查当前mspan是否有空闲的Object内存块,如果满了就调用mcache.refill()方法从 mcentral 中获取可用的mspan,并替换掉当前 mcache里面的 mspan。至于mcache.refill()方法的具体逻辑,这里不再赘述。
从runtime.mallocgc()函数可以看到,大对象的内存分配主要通过调用mcache.allocLarge()函数实现:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
......
// 获取当前mcache
c := getMCache(mp)
......
// 对象是否包含指针
noscan := typ == nil || typ.ptrdata == 0
......
if size <= maxSmallSize { // 对象小于32KB
if noscan && size < maxTinySize {
......
} else {
......
}
} else { // 大对象内存分配逻辑
shouldhelpgc = true
// 调用mcache.allocLarge()根据对象的大小size和是否包含指针分配大内存mspan
span = c.allocLarge(size, noscan)
span.freeindex = 1
span.allocCount = 1
size = span.elemsize
x = unsafe.Pointer(span.base())
......
}
......
}
mcache.allocLarge()函数主要根据对象的大小获取要分配的页数npages,然后调用mheap.alloc()函数从mheap中直接分配npages页的mspan:
// src/runtime/mcache.go
// allocLarge()函数为大对象申请一个mspan
func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {
// Linux 64位系统下_PageSize为8K,如果对象太大发生溢出,甩出错误
if size+_PageSize < size {
throw("out of memory")
}
// _PageShift=13,2的13次方是8192,用对象大小size/8192得到需要分配的页数npages
npages := size >> _PageShift
// 页数npages不是整数,多出来一些小数,加1
if size&_PageMask != 0 {
npages++
}
......
// 从mheap页堆上分配npages页Size Class为0的mspan,spanClass根据对象是否包含指针为0或1
spc := makeSpanClass(0, noscan)
s := mheap_.alloc(npages, spc)
if s == nil {
throw("out of memory")
}
......
return s
}
mheap.alloc()函数主要在系统栈上调用mheap.allocSpan()获取具体的npages页mspan:
// src/runtime/mheap.go
// alloc()函数从GC的堆上分配npages页的mspan
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
var s *mspan
systemstack(func() {
// 为了阻止额外的堆增长,如果GC扫描未结束,在分配前需要先回收npages个页的内存
if !isSweepDone() {
h.reclaim(npages)
}
// 调用mheap.allocSpan()获取具体的npages页mspan
s = h.allocSpan(npages, spanAllocHeap, spanclass)
})
return s
}
mheap.allocSpan()是获取大对象所需内存页的具体实现:
// src/runtime/mheap.go
func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
// 获取当前G
gp := getg()
base, scav := uintptr(0), uintptr(0)
growth := uintptr(0)
// 在某些平台上,需要进行物理页对齐
needPhysPageAlign := physPageAlignedStacks && typ == spanAllocStack && pageSize < physPageSize
// 当对象足够小,且无需进行物理页对齐,优先从本地P的缓存分配内存
pp := gp.m.p.ptr()
if !needPhysPageAlign && pp != nil && npages < pageCachePages/4 {
c := &pp.pcache
// 本地P的缓存为空,从mheap分配缓存pageCache给它
if c.empty() {
lock(&h.lock)
*c = h.pages.allocToCache()
unlock(&h.lock)
}
// 从本地P的缓存pageCache获取npages页内存
base, scav = c.alloc(npages)
if base != 0 {
s = h.tryAllocMSpan()
if s != nil { // 获取到了内存,调整到HaveSpan位置
goto HaveSpan
}
}
}
lock(&h.lock)
if needPhysPageAlign { // 需要物理页对齐
// 进行物理页对齐需要增加的额外页数
extraPages := physPageSize / pageSize
// 从mheap页堆的pages页分配数据结构中查找所需物理对齐后的页数
base, _ = h.pages.find(npages + extraPages)
if base == 0 { // 如果没有获取到,调用mheap.grow()函数增长页堆
var ok bool
growth, ok = h.grow(npages + extraPages)
if !ok {
unlock(&h.lock)
return nil
}
base, _ = h.pages.find(npages + extraPages)
if base == 0 {
throw("grew heap, but no adequate free space found")
}
}
// 将获取的内存地址base进行物理页对齐,并获取内存大小scav
base = alignUp(base, physPageSize)
scav = h.pages.allocRange(base, npages)
}
if base == 0 { // 如果在上面两种情况下,依然没有获取到所需内存页
// 从mheap的pages页分配数据结构中获取
base, scav = h.pages.alloc(npages)
if base == 0 { // 内存不够,调用mheap.grow()扩容
var ok bool
growth, ok = h.grow(npages)
if !ok {
unlock(&h.lock)
return nil
}
// 重新从mheap的pages页分配数据结构中获取内存
base, scav = h.pages.alloc(npages)
// 内存还是不足,则抛出异常
if base == 0 {
throw("grew heap, but no adequate free space found")
}
}
}
if s == nil {
// 分配一个mspan对象
s = h.allocMSpanLocked()
}
unlock(&h.lock)
HaveSpan:
.....
// 初始化得到的 mspan,并将其与mheap关联起来
h.initSpan(s, typ, spanclass, base, npages)
.....
return s
}
mheap.allocSpan() 函数的主要逻辑是:
1)根据平台差异,检查对象所需内存是否需要进行物理页对齐;
2)当无需进行物理页对齐,且对象足够小,即小于pageCachePages/4=64/4=16页时,优先从本地P的缓存分配内存;此时,如果本地P的缓存为空,从mheap分配缓存pageCache给它,然后再获取所需内存;
3)当需要物理页对齐,从mheap页堆的pages页分配数据结构中查找(mheap.pages.find函数)所需物理对齐后的页数,如果没有获取到,调用mheap.grow()函数扩容mheap,之后再获取;
4)如果在上面两种情况下,依然没有获取到所需内存页,则从mheap的pages页分配数据结构中获取(mheap.pages.alloc函数),如内存不够,则调用mheap.grow()扩容,之后,再重新从mheap的pages页分配数据结构中获取内存;
5)获取了所需内存页后,分配一个新的mspan,初始化相关参数,并将二者绑定,结束流程。
Go内存分配器中使用到的多级缓存机制,是程序开发中常用的设计理念,将对象根据大小分为不同规格,通过提高数据局部性和细粒度内存的复用率,能够有效提升不同大小对象的内存分配的整体效率。
一站式Golang内存管理洗髓经 https://www.yuque.com/aceld/golang/qzyivn#a7sjw
内存分配 https://golang.design/under-the-hood/zh-cn/part2runtime/ch07alloc/
golang 学习之路 -- 内存分配器 https://xie.infoq.cn/article/e760c46349bd7b443e38ac332
详解Go中内存分配源码实现 https://www.luozhiyun.com/archives/434
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。