前言
本文是讲解Golang内存管理的第二篇,在第一篇中我们提到,Golang的内存分配模式与TCMalloc是极其相似的。
所以先来回顾一下TCMalloc相关知识点。
Page
:TCMalloc也是以页为单位管理内存 默认8KB。Span
:TCMalloc是以Span为单位向操作系统申请内存的,由一组连续的Page组成。Size Class
:由Span分裂出的对象,由同一个Span分裂出的SizeClass大小相同,SizeClass是对象内存实际的载体。ThreadCache
:存小对象,线程都会有一份单独的缓存,不需要加锁。CentralCache
:存小对象,主要是起到针对ThreadCache
的一层二级缓存作用各个线程共用的,所以与CentralCache
获取内存交互是需要加锁的。PageHeap
:PageHeap
则是针对CentralCache
的三级缓存,补对于中对象内存和大对象内存的分配,PageHeap
也是直接和操作系统虚拟内存衔接的一层缓存。
内存分配方式
线性分配器
定义
只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针。
优点
有较快的执行速度,以及较低的实现复杂度;
缺点
无法在内存被释放时重用内存,因此,需要合适的垃圾回收算法配合使用,标记压缩、复制回收和分代回收
等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并。
空闲链表分配器
只需要在内存中维护一个指向内存特定位置的指针、当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针。
链表分配器中常用的几种分配策略
- 首次适应(First-Fit)
- 从链表头开始遍历,选择第一个大小大于申请内存的内存块
- 循环首次适应(Next-Fit)
- 从上次遍历的结束位置开始遍历,选择第一个大小大于申请的内存块
- 最优适应(Best-Fit)
- 从链表头遍历整个链表,选择最合适的内存块
- 隔离适应(Segregated-Fit)
- 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块
- 通过该策略会将内存分割成4、8、16、32字节的内存块组成的链表,当我们向内存分配器申请8字节的内存时,我们会在上图中的第二个链表找到空闲内存块并返回,隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率
- go语言使用的内存分配策略与隔离适应策略有些相似
- 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块
基础概念
Go在程序启动的时候,会先向操作系统申请一块内存,切成小块后自己进行管理。申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小。
arena
区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan
。
bitmap
区域标识arena
区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap
中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B )的内存,所以bitmap
区域的大小是512GB/(4*8B)=16GB。
Golang内存模型层级结构
Golang内存管理模型与TCMalloc的设计极其相似。基本轮廓和概念也几乎相同,只是一些规则和流程存在差异,接下来分析一下Golang内存管理模型的基本层级模块组成概念。
Page
与TCMalloc的Page一致。Golang内存管理模型延续了TCMalloc的概念,一个Page的大小依然是8KB。
mSpan
与TCMalloc中的Span一致。mSpan概念依然延续TCMalloc中的Span概念,在Golang中将Span的名称改为mSpan,依然表示一组连续的Page。对于mspan来说,Size Class会决定mspan所能分到的页数 (class_to_size 数组)
span数据结构
type mSpanList struct {
_ sys.NotInHeap
first *mspan // first span in list, or nil if none
last *mspan // last span in list, or nil if none
}
type mspan struct {
next *mspan //链表前向指针,用于将span链接起来
prev *mspan //链表前向指针,用于将span链接起来
list *mSpanList
startAddr uintptr // 起始地址,也即所管理页的地址 (指向area)
npages uintptr // 管理的页数
nelems uintptr // 块个数,也即有多少个块可供分配
allocBits *gcBits //分配位图,每一位代表一个块是否已分配
allocCount uint16 // 已分配块的个数
spanclass spanClass // class表中的class ID
freeindex uintptr //— 扫描页中空闲对象的初始索引;
elemsize uintptr // class表中的对象大小,也即块大小
}
例如:一个mspan的Size Class
等于10,可知 object size是144B
(后面有介绍),算出可分配的对象个数是8KB/144B=56.89个,取整56个,所以会有一些内存浪费掉了,再根据class_to_allocnpages
数组,得到这个mspan
只由1
个page
组成;假设这个mspan
是分配给无指针对象的,那么spanClass等于20
。allocBits指向一个位图,每位代表一个块是否被分配。
Size Class相关
- Object Size,协程应用逻辑一次向Golang内存申请的对象Object大小。Object是Golang内存管理模块针对内存管理更加细化的内存管理单元。一个Span在初始化时会被分成多个Object。比如
Object Size
是8B(8字节)大小的Object,所属的Span
大小是8KB(8192字节)块大小为1024个,。
Page是Golang内存管理与操作系统交互衡量内存容量的基本单元,Golang内存管理内部本身用来给对象存储内存的基本单元是Object。 - Size Class,Golang内存管理中的
Size Class
与TCMalloc所表示的设计含义是一致的,都表示一块内存的所属规格或者刻度。**。Go1.9.2里mspan的Size Class共有68种,每种mspan分割的object大小是8*2n的倍数。 如果在用noscan
区分的话,则一共有136种spanClass
。 - Span Class,这个是Golang内存管理额外定义的规格属性,是针对Span来进行划分的,是Span大小的级别。一个Size Class会对应两个Span Class,其中一个Span为存放需要GC扫描的对象,另一个Span为存放不需要GC扫描的对象。
其中Size Class
和Span Class
的对应关系计算方式可以参考Golang源代码,如下:
//usr/local/go/src/runtime/mheap.go
type spanClass uint8
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
对象 | Size Class 与 Span Class对应公式 |
---|---|
需要GC扫描 | Span Class = Size Class * 2 + 0 |
不需要GC扫描 | Span Class = Size Class * 2 + 1 |
mspan ,page ,obejct
三者的关系,可以用下面的图来描述:
MCache
MCache
与TCMalloc的ThreadCache
十分相似,访问mcache依然不需要加锁而是直接访问,且MCache
中依然保存各种大小的Span
。但是二者还是存在一定的区别的,MCache
是与Golang协程调度模型GPM中的P
所绑定,而不是和线程
绑定。mcache
在初始化的时候是没有任何mspan资源的,在使用过程中会动态地从mcentral
申请,之后会缓存下来。当对象小于等于32KB大小时,使用mcache的相应规格的mspan进行分配。
type mcache struct {
local_scan uintptr // 在当前mcache中已经分配的可以扫描的字节数
// 微对象分配器
tiny uintptr
tinyoffset uintptr
local_tinyallocs uintptr // 微对象的分配数量
// numSpanClasses = 138 = _NumSizeClasses * 2
alloc [numSpanClasses]*mspan
}
第一组列表中所表示的对象中包含了指针,第二组列表中所表示的对象不含有指针,这么做是为了提高GC扫描性能,对于不包含指针的span列表,没必要去扫描。
根据对象是否包含指针,将对象分为noscan和scan两类,其中noscan代表没有指针,而scan则代表有指针,需要GC进行扫描。
MCache
中每个Span Class
都会对应一个MSpan,不同Span Class
的MSpan的总体长度不同,参考上面的分配。
mcentral
MCentral
与TCMalloc
中的Central
概念依然相似。向MCentral
申请Span
是同样是需要加锁的。当MCache
中某个Size Class
对应的Span被一次次Object被上层取走后,如果出现当前
Size Class的
Span空缺情况,
MCache则会向
MCentral申请对应的
Span。
Goroutine、
MCache、
MCentral、
MHeap`互相交换的内存单位是不同,其中协程逻辑层与MCache的内存交换单位是Object,MCache与MCentral的内存交换单位是Span,而MCentral与MHeap的内存交换单位是Page。
MCentral
与TCMalloc
中的Central
不同的是MCentral
针对每个Span Class
级别有两个Span
链表,而TCMalloc
中的Central
只有一个。
type mcentral struct {
// mcentral对应的spanClass
spanclass spanClass
partial [2]spanSet // 储存空闲的Span的列表
full [2]spanSet // 储存不包含空闲空间的列表
}
**partial ** : 表示还有可用空间的Span
链表。链表中的所有Span都至少有1个空闲的Object空间。如果MCentral上游MCache退还Span,会将退还的Span加入到partial
链表中。
**full **:表示这条链表里的mspan
都被分配了object,或者是已经被cache
取走了的mspan
,这个mspan就被那个工作线程独占了。
可以看见Partial和Full
都是一个[2]spanSet类型,也就每个Partial和Full都各有两个spanSet集合,这是为了给GC垃圾回收来使用的,其中一个集合是已扫描
的,另一个集合是未扫描
的。
线程从central获取span步骤如下:
- 加锁
- 从
partial
列表获取一个可用span
,并将其从链表中删除 - 将取出的
span
放入full
链表 - 将span返回给线程
- 解锁
- 线程将该
span
缓存进cache
线程将span归还步骤如下: - 加锁
- 将
span
从full `列表删除 - 将
span
加入partial
列表 - 解锁
mheap
Golang内存管理的MHeap
依然是继承TCMalloc
的PageHeap
设计。MHeap的上游是MCentral
,MCentral中的Span不够时会向MHeap
申请。MHeap
的下游是操作系统,MHeap
的内存不够时会向操作系统的虚拟内存空间申请。访问MHeap
获取内存依然是需要加锁的。MHeap
是对内存块的管理对象,是通过Page为内存单元进行管理。那么用来详细管理每一系列Page的结构称之为一个HeapArena
。
type mheap struct {
lock mutex // spans: 指向mspans区域,用于映射mspan和page的关系
spans []*mspan
// 指向bitmap首地址,bitmap是从高地址向低地址增长的
bitmap uintptr
// 指示arena区首地址
arena_start uintptr
// 指示arena区已使用地址位置
arena_used uintptr
// 指示arena区末地址
arena_end uintptr
central [67*2]struct {
mcentral mcentral
pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
}
}
heapArena用于管理真实的内存
对象分配
- 微对象 (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;
- 小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存;
- 大对象 (32KB, +∞) — 直接在堆上分配内存;
Tiny对象分配流程
Tiny空间是从Size Class = 2
中获取一个16B的Object,作为Tiny对象的分配空间。对于Golang内存管理为什么需要一个Tiny这样的16B空间,原因是因为如果协程逻辑层申请的内存空间小于等于8B,那么根据正常的Size Class匹配会匹配到Size Class = 1
,所以像int32、 byte、 bool
以及小字符串等经常使用的Tiny微小对象,也都会使用从Size Class = 1
申请的这8B的空间。但是类似bool或者1个字节的byte,也都会各自独享这8B的空间,进而导致有一定的内存空间浪费。
Go 语言运行时将小于 16 字节的对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
微对象分配部分的代码:
//maxSmallSize =32*1024*1024 32kb
// maxTinySize =16b
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
off := c.tinyoffset
// 省略将off对齐的代码
if off+size <= maxTinySize && c.tiny != 0 {
// 将对象分配到微对象分配器中,实际是将对应的内存作为指针返回
x = unsafe.Pointer(c.tiny + off)
// 更新微对象分配器中的状态
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x
}
// 如果微对象分配器中的内存不足时,使用span进行分配.
span = c.alloc[tinySpanClass]
//调用mcache中缓存的mspan获取内存.
v := nextFreeFast(span)
if v == 0 {
// 同样是获取mcache中的缓存,但是更加耗时
// 如果mcache中没获取到则获取mcentral中的mspan用于分配(调用refill方法)
// 如果mcentral也没有则去找mheap.
// 这里的tinySpanClass,是序号为2的spanClass,即大小为16字节.同时也等于macTinySize
v, span, shouldhelpgc = c.nextFree(tinySpanClass)
}
// 返回对应内存的指针
x = unsafe.Pointer(v)
(*[2]uint64)(x)[0] = 0
(*[2]uint64)(x)[1] = 0
// 如果微对象分配器没有初始化,则将当前对象申请的空间作为微对象分配器的空间
if size < c.tinyoffset || c.tiny == 0 {
c.tiny = uintptr(x)
c.tinyoffset = size
}
size = maxTinySize
}
...
}
MCache中对于Tiny微小对象的申请流程如下:
- P向MCache申请微小对象(假如是bool类型)。如果申请的
Object
在Tiny对象
的大小范围内,则进入Tiny
对象申请流程,否则进入小对象或大对象申请流程。 - 判断申请的
Tiny
对象是否包含指针,如果包含则进入小对象申请流程(不会放在Tiny缓冲区,因为需要GC走扫描等流程)。 - 如果Tiny空间的16B没有多余的存储容量,则从
Size Class = 2
的Span中获取一个16B的Object放置Tiny缓冲区。 - 将对象(bool)放置在16B的Tiny空间中,以字节对齐的方式。
注意:
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize 是可以调整的.
在默认情况下,内存块的大小为 16 字节。
微对象分配器中已经被分配了12B的内存,现在仅剩下4B空闲, 如果此时有小于等于4B的对象需要被分配内存,那么这个对象会直接使用tinyoffset之后剩余的空间。
分配在微对象分配器中的对象只有在微对象分配器中所有对象都标记为垃圾才会被整块回收。
如果微对象分配器一开始没有被初始化,但是又有微对象需要被分配,就会走小对象分配的过程,但是申请到的空间会作为微对象分配器的空间,剩下的空间可以用于分配另外的微对象。
使用微对象分配器节省空间。
小对象分配流程
分配小对象的标准流程是按照·Span Class·规格匹配的。在之前介绍MCache的内部构造已经介绍了,MCache一共有68份Size Class
其中Size Class 为0的做了特殊的处理直接返回一个固定的地址。Span Class
为Size Class
的二倍。
具体的流程过程:
- MCache在接收到请求后,会根据对象所需的内存空间计算出具体的大小Size。
- 判断Size是否小于16B,如果小于16B则进入Tiny微对象申请流程,否则进入小对象申请流程。
- 根据Size匹配对应的
Size Class
内存规格,再根据Size Class和该对象是否包含指针,来定位是从noscan Span Class
还是scan Span Class
获取空间,没有指针则锁定noscan。 - 在定位的
Span Class
中的Span取出一个Object返回给协程逻辑层P,P得到内存空间,流程结束。 - 如果定位的
Span Class
中的Span
所有的内存块Object
都被占用,则MCache
会向MCentral
申请一个Span。 - MCentral收到内存申请后,优先从相对应的
Span Class
中的Partial Set
,里取出Span
,Partial Set
List没有则从Full Set
中取,返回给MCache
。 MCache
得到MCentral
返回的Span
,补充到对应的Span Class
中,P得到内存空间,流程结束。- 如果
Full Set
中没有符合条件的Span,则MCentral会向MHeap申请内存。 - MHeap收到内存请求从其中一个
HeapArena
从取出一部分Pages
返回给MCentral
,当MHeap
没有足够的内存时,MHeap
会向操作系统申请内存,将申请的内存也保存到HeapArena
中的mspan中。MCentral将从MHeap获取的由Pages组成的Span添加到对应的Span Class
链表或集合中。 - 最后协程业务逻辑层得到该对象申请到的内存,流程结束。
大对象分配流程
小对象是在MCache中分配的,而大对象是直接从MHeap中分配。对于不满足MCache分配范围的对象,均是按照大对象分配流程处理。
具体的大对象内存分配流程
- 协程逻辑层申请大对象所需的内存空间,如果超过32KB,则直接绕过
MCache
和MCentral
直接向MHeap
申请。 MHeap
根据对象所需的空间计算得到需要多少个Page。MHeap
向Arenas
中的HeapArena
申请相对应的Pages。- 如果
Arenas中没有
HeapA可提供合适的
Pages`内存,则向操作系统的虚拟内存申请,且填充至Arenas中。 - MHeap返回大对象的内存空间。
- 协程逻辑层P得到内存,流程结束。
参考链接
https://zhuanlan.zhihu.com/p/572059278
https://www.topgoer.cn/docs/gozhuanjia/gozhuanjiachapter044.1-memory_alloc
https://juejin.cn/post/6844903795739082760#heading-5
https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/#%E5%B0%8F%E5%AF%B9%E8%B1%A1
总结
- golang内存TCMalloc算法的区别
- Go内存管理的基本单元是mspan,它由若干个页组成,每种mspan可以分配特定大小的object
- mcache, mcentral, mheap是Go内存管理的三大组件,层层递进。mcache管理线程在本地缓存的
- 极小对象会分配在一个object中,以节省资源,使用tiny分配器分配内存;一般小对象通过mspan分配内存;大对象则直接由mheap分配内存。