本文是讲解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
也是直接和操作系统虚拟内存衔接的一层缓存。
定义
只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针。
优点
有较快的执行速度,以及较低的实现复杂度;
缺点
无法在内存被释放时重用内存,因此,需要合适的垃圾回收算法配合使用,标记压缩、复制回收和分代回收
等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并。
只需要在内存中维护一个指向内存特定位置的指针、当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针。
链表分配器中常用的几种分配策略
Go在程序启动的时候,会先向操作系统申请一块内存,切成小块后自己进行管理。申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小。
arena
区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan
。
bitmap
区域标识arena
区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap
中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B )的内存,所以bitmap
区域的大小是512GB/(4*8B)=16GB。
Golang内存管理模型与TCMalloc的设计极其相似。基本轮廓和概念也几乎相同,只是一些规则和流程存在差异,接下来分析一下Golang内存管理模型的基本层级模块组成概念。
与TCMalloc的Page一致。Golang内存管理模型延续了TCMalloc的概念,一个Page的大小依然是8KB。
与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指向一个位图,每位代表一个块是否被分配。
Object Size
是8B(8字节)大小的Object,所属的Span
大小是8KB(8192字节)块大小为1024个,。Size Class
与TCMalloc所表示的设计含义是一致的,都表示一块内存的所属规格或者刻度。**。Go1.9.2里mspan的Size Class共有68种,每种mspan分割的object大小是8*2n的倍数。 如果在用 noscan
区分的话,则一共有136种 spanClass
。其中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
与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
与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
缓存进cache
span
从full `列表删除span
加入partial
列表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
}
}
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微小对象的申请流程如下:
Object
在Tiny对象
的大小范围内,则进入Tiny
对象申请流程,否则进入小对象或大对象申请流程。Tiny
对象是否包含指针,如果包含则进入小对象申请流程(不会放在Tiny缓冲区,因为需要GC走扫描等流程)。Size Class = 2
的Span中获取一个16B的Object放置Tiny缓冲区。注意:
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize 是可以调整的.
在默认情况下,内存块的大小为 16 字节。
微对象分配器中已经被分配了12B的内存,现在仅剩下4B空闲, 如果此时有小于等于4B的对象需要被分配内存,那么这个对象会直接使用tinyoffset之后剩余的空间。
分配在微对象分配器中的对象只有在微对象分配器中所有对象都标记为垃圾才会被整块回收。
如果微对象分配器一开始没有被初始化,但是又有微对象需要被分配,就会走小对象分配的过程,但是申请到的空间会作为微对象分配器的空间,剩下的空间可以用于分配另外的微对象。
分配小对象的标准流程是按照·Span Class·规格匹配的。在之前介绍MCache的内部构造已经介绍了,MCache一共有68份Size Class
其中Size Class 为0的做了特殊的处理直接返回一个固定的地址。Span Class
为Size Class
的二倍。
具体的流程过程:
Size Class
内存规格,再根据Size Class和该对象是否包含指针,来定位是从noscan Span Class
还是 scan Span Class
获取空间,没有指针则锁定noscan。Span Class
中的Span取出一个Object返回给协程逻辑层P,P得到内存空间,流程结束。Span Class
中的Span
所有的内存块Object
都被占用,则MCache
会向MCentral
申请一个Span。Span Class
中的Partial Set
,里取出Span
,Partial Set
List没有则从Full Set
中取,返回给MCache
。MCache
得到MCentral
返回的Span
,补充到对应的Span Class
中,P得到内存空间,流程结束。Full Set
中没有符合条件的Span,则MCentral会向MHeap申请内存。HeapArena
从取出一部分Pages
返回给MCentral
,当MHeap
没有足够的内存时,MHeap
会向操作系统申请内存,将申请的内存也保存到HeapArena
中的mspan中。MCentral将从MHeap获取的由Pages组成的Span添加到对应的Span Class
链表或集合中。小对象是在MCache中分配的,而大对象是直接从MHeap中分配。对于不满足MCache分配范围的对象,均是按照大对象分配流程处理。
具体的大对象内存分配流程
MCache
和MCentral
直接向MHeap
申请。MHeap
根据对象所需的空间计算得到需要多少个Page。MHeap
向Arenas
中的HeapArena
申请相对应的Pages。Arenas中没有
HeapA可提供合适的
Pages`内存,则向操作系统的虚拟内存申请,且填充至Arenas中。参考链接
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