Go Extension:内存管理の内存分配器
Go
是一种静态类型的编译语言,因此它并不需要VM。那么GC、调度和并发之类的功能是如何实现的呢?实际上,Go
的应用程序二进制文件中嵌入了一个小型运行时(Go runtime
)。
内存空间包含两个重要区域,一个是栈区还有一个是堆区。函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存是由编译器管理的。
Go
的内存管理包括在需要内存时自动分配内存,不需要时进行垃圾回收。这些工作都是由运行时完成。
1.Go的内部内存结构
Go runtime
将Goroutines(G)
调度到逻辑处理器(P)
上执行,每个P
都有一台逻辑机器(M)
。操作系统会给每个Go
程序进程分配一些虚拟内存,这是该进程可以访问的全部内存。这部分虚拟内存中实际使用的内存又称之为驻留内存(resident set)
。该空间由内部内存结构管理,如下所示。
2. 内存管理组件
mheap
是最大的内存块,也是进行垃圾收集的地方,主要存储动态数据,也就是存储那些编译时无法计算出大小的数据。
驻留内存被划分为每个大小为8KB
的页,并由一个全局mheap
对象管理。mheap
通过把页归类为不同结构进行管理,mheap
中管理内存页的基本单位为mspan
,通过查看mspan相关源码可以发现它其实是一个双向链接列表,其中包括起始页面的地址,span size class
和span
中的页面数量。像TCMalloc
一样,Go
把内存页按照大小分成了67
个类别,大小从8
字节到32KB
,如下图所示。
每个span
存在两个,一个span
用于带指针的对象(scan class
),另一个用于无指针的对象(noscan class
)。为什么要分两类呢?当垃圾回收的时候,没有指针的类扫描对象的时候就不需要遍历指针了。
我们刚刚提到mspan
有很多不同的大小规格,我们把相同大小级别的span
可以归为一类,用mcentral
管理。每个mcentral包含两个mspanList
:
empty
:双向span
链表,包括没有空闲对象的span
或缓存(mcache
)中的span
。当此处的span
被释放时,它将被移动到non-empty span
链表。non-empty
:有空闲对象的span
双向链表。当从mcentral
请求新的span
,mcentral
会从该链表中获取span
并把它移入到empty span
链表中。
如果mcentral
没有可用的span
,那么就向mheap
请求新页。在Go
源码实现中,mheap
结构体由两组非常重要的字段,一个是长度为134的mcentral
数组,其中67个为跨度类需要Scan
的mcentral
,另外67个是noscan
的mcentral
;另一组就是管理堆区内存区域的arenas
及相关字段。
arena
:堆在已分配的虚拟内存中根据需要增长和缩小。当需要更多内存的时候,mheap
会从虚拟内存中以每块64MB(对于64-bit机器)为单位获取新内存,这块内存就叫做arena。
mcache
:mcache
是提供给P
的高速缓存,用于存储小对象(对象大小<=32KB)。这类似于线程堆栈,但是它属于堆的一部分,用于动态数据。所有类大小的mcache
包含scan
和noscan
类型的mspan
。这些大对象申请请求是以获取central lock
为代价的,因此在任何给定的时间点只能满足一个P的请求。mcache
需要的时候会从mcentral
需要时请求新的span
。
3.设计原理
每次用户程序申请内存的时候,它会通过内存分配器申请新的内存,分配器会负责从堆中初始化响应的内存区域。在具体了解内存分配器的实现之前,简单介绍下内存分配器的分配方法和分级分配的概念。
3.1 分配方法
内存分配器一般有两种分配方法,首先是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator)。
3.1.1 线性分配器
原理:我们只需要在内存中维护一个指向内存特定位置的指针,用户每次申请内存的时候,分配器只需要检查剩余的空闲内存,然后返回分配的内存区域并修改指针在内存中的位置(我们可以理解为移动指针)。
优点:实现简单,执行速度较快。
缺点:如果某一块内存被释放,那么该块内存无法被重用。下图中的红色部分内存被释放后,线性分配器无法重用。
适用场景:比较适合包括拷贝的垃圾回收算法。像标记-复制、标记-整理等算法通过copy
的方式整理对象的位置,将空闲内存合并,这样就可以利用线性分配器的效率提升内存分配器的性能,实际上你看JVM的对于不同分代的回收算法也是基于线性分配器来做的。
3.1.2 空闲链表分配器
原理:在内部会维护一个类似链表的数据结构,每次用户申请内存的时候,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表。
特点:空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,这里主要介绍4种方式:
-
首次适应(First-Fit):从链表头开始遍历,选择大于申请内存大小的第一个内存块;
-
循环首次适应(Next-Fit):从上次遍历的结束位置开始遍历,选择大于申请内存大小的第一个内存块;
-
最优适应(Best-Fit): 从链表头遍历整个链表,选择最合适的内存块;
-
隔离适应(Segregated-Fit): 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
其中,Go语言使用的内存分配策略和
隔离适应
有些类似,所以简单了解下该策略的原理。如果采用该策略,那么会把内存分割成由4、8、16、32字节的内存块组成的链表,如果我们申请16字节的内存时,我们会在上图中的第三个链表找到空闲的内存块并返回。这种分配策略减少了需要遍历的内存块数量,提高了分配效率。
优点:
- 可以重用被释放的内存。
- 可以选择不同的策略在链表中的内存块中进行选择,其中
Go
采用的是隔离适应策略。
缺点:分配内存时需要遍历链表,时间复杂度为 O ( 1 ) O(1) O(1)
3.2 分级分配
前面的部分提到过,Go语言的内存分配器实际上借鉴了线程缓存分配(TCMalloc
)的思路。Sanjay Ghemawat曾经对TCMalloc
和glibc 2.3 malloc
的性能进行了测试,发现对于执行malloc/free
操作的时间前者是后者的
1
/
6
1/6
1/6。TCMalloc
核心理念是使用多级缓存,把对象根据大小进行分类,不同类别选择不同的处理分配逻辑。
TCMalloc
引入了线程缓存(Thread Cache
)、中心缓存(Central Cache
)和页堆(Page Heap
)三个组件分级管理内存,在三个概念分别对应Go
中的内存管理组件mcache
/mcentral
/mheap
。
- 线程缓存属于每一个独立的线程,能够满足线程上绝大多数的内存分配需求,不需要使用互斥锁来保护内存,可以减少锁竞争带来的性能损耗。;
- 线程缓存无法满足需求时,使用中心缓存解决小对象的内存分配问题;
- 如果遇到32KB以上的对象时,内存分配器会选择页堆直接分配内存。
3.3 虚拟内存布局
在Go
语言1.10之前的版本,堆区的内存空间都是连续的,在1.11版本中,Go
团队使用稀疏的堆内存空间替代了连续的内存,解决了连续内存带来的限制以及在特殊场景下可能出现的问题。
线性内存
💡优点:Go
语言在垃圾回收时会根据指针的地址判断对象是否在堆中,线性内存的设计方式简单且方便。
💡缺点:线性内存的方式需要预留大块的内存空间,但是申请大块的内存空间却不使用是不实际的;如果我们不预留内存空间,那么在有些特殊场景下会造成程序崩溃。
稀疏内存
💡优点:解决了堆大小的上限(这个问题有人提了个issues),还解决了C和Go混用时出现的地址空间冲突问题。
💡缺点:稀疏内存的内存管理失去了内存的连续性这一假设,导致内存管理变得复杂;相对于线性内存来说,稀疏内存约增加1%的垃圾回收成本。
4. 内存分配
Go
使用线程本地缓存来加速小对象的分配,并且维护scan/noscan
的span
来加速GC
。整个过程中避免了内存碎片,所以GC
期间也不需要做压缩处理。
Go
根据对象的大小可以把对象分为三类,每一类对象都对应着不同的对象分配过程。
4.1 微对象
微对象 (0, 16B)
— 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;
主要使用它来分配较小的字符串和逃逸的临时变量。
微分配器可以将多个较小的内存分配请求合入同一个内存块,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小
maxTinySize
是可以调整的,在默认情况下为16字节,该值越大,说明组合多个对象的可能性越高,内存浪费也就会更严重,但不管如何,最好保证是8的倍数。 如果微分配器在16字节的内存块中已经分配了一个12字节的对象,如果下一个待分配的对象小于4字节,那么直接使用剩余部分即可,否则会从中心缓存或者页堆中获取可分配的内存块。
但是如果想要回收这个16字节的内存块,要求该内存块中的所有对象都被标记为垃圾时才可以被回收。
下面是分配微对象的动图演示:
4.2 小对象
小对象 [16B, 32KB]
— 依次尝试使用线程缓存、中心缓存和堆分配内存;
在微对象和小对象分配的时候,如果
mspan
的列表是空的,那么分配器会从mheap
获取大量的页面用于mspan
,如果mheap
为空或没有足够大的页面满足分配请求,那么会从操作系统中分配一组新的页(至少1MB);
下面是分配小对象的动图演示:
4.3 大对象
大对象 (32KB, +∞)
— 直接在堆上分配内存,如果mheap
为空或者没有足够大的页面来满足分配请求,那么会在操作系统中分配一组新的页(至少1MB);
下面是分配大对象的动图演示:
4.4 源码走读
堆上所有的对象都会通过调用runtime.newobject函数分配内存,该函数会调用runtime.mallocgc分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数。
前面我们提到过,runtime
会根据对象的大小将他们分成微对象、小对象和大对象,然后根据分类选择不同的分配逻辑,从下面的mallocgc()
代码中就可印证这一点。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
mp := acquirem()
mp.mallocing = 1
c := gomcache()
var x unsafe.Pointer
noscan := typ == nil || typ.ptrdata == 0
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 微对象分配
} else {
// 小对象分配
}
} else {
// 大对象分配
}
publicationBarrier()
mp.mallocing = 0
releasem(mp)
return x
}
4.4.1 分配微对象
微对象由mcache
上的微分配器提高微对象分配的性能,微分配器管理的对象不可以是指针类型。分配微对象时首先选择在mcache
中分配,mcache
中的tiny
字段指向maxTinySize
大小的内存块,如果该内存块还有合适的空闲内存,那么运行时获取并返回这块内存。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
//非指针类型&&微对象
if noscan && size < maxTinySize {
off := c.tinyoffset
//当前块还包含合适的空闲内存,通过基址和偏移量获取并返回空闲内存
if off+size <= maxTinySize && c.tiny != 0 {
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.local_tinyallocs++
releasem(mp)
return x
}
...
}
...
}
...
}
如果这块内存中的空闲内存无法满足需求,那么会先从mcache
找到对应的内存管理单元mspan
,然后调用nextFreeFast
函数获取空闲内存,如果依然不存在空闲内存,那么就要调用nextFree
从mcentral
或者mheap
获取可分配的内存块。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
...
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
//从`mcentral`或者`mheap`获取可分配的内存块
v, _, _ = c.nextFree(tinySpanClass)
}
//获取新的空闲内存之后,清空空闲内存中的数据,更新构成微对象分配器的tiny和tinyoffset
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
}
...
}
...
//返回新的空闲内存
return x
}
4.4.2 分配小对象
小对象的分配步骤可以分为以下三个阶段:
- 确定分配对象的大小以及跨度类 runtime.spanClass;
- 从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;
- 调用 memclrNoHeapPointers 清空空闲内存中的所有数据;
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
...
} else {
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)
span := c.alloc[spc]
//nextFreeFast用来获取空闲内存的
//nextFreeFast通过mspan的allocCace字段可以快速找到字段中为1的位数
//1表示该位对应的内存空间是空闲的
v := nextFreeFast(span)
if v == 0 {
//如果nextFreeFast没有找到空闲内存,那么通过nextFree找到新的内存管理单元
v, span, _ = c.nextFree(spc)
}
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
memclrNoHeapPointers(unsafe.Pointer(v), size)
}
}
} else {
...
}
...
return x
}
4.4.3 分配大对象
分配大对象直接从系统的栈中调用largeAlloc函数分配内存。在该函数中会计算分配该大对象需要的页数,然后按照8KB
的倍数为大对象在堆上申请内存。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
...
} else {
var s *mspan
systemstack(func() {
//调用largeAlloc函数直接分配内存
s = largeAlloc(size, needzero, noscan)
})
s.freeindex = 1
s.allocCount = 1
x = unsafe.Pointer(s.base())
size = s.elemsize
}
publicationBarrier()
mp.mallocing = 0
releasem(mp)
return x
}
largeAlloc
函数主要是调用alloc函数去分配内存管理单元。
func largeAlloc(size uintptr, needzero bool, noscan bool) *mspan {
npages := size >> _PageShift
if size&_PageMask != 0 {
npages++
}
...
//alloc函数:从heap中分配npages页的新span
s := mheap_.alloc(npages, makeSpanClass(0, noscan), needzero)
s.limit = s.base() + size
heapBitsForAddr(s.base()).initSpan(s)
return s
}
Link