Golang 内存分配学习笔记
内存分配的核心问题是什么?
内存分配的设计核心问题是分配速度和内存利用率。但这两部分是互相牵制的。例如如果追求极致的速度,那么只能采用最简单的分配方式,无法按需分配,浪费极多。如果追求极致的内存利用率,选择最佳匹配算法,则需要大量的时间去计算和维护。我觉得了解一个内存分配机制,从这个矛盾来看也许会清晰一点。
TCMalloc : Thread-Caching Malloc的思想
golang对于内存分配的思想来自于TCMalloc,其主要想法是为每一个线程分配一块局部缓存。小内存分配从线程的局部缓存中获取,实现了无锁分配,减少了竞争。 golang也同时继承了TCMalloc三级结构ThreadCache -> CentralCache -> PageHeap,在golang中分别为mcache -> mcentral -> mheap。
golang中内存分配相关概念
微对象、小对象和大对象
golang中,微对象是指小于16B的对象。小对象是指大小在[16B, 32KB]的对象,大对象是指大于32KB的对象。
page和mspan
将虚拟内存空间按8KB划分为连续的空间,每一份被称为一个page,而mspan是连续若干个page组成。在golang的内存管理中,mspan以双向链表的形式构建。以下是mspan中的基础字段。
type mspan struct {
next *mspan
prev *mspan
startAddr uintptr
npages uintptr
freeindex uintptr // 空闲对象的索引
nelems uintptr // span中存放的对象数量
spanclass spanClass
}
其中startAddr 和 npages 分别代表mspan管理的堆页的起始地址和数量;而freeindex 和 nelems 分别表示空闲对象的索引和这个mspan中存放的对象数量;最后spanclass是golang对span针对”是否需要GC“和”对象所需内存大小“进行的分类。
golang将对象内存分为了67个类,称为:size class。每一个size class对应不同大小的object size。当分配内存时,会根据对象所需内存大小寻找对应的size class。然后在对应类型的span中进行分配。表格可见:https://plan9.io/sources/contrib/stallion/root/386/go/src/runtime/sizeclasses.go,表中可见,最大类就是32KB了,也就是说,实际上span是为存储小对象而设计的。
其中的tail waste是指每个mspan末尾浪费的空间,以字节为单位。因为span不能object整除,而产生的尾部碎片。max waste是指理论最大内存浪费率。
一个size class对应两个span class,其中一个span存放需要GC扫描的对象,另一个存放不需要GC扫描的对象。
mcache
type mcache struct {
// 分配tiny对象的参数
tiny uintptr // 申请tiny对象的起始地址
tinyoffset uintptr // 从tiny地址开始的偏移量
tinyAllocs uintptr // tiny对象分配的数量
alloc [numSpanClasses]*mspan // 待分配的mspan列表,通过spanClass索引
}
mcache除了微对象的部分,主体是一个列表,是不同spanclass组成的,共有(67+1)*2 = 136个。其中0和1号是不分配内存的。
在golang中,mcache是和GMP模型中的P对应的,也就是GOMAXPROCS个。goroutine需要时,通过P向对应的mcache申请内存。
mcentral
如果在内存申请的过程中,mcache当前的mspan出现空缺,则会向mcentral申请一个mspan。
type mcentral struct {
// 该mcentral的spanClass规格大小
spanclass spanClass
partial [2]spanSet // 维护全部空闲的span集合,partial有两个spanSet集合,其中一个是GC已经扫描的,一个是GC未扫描的
full [2]spanSet // 维护已经被使用的span集合,full也有两个spanSet集合,一个GC已扫描,一个未扫描
}
以上是mcentral的结构,注释我直接借鉴了https://cloud.tencent.com/developer/article/2395092这篇文章。 其中spanclass和上文提到的一致,每一个spanClass都有相对应的mcentral,因此实际上有136个mcentral。
除此之外还有两个spanSet列表,分别是partical和full。partical存放至少还有一个空闲对象槽位的 mspan。这些 span 可以被分配给 mcache 用于满足新的内存分配请求。full存放所有对象槽位都被占满的 mspan。这些 span 没有空闲对象,因此不能直接用于分配,只能等待 GC 回收后,部分对象变成空闲,才能重新移入 partial。之所以有两个也是为了支持GC。
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
...
}
这是mheap的基本结构,可以看到,mcentral实际上是mheap的一部分。
另外,最主要的就是arenas。这是一个heapArena的二维数组,每一个heapArena都会管理64MB的内存,以页为单位。基本结构如下:
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
}
这里也很不要脸了直接复制了上面那篇blog的注释,真不要脸。
这里非常明显的体现了mheap是以page为单位管理虚拟内存的。当mcentral对应的span也被用尽后,mheap会从arena中根据所需的spanclss构造对应的mspan。
内存分配过程
内存
微对象
- P向mcache申请内存,mcache首先判断object的大小是否在tiny的大小范围以内,如果不是则进入小对象的申请流程
- 判断申请的Tiny对象是否包含指针,如果包含则进入小对象申请流程(不会放在Tiny缓冲区,因为需要GC走扫描等流程)。
- 如果Tiny空间的16B没有多余的存储容量,则从Size Class = 2(即Span Class = 4或5)的Span中获取一个16B的Object放置Tiny缓冲区。
- 将该对象放入tiny空间内
小对象
- P向mcache申请内存,mcache判断object的大小,先判断是否是微对象
- 根据size对应的size class和是否包含指针匹配spanclass,然后再mcache对应的spanclass中寻找mspan进行分配
- 如果mcache中对应的mspan都被占用了,则会去mcentral中申请一个mspan
- mcentral收到申请后,会依次检查对应spanclass的partial 、full,如果有可用的就返回给mcache
- 如果都没有可用的,则向mheap申请。MHeap收到内存请求从其中一个heapArena从取出一部分Pages构造成mspan返回给MCentral,重新执行第4步
- 将该对象放入匹配到的mspan中
大对象
- P申请大对象,直接向mheap申请
- mheap计算需要多少个page,然后向arenas中的heapArena申请对应的pages
- mheap返回内存空间分配给该对象
参考
一文搞懂Go1.20内存分配器 https://cloud.tencent.com/developer/article/2395092 一站式Golang内存管理洗髓经 https://www.yuque.com/aceld/golang/qzyivn#a7sjw TCMalloc : Thread-Caching Malloc https://goog-perftools.sourceforge.net/doc/tcmalloc.html Go语言设计与实现 7.1 内存分配器 https://draven.co/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/#%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86%E5%8D%95%E5%85%83