前言
内存空间包含两个重要区域:栈区(Stack)和堆区(Heap)。函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;不同编程语言使用不同的方法管理堆区的内存,C++ 等编程语言会由工程师主动申请和释放内存,Go 以及 Java 等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。因此这里主要讨论堆中的内存。
设计原理
内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector)1,当用户程序申请内存时,它会通过内存分配器申请新内存,而分配器会负责从堆中初始化相应的内存区域(垃圾回收)。
分配方法
编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator),这两种分配方法有着不同的实现机制和特性。
线性分配器
线性分配(Bump Allocator)是当我们使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
优点:简单高效。缺点:无法利用被释放的内存,产生内碎片。如图,下图红色的空间就无法利用了。因此,这种分配器通常要配合适当得垃圾回收算法,如标记赋值,标记整理算法,通过拷贝的方式整理存活对象的碎片,将空间内存定期合并,从而提升空间利用的效率。
空闲链表分配器
空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
因为不同的内存块通过指针构成了链表,所以使用这种方式的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度是 O(n)O(n)。空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的是以下四种:
首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块
Go中的分配策略和第四种有点相似,如下图,将内存分为4,8,16,32字节组成的链表。当申请8字节的对象时,就能很快找到对应大小的内存进行分配。
对象大小
Go中将对象分为了3类大小,对象的大小影响着内存分配的性能。
类别 大小
微对象 (0, 16B)
小对象 [16B, 32KB]
大对象 (32KB, +∞)
多级分配
内存分配器不仅将对象区别对待,并且还会将内存分成不同的级别进行管理,分别是线程缓存,中心缓存和页堆。
内存管理单元
Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件,分别对应 runtime.mspan、runtime.mcache、runtime.mcentral 和 runtime.mheap。
每个处理器都会分配一个线程缓存cache用于处理微对象和小对象的分配,它们会持有内存管理单元mspan,当mspan内存不足时,会从mhead持有的134种中心缓存中获取新的内存单元,他们会从操作系统申请内存。
内存管理单元mspan
mspan是go内存管理的基本单元,该结构体中含有next和prev两个字段,分别指向前一个和后一个span,形成了一个双向链表。
每个mspan管理着mpages个大小为8KB的页,当内存不足时,运行时会以页为单位向堆申请内存:
内存管理单元可能会处于4种状态,mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 。当 runtime.mspan 在空闲堆中,它会处于 mSpanFree 状态;当 runtime.mspan 已经被分配时,它会处于 mSpanInUse、mSpanManual 状态,运行时会遵循下面的规则转换该状态:
在垃圾回收的任意阶段,可能从 mSpanFree 转换到 mSpanInUse 和 mSpanManual;
在垃圾回收的清除阶段,可能从 mSpanInUse 和 mSpanManual 转换到 mSpanFree;
在垃圾回收的标记阶段,不能从 mSpanInUse 和 mSpanManual 转换到 mSpanFree;
当我们需要申请mspan时,会查找空闲的mspan,即状态为mSpanFree,如果不足则会从mcache中更新内存单元以满足要求。
线程缓存mcache
runtime.mcache 是 Go 语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有 68 * 2 个 runtime.mspan。
线程缓存在初始化的时候是没有mspan的,只有当用户申请内存时,才会去申请mspan。通过runtime.mcache.refill 会为线程缓存获取一个指定跨度类的内存管理单元。
线程缓存中还包含几个用于分配微对象的字段,下面的这三个字段组成了微对象分配器,专门管理 16 字节以下的对象:
type mcache struct {
tiny uintptr
tinyoffset uintptr
local_tinyallocs uintptr
}
微分配器只会用于分配非指针类型的内存
中心缓存
runtime.mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁:
每个中心缓存都会管理某个跨度类的内存管理单元(一共134种),它会同时持有两个 runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的内存管理单元。
当线程缓存向中心缓存申请mspan时,总是先调用清理过的,有空闲空间的msapn,然后才是没有清理过,有空闲空间的mspan,即重复利用。
当中心缓存内存不足时,会计算待分配的页数以及跨度类(即申请了哪一类span),然后调用 runtime.mheap.alloc 获取新的 runtime.mspan 结构。
页堆
runtime.mheap 是内存分配的核心结构体,Go 语言程序会将其作为全局变量存储,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central,另一个是管理堆区内存区域的 arenas 以及相关字段。
页堆中包含一个长度为 136 的 runtime.mcentral 数组,其中 68 个为跨度类需要 scan(指针类型) 的中心缓存,另外的 68 个是 noscan 的中心缓存:
内存分配
微对象
Go 语言运行时将小于 16 字节(默认情况下,可以调节)的对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。
如上图所示,微分配器已经在 16 字节的内存块中分配了 12 字节的对象,如果下一个待分配的对象小于 4 字节,它会直接使用上述内存块的剩余部分,减少内存碎片,不过该内存块只有所有对象都被标记为垃圾时才会回收。
线程缓存 runtime.mcache 中的 tiny 字段指向了 maxTinySize 大小的块,如果当前块中还包含大小合适的空闲内存,运行时会通过基地址和偏移量获取并返回这块内存:
当内存块中不包含空闲的内存时,会先从线程缓存找到跨度类对应的内存管理单元 runtime.mspan,调用 runtime.nextFreeFast 获取空闲的内存;当不存在空闲内存时,我们会调用 runtime.mcache.nextFree 从中心缓存或者页堆中获取可分配的内存块:
小对象
小对象是指大小为 16 字节到 32,768 字节的对象以及所有小于 16 字节的指针类型的对象,小对象的分配可以被分成以下的三个步骤:
确定分配对象的大小以及跨度类 runtime.spanClass;
从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;
调用 runtime.memclrNoHeapPointers 清空空闲内存中的所有数据;
大对象
运行时对于大于 32KB 的大对象会单独处理,我们不会从线程缓存或者中心缓存中获取内存管理单元,而是直接调用 runtime.mcache.allocLarge 分配大片内存:
runtime.mcache.allocLarge 会计算分配该对象所需要的页数,它按照 8KB 的倍数在堆上申请内存。
总结
Go将对象分为了3类:微对象(小于16bit),小对象(16bit~32KB)和大对象(大于32KB)。
内存管理的基本单位是mspan,mspan由多个大小为8KB的page构成,根据不同类型可以分为134种mspan,多个mspan又形成一个arena,在堆中,有多个arena。另外,堆中还有一个central列表,一共存放着134类中心缓存,每一类中心缓存管理着对应的mspan。线程缓存服务于GMP中的处理器P,线程缓存的基本单位为mspan,根据需要可以申请不同的mspan进线程缓存中以使用。
对于内存的分配
微对象,默认分配在线程缓存中大小为16字节的微分配器中,只有当微分配器中的所有对象都不可达,其对象才会被回收。当内存不足时,首先会获取线程缓存中空闲的mspan,如果没有空闲的,则会去中心缓存申请
小对象有132种,首先会确认需要获取哪类mspan,再从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;
大对象直接分配在堆中的arena中
整个堆中的内存就大致如下图所示。
参考
https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/#713-%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D
https://www.zhihu.com/zvideo/1329802581178884096