一. TCMalloc
TcMalloc是什么与golang直接的关系
- TCMalloc 是google开发的内存分配算法库, Golang的内存分配算法大部分是在TCMalloc 基础上优化开发的,所以学习golang的内存管理前要先了解一下TcMalloc
使用TcMalloc时对底层内存的划分单位及相互之间的关系
- 在使用TcMalloc时,将底层内存分为了Page, PageHeap, Span, Size-Class, Central Free List, Transfer Cache, Pagemap and Spans, 接下来我们一个一个的解释
1. Page
- Page是操作系统对内存管理的单位,同样TCMalloc也是以页为单位管理内存,但是TCMalloc中Page大小是操作系统中页的倍数关系. TCMalloc的默认Page大小是8KB,Linux是4KB
3. Span
- Span是用来管理内存页的单位,它是由一组连续的Page组成,比如2个Page组成的span,多个这样的span就用链表来管理。当然,还可以有4个Page组成的span等等。
- Span可用于管理已移交给应用程序的大对象(多个Page组成的大对象),或已拆分为一系列小对象的一组页面(一个或多个Page被Size-Class拆分固定大小的Object链表)。如果Span管理的是小对象,则会在Span中记录对象的Size-Class信息。
4. Size-Class
- SizeClass是内存对象,是实际的载体
- “小”对象的分配被映射到60-80个不同大小的Size-class类型上。例如,一个12字节的分配将被四舍五入到16字节Size-class。Size-class的设计是为了在舍入到下一个最大的size类时尽量减少内存的浪费
- Page是内存页,多个Page可以组成Span,也可以间接的说Span可以管理对象,并且是根据对象大小来管理的,如果某个Span管理的都是小对象,则会在这个Span中记录对象的Size-Class, 同一个Span分裂出的SizeClass大小相同
同一个链表中的Object大小相同,即由Span分割出来的对象
5. CentralCache
- 每种规则的Size-class都有一个独立的内存分配单元:CentralCache
- 每一个size-class都会关联一个span List,这个list中所有span的大小都是相同的,每个span都已经被拆分为对应的size-class,其中用链表维护着空闲的object,当前端有需要时会被取出
6. Transfer Cache
- Front-End前端请求内存或返回内存时,首先到达Transfer Cache.
Front-End前端在下面TCMalloc 内部组成中会将
- Transfer Cache包含一个指向可用内存的指针数组,可以快速将对象移动到该数组中,或者代表前端从该数组中获取对象
- 如果传输缓存无法满足内存请求,或者没有足够的空间容纳返回的对象,它将访问Central free List
7. Central Free List
- Central Free List是当Front-End前端内存不足时,提供内存供Front-End前端使用的
- Central Free List 以Span为基础管理内存,当需要内存时,会从不同的Span中提取内存.(即将Span按照Size-class拆分为Object提供给前端)
- 当对象返回到Central Free List时,每个对象都将映射到它所属的Span(使用pagemap,然后释放到该Span中)
8. PageHeap
- PageHeap保存的是Span。
- 当前端通过Central Free List申请内存发现内存不足时,可以从PageHeap获取Span,然后把Span切割成对应Size-class大小的Object 返回前端
9. Pagemap and Spans
- 所有被TCMalloc从操作系统申请到的内存都会被划分成编译时就被确认好的Page大小, pagemap用于查找对象所属的Span,或标识给定对象的Size-class(例如一个大对象被分配了多个Page的Span,或者一个Span被拆分成Size-class大小后分配给小对象)。
TCMalloc 内部组成
- 查看上图,其中User Code是我们所写的代码,OS是指操作系统,在TCMalloc中实际可分为三层,分别是Front-End(前端),Middle-End(中端),Back-End(后端),一种层层递进的关系
- 前端是一个高速缓存,可为应用程序提供快速的内存分配和重新分配。
- 中端负责重新填充前端缓存。
- 后端处理从OS提取的内存
- 在程序运行需要内存时,会先向前端申请内存,当前端的内存缓存不足时,会像中端请求内存,中端内存不足时会向后端请求内存,后端发现也没有内存的时候,会向OS申请内存
1. Front-End 前端
- 程序运行需要内存时,会先按照策略向Front-End申请内存, 它是一个内存缓存,提供了快速分配和重分配内存给应用的功能
- 有两种实现策略: Per-thread cache基于线程分配内存 和 Per-CPU cache基于CPU分配内存
- 基于线程分配内存,每个线程都有一定的内存缓存, 缺点:现代的程序大都有比较多的线程数量,会造成大量线程聚合即缓存内存过大,或者每个线程的内存缓存较少.
- 基于CPU分配内存,每个CPU都有一定的内存缓存,注:在x86的架构里,一个逻辑CPU相当于一个超线程.(例如6核12线程里的12线程就是逻辑CPU)
- 在请求前端分配内存时,请求也分为两种由kMaxSize这个参数决定
- 当小于kMaxSize的内存分配请求来到前端,前端会对内存请求的大小进行一次映射,映射到对应的Size-class类型(如:12KB会被舍入到16KB),然后将该类型的Size-Class分配给对象
- 当大于kMaxSize的内存分配请求,会交给后端直接分配Span给大对象.
- 前端用于处理大部分的内存的请求(Size-class类型包含了大部分请求的内存请求)。前端具有内存缓存,可用于分配或保存可用内存。该高速缓存一次只能由单个线程(CPU)访问,因此它不需要任何锁,因此大多数分配和释放都是快速的
2. Middle-End 中端
- 当通过前端申请某种特定大小的缓存为空时,前端将向中端(Middle-End)请求一批内存以重新填充缓存。 中端就包括了Central Free List和Transfer Cache
- Transfer Cache 传输缓存
- 当前端请求内存或返回内存时,它将到达传输缓存。
- 传输缓存包含一个指向可用内存的指针数组,可以快速将对象移动到该数组中,或者代表前端从该数组中获取对象。
- 传输缓存的名称来自一个线程正在分配由另一个线程释放的内存的情况。 传输缓存允许内存在两个不同线程之间快速流动。
- 如果传输缓存无法满足内存请求,或者没有足够的空间容纳返回的对象,它将访问中央空闲列表
3… Central Free List- Central Free List是当Front-End内存不足时,提供内存供其使用.
- Central Free List 以Span为基础管理内存,当需要内存时,会从不同的Span中提取内存.
- 当对象返回到Central Free List时,每个对象都将映射到它所属的Span(使用pagemap,然后释放到该跨度中。如果驻留在特定跨度中的所有对象都返回给它,则整个跨度 返回到后端。
- 如果中端已用尽,将请求后端分配Page填充到中端. 后端也称为PageHeap
3. Back-End 后端
- 当中端内存不够的请求来到后端时,后端会返回对应Span的内存给中端.
- TCMalloc的后端实际负责三个任务:
- 管理未使用的内存。
- 当没有合适大小的可用内存来满足分配请求时, 它负责从操作系统获取内存。
- 将不需要的内存返回给操作系统
- TCMalloc有两个类型的后端
- Legacy Pageheap: 用于管理一定大小的Span.
- Hugepage Aware Allocator: 用于管理大对象,通常超过2MB
TCMalloc 总结
二. golang 内存分配之 前置概念相关的东西
- 先了解一下golang中内存分配一些前置概念相关的东西
内存分配方式
- 先了解一下几种常见的内存分配方式
- 内存分配器通常有三种:线性分配器Sequential Allocator,Bump Allocator,空闲链表分配器Free-List Allocator,这两种分配方法有着不同的实现机制和特性, 另外还有一种按级别分配
1. 线性分配器
- 线性分配器: 需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器需要检查剩余的空闲内存,返回分配的内存区域并修改指针在内存中的位置(也就是维护一个指针指向空闲内存区域,移动指针到空闲区域分配空闲内存即可)
- 优缺点:
- 优点: 实现复杂度低,执行速度快
- 缺点: 如果已经分配的内存被回收,线性分配器是无法重新使用,容易造成内存碎片
- 所以要配合垃圾回收算法例如:标记压缩Mark-Compact,复制回收Copying GC 和分代回收Generational GC 等可以通过压缩或拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了
2. 空闲链表分配器
- 空闲链表分配器: 在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存然后申请新的资源并修改链表,进而做到可以重用已经被释放的内存
- 虽然实现了内存回收后重复利用,但是再次分配时获取空闲内存块,需要遍历链表,时间复杂度就是 O(n),进而提出来几种内存选择策略
- 首次适应First-Fit: 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应Next-Fit: 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应Best-Fit: 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应Segregated-Fit: 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
- 隔离适应策略,如下图: 会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,我们会在上图中的第二个链表找到空闲的内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率
3. 分级分配
- 分级分配比空闲链表分配的优点:
- 内存碎片化更低:链表内存分配器在分配和释放内存时可能会导致内存碎片化。而按级分配器使用预先分配的固定大小的内存块(slab),每个 slab 块可以容纳多个相同大小的对象,当对象被释放时,可以直接重用该 slab 块,而无需将内存碎片回收到整个内存池中,可以减少内存碎片化的问题
- 内存分配速度更快:链表内存分配器在寻找合适大小的空闲内存块时需要遍历链表,这可能需要较长的时间。而按级分配器在每个 slab 内存块中维护了一个空闲对象列表,可以直接从该列表中获取空闲对象,因此分配速度更快。
- 并发性能更好:链表内存分配器通常需要保证线程安全性,因为多个线程可能同时访问和修改内存分配器的数据结构。而按级分配器可以使用 per-CPU 缓存等技术来提高并发性能,每个 CPU 核心都拥有自己的缓存,减少了线程间的竞争。
- 对象对齐更灵活:按级分配器可以根据对象的对齐要求将对象放置在合适的 slab 块中,以最大程度地利用内存。而链表内存分配器通常只能以固定的大小进行内存分配,无法满足特定对象对齐的要求。
- 上面说的空闲链表分配器使用隔离适应策略时,将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,则会在大小为8字节的链表找到空闲的内存块并返回,这也可以看为简单版的分级分配的一种
- Go 语言的内存分配器借鉴了 TCMalloc 线程缓存分配的设计,对整个内存进行了组件划分,提出了线程缓存Thread Cache,中心缓存Central Cache,和页堆Page Heap三个组件,并且根据对象大小将对象分成微对象,小对象和大对象三种,在申请内存时会根据申请分配的内存大小选择不同的处理逻辑:
- 线程缓存是每个 Goroutine 都会拥有的本地内存池,用于缓存小型和微观对象。
- 中心缓存是用来缓存大型对象的内存池,由多个 Goroutine 共享,可以实现资源的共享利用
- 页堆负责向程序申请和释放系统内存页,并将这些系统页面划分为多个内存块,供后续使用,是用来管理系统内存的第一层分配器
内存布局
- Go 1.10 以前的版本,堆区的内存空间都是连续的线性内存
- 1.11 版本修改成了稀疏的堆内存空间,解决了连续内存带来的限制以及在特殊场景下可能出现的问题
1. 线性内存
- Go 语言程序的 1.10 版本采用线性内存,在启动时会初始化整片虚拟内存区域,在线性内存管理中有 spans、bitmap 和 arena 如下所示的三个区域,分别预留了 512MB、16GB 以及 512GB 的内存空间,这些内存并不是真正存在的物理内存,而是虚拟内存
- spans 一个 span 是一块连续的内存区域,它的大小是固定的,通常为 8KB 或者 4KB,在 Golang 中被称为 page。Spans 用于表示线性内存堆上的一部分空闲或已分配的内存。每个 span 都有其自己的元数据信息,包括 span 的状态(例如,Free、Allocated)、空闲块的数量和位置等区域,内部存储了指向内存管理单元 runtime.mspan 的指针,每个内存单元会管理几页的内存空间,每页大小为 8KB;
- bitmap 是一个位图,用于记录 spans 的使用情况,bitmap 中的每一位都可以通过索引与 spans 数组中的对应 span 关联起来,标识 arena 区域中的那些地址保存了对象,位图中的每个字节都会表示堆区中的 32 字节是否包含空闲;
- arena 区域是真正的堆区,用于存储所有的 spans。每个 Arena 都有自己的 bitmap 用于跟踪 spans 的使用情况,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象
- 对于任意一个地址,我们都可以根据 arena 的基地址计算该地址所在的页数并通过 spans 数组获得管理该片内存的管理单元 runtime.mspan,spans 数组中多个连续的位置可能对应同一个 runtime.mspan
- Go 语言在垃圾回收时会根据指针的地址判断对象是否在堆中,并通过上一段中介绍的过程找到管理该对象的 runtime.mspan。这些都建立在堆区的内存是连续的这一假设上。这种设计虽然简单并且方便,但是在 C 和 Go 混合使用时会导致程序崩溃:
- 分配的内存地址会发生冲突,导致堆的初始化和扩容失败;
- 没有被预留的大块内存可能会被分配给 C 语言的二进制,导致扩容后的堆不连续
- 线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用是不切实际的,不预留内存空间却会在特殊场景下造成程序崩溃。虽然连续内存的实现比较简单,但是这些问题我们也没有办法忽略
2. 稀疏内存
- 稀疏内存是 Go 语言在 1.11 中提出的方案,使用稀疏的内存布局不仅能移除堆大小的上限,还能解决 C 和 Go 混合使用时的地址空间冲突问题。不过因为基于稀疏内存的内存管理失去了内存的连续性这一假设,这也使内存管理变得更加复杂
- 运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个单元都会管理 64MB 的内存空间, 底层结构
type heapArena struct {
bitmap [heapArenaBitmapBytes]byte
spans [pagesPerArena]*mspan
pageInUse [pagesPerArena / 8]uint8
pageMarks [pagesPerArena / 8]uint8
zeroedBase uintptr
}
- 该结构体中的 bitmap 和 spans 与线性内存中的 bitmap 和 spans 区域一一对应,zeroedBase 字段指向了该结构体管理的内存的基地址。这种设计将原有的连续大内存切分成稀疏的小内存,而用于管理这些内存的元信息也被切分成了小块
- 不同平台和架构的二维数组大小可能完全不同,如果我们的 Go 语言服务在 Linux 的 x86-64 架构上运行,二维数组的一维大小会是 1,而二维大小是 4,194,304,因为每一个指针占用 8 字节的内存空间,所以元信息的总大小为 32MB。由于每个 runtime.heapArena 都会管理 64MB 的内存,整个堆区最多可以管理 256TB 的内存,这比之前的 512GB 多好几个数量级
- Go 语言团队在 1.11 版本中通过以下几个提交将线性内存变成稀疏内存,移除了 512GB 的内存上限以及堆区内存连续性的假设
- 由于内存的管理变得更加复杂,上述改动对垃圾回收稍有影响,大约会增加 1% 的垃圾回收开销,不过这也是我们为了解决已有问题必须付出的成本
状态划分
- Go 语言的运行时构建了操作系统的内存管理抽象层,该抽象层将运行时管理的地址空间分成以下的四种状态
- 每一个不同的操作系统都会包含一组特定的方法,这些方法可以让内存地址空间在不同的状态之间做出转换
- 运行时中包含多个操作系统对状态转换方法的实现,所有的实现都包含在以 mem_ 开头的文件中,在linux上实现如下
- runtime.sysAlloc 会从操作系统中获取一大块可用的内存空间,可能为几百 KB 或者几 MB;
- runtime.sysFree 会在程序发生内存不足(Out-of Memory,OOM)时调用并无条件地返回内存;
- runtime.sysReserve 会保留操作系统中的一片内存区域,对这片内存的访问会触发异常;
- runtime.sysMap 保证内存区域可以快速转换至准备就绪;
- runtime.sysUsed 通知操作系统应用程序需要使用该内存区域,需要保证内存区域可以安全访问;
- runtime.sysUnused 通知操作系统虚拟内存对应的物理内存已经不再需要了,它可以重用物理内存;
- runtime.sysFault 将内存区域转换成保留状态,主要用于运行时的调试;
- 运行时使用 Linux 提供的 mmap、munmap 和 madvise 等系统调用实现了操作系统的内存管理抽象层,抹平了不同操作系统的差异,为运行时提供了更加方便的接口,除了 Linux 之外,运行时还实现了 BSD、Darwin、Plan9 以及 Windows 等平台上抽象层
GPM并发模型与内存分配的关系
- 在golang中提出了GPM并发模型,简单复习一下,具体参考前面的文档
G:表示Goroutine,也就是我们说的协程,由P进行调度。
P:是 G-M 的中间层,是逻辑处理器,每一个P组织多个goroutine 跑在同一个 OS 线程上。
M:是对内核级线程的封装,真正干活的对象。
- P作为协调器将G队列动态的绑在不同的M上,根据负载情况动态调整,从而均衡的发挥出多核最大并行处理计算能力
- Go语言中的每个处理器拥有一个对应的线程缓存,这个缓存中缓存的就是类似于TCMalloc的Span对象,同时也有Size-class的概念,每个Size-class对应不同的对象大小.
- 因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配,在遇到 32KB 以上的对象时(即大对象),内存分配器会选择页堆直接分配大内存。
三. golang 内存分配之 内存管理组件
- Golang内存分配由mspan,mcache,mcentral,mheap,spanClass组成,基本对应了TCMalloc中的Span,Pre-Thread,Central Free List,以及Page Heap
- Go的内存分配器不仅按照分配对象的大小将对象分成了微,小,大三种级别,还对整个内存进行了划分,比如:TCMalloc 和 Go 都引入了: 线程缓存Thread Cache,中心缓存Central Cache,和页堆Page Heap三个组件,用来分级管理内存
- 线程缓存是每个 Goroutine 都会拥有的本地内存池,用于缓存小型和微观对象,就是指上面的runtime.mcache
- 中心缓存是用来缓存大型对象的内存池,由多个 Goroutine 共享,可以实现资源的共享利用,也就是mcentral
- 页堆负责向程序申请和释放系统内存页,并将这些系统页面划分为多个内存块,供后续使用,是用来管理系统内存的第一层分配器,也就是mhelp
- 线程缓存属于每一个独立的线程,每个 Goroutine 都会拥有的本地内存池,能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以不需要使用互斥锁来保护内存,减少了锁竞争带来的性能损耗也,就是指上面的runtime.mcache
- Go 程序启动时初始化内存布局,每一个处理器都会被分配一个线程缓存runtime.mcache,用于处理微对象以及小对象的内存分配,mcache管理的单位就是mspan,如果mcache中缓存的空闲spanClass不足时,会向mheap持有的134个mcentral获取新的内存单元(这里的134也是mcache中的134,对应了67个无指针的Size-class和有指针的68个Szie-class)
- runtime.mcentral 是中心缓存,中心缓存属于全局的堆结构体 runtime.mheap,它会从操作系统中申请内存
- 这么看是不是很像TCMalloc中依次向前端,中端,后端请求内存呢
- 换个角度看,在申请缓存时的执行流程可分为:线程(协程)—> 线程缓存–>中心缓存–>pageHeap
spanClass
- 此处的spanClass实际功能就对应TCMalloc中有一个Size-class,主要是为了中小对象的分配
- 在golang中会根据大小将对象分类
- 小于16字节: 微对象
- 大于16字节,小于32KB: 小对象
- 大于32KB : 大对象
- Golang中有67种不同大小的spanClass,用于对小对象的分配
- spanClass实际就是一个8位的数据,前七位用于存储当前spanClass属于67种的哪一种,最后一位代表当前spanClass(当前对象)是否存储了指针,这个非常重要,因为是否存在指针意味着是否需要在垃圾回收的时候进行扫描
- 不同大小的对象在需要mcache分配内存时,会通过映射对应到不同的spanClass上,比如一个12字节的对象,会被向上取整到上表中的16字节的spanClass. 也就是spanClass的class为2,但是这个2代表的是前七位的值,如果这个12字节的对象不存在指针,那么最后一位就是1, 由此可得这个12字节的对象对应spanClass的值就是0000010 1也就是5
- 查看spanClass 源码
type spanClass uint8
const (
// _NumSizeClasses = 67 ,左移即乘2,分表代表有无指针
numSpanClasses = _NumSizeClasses << 1
// tinySizeClass = 2,对应图上的小于16字节的两种
tinySpanClass = spanClass(tinySizeClass<<1 | 1)
)
// 前七位用于登记类型,最后一位用于记录有无指针
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
// 获取前七位,也就是得到对应的spanClass类型
func (sc spanClass) sizeclass() int8 {
return int8(sc >> 1)
}
// 判断最后一位是否为1, 1为无指针,0为有指针
func (sc spanClass) noscan() bool {
return sc&1 != 0
}
mspan 内存管理基本单元
- mspan是 Go 语言内存管理的基本单元,与TCMalloc中Span概念基本一致,一个Span可以管理一个或多个操作系统分配给程序的内存页(Page).同时spanClass也是由mspan分割而来
- 操作系统通常页面大小为8KB也就是8192字节. 如果按照序号为1的spanClass分配,那么管理1个Page的mspan会被分割为1024份
- 看一下mspan结构体
type mspan struct {
// 前后指针,分别指向了前后的Span
next *mspan
prev *mspan
// 当前Span的起始地址
startAddr uintptr // address of first byte of span aka s.base()
// 代表当前Span是由多少Page构成的
npages uintptr // number of pages in span
// 扫描页中空闲对象的初始索引
freeindex uintptr
// 与freeindex结合使用可以快速定位空闲内存
allocCache uint64
// 记录这个Span被spanClass切割成了多少份,即可以存放多少个对象
nelems
// 位图,记录已经分配的对象
allocBits *gcBits
// 位图,记录内存的回收情况,用于垃圾回收
gcmarkBits *gcBits
// 记录已经分配对象个数
allocCount uint16
// 记录Span信息
spanclass spanClass
//状态,记录内存使用情况
//1. mSpanDead 已经被回收
//2. mSpanInUse 已经分配
//3. mSpanManual 已经分配
//4. mSpanFree 空闲
state mSpanStateBox
}
- 在mspan结构体中内部包含一个startAddr,对应了这个Span对应于内存中的地址,npages对应了当前Span包含的Page个数,如图所示就是一个包含两个Page的Span,并且在mspan的结构中还有用于快速查找空闲内存的allocCache以及用于定位空闲内存的指针freeindex
- 在mspan结构体中还包含 next 和 prev 两个字段,它们分别指向了前一个和后一个 runtime.mspan,最终形成一个双向链表,运行时会使用 runtime.mSpanList 存储双向链表的头结点和尾节点并在线程缓存以及中心缓存中使用,相邻的管理单元会互相引用,所以我们可以从任意一个结构体访问双向链表中的其他节点
- 注意: spanClass只是划分mspan的方式,真正的内存管理单元只有mspan一个
1. mspan.state 状态
- 运行时会使用mspan中的state字段,类型是 runtime.mSpanStateBox 结构,存储内存管理单元的状态
- 该状态可能处于 mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 四种情况。当 runtime.mspan 在空闲堆中,它会处于 mSpanFree 状态;当 runtime.mspan 已经被分配时,它会处于 mSpanInUse、mSpanManual 状态,这些状态会在遵循以下规则发生转换
- 在垃圾回收的任意阶段,可能从 mSpanFree 转换到 mSpanInUse 和 mSpanManual;
- 在垃圾回收的清除阶段,可能从 mSpanInUse 和 mSpanManual 转换到 mSpanFree;
- 在垃圾回收的标记阶段,不能从 mSpanInUse 和 mSpanManual 转换到 mSpanFree;
2. mspan.spanclass 跨度类
- mspan中的spanclass属性是 runtime.mspan 结构体的跨度类,它决定了内存管理单元中存储的对象大小和个数
- Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在 runtime.class_to_size 和 runtime.class_to_allocnpages 等变量中
mcache 线程缓存
- 当一个线程为对象申请内存时,就会使用mcache进行申请,在golang中使用GPM并发模型,每个处理器拥有一个对应的线程缓存也就是runtime.mcache,也可以这样说: mcache会被绑定在并发模型中的P上.也就是说每一个P(处理器)都会有一个mcache,用于给对应的协程的对象分配内存
- 查看mcache 结构
type mcache struct {
next_sample uintptr
local_scan uintptr // 在当前mcache中已经分配的可以扫描的字节数
*** 重要
// 微对象分配器,在后文中会有详细的介绍.
tiny uintptr
tinyoffset uintptr
local_tinyallocs uintptr // 微对象的分配数量
*** 重要
// numSpanClasses = 134 = _NumSizeClasses * 2
// 可以被分配的mspan,数量为(spanClass) * 2
alloc [numSpanClasses]*mspan
// 栈缓存
stackcache [_NumStackOrders]stackfreelist
// 本地分配的缓存信息,在GC时被刷新
local_largefree uintptr // 大对象被回收后会增加该对象大小
local_nlargefree uintptr // 大对象被回收的数量
local_nsmallfree [_NumSizeClasses]uintptr // 小对象被回收的数量
// 用于垃圾回收,本篇不讨论.
flushGen uint32
}
- 存储在mcache中的缓存对象数组一共有67 * 2个,其中*2是将spanClass分成了有指针和没有指针两种,方便与垃圾回收
1. mcache的初始化流程
- 在初始化mcache时会执行底层的allocmcache()函数
// 空占位符
var emptymspan mspan
func allocmcache() *mcache {
var c *mcache
systemstack(func() {
// 通过全局的mheap上锁
lock(&mheap_.lock)
c = (*mcache)(mheap_.cachealloc.alloc())
// 更新当前mcache的状态为sweepgen(用于垃圾回收)
c.flushGen = mheap_.sweepgen
unlock(&mheap_.lock)
})
// 将mcache中alloc数组使用空占位符代替(即emptymspan)
for i := range c.alloc {
c.alloc[i] = &emptymspan
}
// 获取堆分析的下一个采样点
c.nextSample = nextSample()
return c
}
- 初始化完成并没有真正的分配内存到mcache中,当初始化完成并且需要内存时,由于并没有真正的持有空闲的mspan,mcache首先会调用mcache.refill方法获取对应的mspan, 这是唯一一个向线程缓存mcache中插入内存管理单元mspan的唯一方法
func (c *mcache) refill(spc spanClass) {
s := c.alloc[spc]
if uintptr(s.allocCount) != s.nelems {
throw("refill of span with free space remaining")
}
if s != &emptymspan {
if s.sweepgen != mheap_.sweepgen+3 {
throw("bad sweepgen in refill")
}
atomic.Store(&s.sweepgen, mheap_.sweepgen)
}
// 调用mcentral的cacheSpan()获取对应spanClass的mspan.
s = mheap_.central[spc].mcentral.cacheSpan()
if s == nil {
throw("out of memory")
}
// 如果已经分配的对象等于这个mspan能分配的最大数
if uintptr(s.allocCount) == s.nelems {
throw("span has no free space")
}
// 将这个mspan缓存,下次垃圾回收的时候会扫描
s.sweepgen = mheap_.sweepgen + 3
// 把获得的mspan放到mcache中.
c.alloc[spc] = s
}
2. 微对象分配器预览
- 再次查看上面mcache 结构体,内部存在三个字段,这就是微对象分配器,前面的流程主要是针对于spanClass由需要3开始到序号67分配是使用的,而微对象分配器主要是针对于16字节以下的对象
type mcache struct {
...
// 指向分配微对象的首地址
tiny uintptr
// 指向当前已经分配对象的尾地址,即下一个可以分配空间的首地址
tinyoffset uintptr
// 记录微对象分配器中已分配对象的个数
local_tinyallocs uintptr
...
}
- 微分配器只会用于分配非指针类型的内存,上述三个字段中 tiny 会指向堆中的一篇内存,tinyOffset 是下一个空闲内存所在的偏移量,最后的 local_tinyallocs 会记录内存分配器中分配的对象个数
mcentral 中心缓存
- mcentral中央缓存,每一个mcentral只负责管理一种spanClass类型的mspan,并且管理的单元就是mspan,如果mcache中缓存的某种spanClass类型的Span没有空闲的了,就会向对应spanClass类型的mcentral申请
- 每一个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.mSpanList,分别存储包含空闲对象的列表和不包含空闲对象的链表:
- 查看mcentral的数据结构,重点关注,内部的几个链表属性
- allocCache 链表:用于缓存当前可用的内存块,它是 mcentral 中的一个重要优化点,可以在内存分配请求到来时快速分配内存,并减少系统调用的频率。当程序第一次申请某个尺寸类的内存块时,会先尝试从 allocCache 中取出一个内存块,如果有则直接返回;如果没有则需要进行以下三个步骤:在 nonempty、partial 和 empty 链表中查找可用的内存块,如果找到则将其移到 allocCache 链表末尾并返回,否则需要执行第二步和第三步。
- nonempty 链表:用于保存正在被使用的、不需要回收的内存块,也就是说,这些内存块中至少还有一个空闲对象。当程序释放某个尺寸类的内存块时,会将其插入到 nonempty 链表中,以便后续的内存分配请求可以快速获取可用的内存块。
- partial 链表:该链表用于保存已经使用过一部分空闲对象的内存块。如果在 nonempty 链表中无法找到满足要求的内存块,则会继续在 partial 链表中查找,如果找到了满足要求的内存块,则需要先进行一些处理(如把一部分空闲对象移到 allocCache 中),然后再将其加入到 nonempty 链表中。
- empty 链表:该链表用于保存已经被回收但尚未被清理的内存块。当程序释放某个尺寸类的内存块时,会将其加入到 empty 链表中,等待 sweep 函数来回收并清理它。如果在 nonempty 和 partial 链表中均未找到满足要求的内存块,则需要从 empty 链表中选择一个尺寸类最接近申请大小的内存块,进行清理和复用。
//Go 1.16 版本(其它内存组件基本没有大变动)
type mcentral struct {
lock mutex // 互斥锁,用于保护链表操作的原子性。
sizeclass int8 // 尺寸类编号,表示该 mcentral 管理的内存块大小(以字节为单位)。
nonempty mSpanList // 非空闲的内存块链表,其中至少有一个空闲对象。
empty mSpanList // 空闲的、已经清理过的内存块链表。
partial mSpanList // 部分空闲的内存块链表,其中至少有一个空闲对象,但未满足申请条件。
allocCache uintptr // 用于缓存可用的内存块,提高内存分配效率。
}
//1.17版本
type mcentral struct {
// 用于保护对 span 列表访问的锁。
lock mutex
// 此 mcentral 对应的 span 类别。
spanclass spanClass
// 非空闲的 span 集合。
nonempty mSpanList
// 空闲的 span 集合。
empty mSpanList
// mcentral 缓存的统计数据。
nmalloc uint64
nfree uint64
// nlargefree 是上次检查后大尺寸的自由列表上的对象数目。
nlargefree uint64
// 自由的大尺寸对象的列表。
largefree *[numLarge]mspan
// 第一次需要时创建这些结构。
// 必须在堆中为特殊 span 保留的空间。
specials *specialpools
// partial[i] 包含长度等于 2^i 但同时存在于 free 和 non-free 链表中的非空 span 集合。
partial [numSpanClasses]spanSet
// full[i] 包含长度等于 2^i 的空 span 集合。
full [numSpanClasses]spanSet
}
- 该结构体在初始化时,两个链表都不包含任何内存,程序运行时会扩容结构体持有的两个链表,nmalloc 字段也记录了该结构体中分配的对象个数
1. mcache向 mcentral申请内存
- mcache某个spanClass类型的Span不足时向mcentral申请内存底层会执行mcentral的cacheSpan()方法,查看源码(1.14版本)
func (c *mcentral) cacheSpan() *mspan {
// 部分代码省略
lock(&c.lock)
retry:
var s *mspan
// 前两步都在这个循环中
for s = c.nonempty.first; s != nil; s = s.next {
//2. 从未被清理过的、有空闲对象的 (nonempty或partial)结构中查找可以使用的内存管理单元;
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
c.nonempty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
s.sweep(true)
goto havespan
}
if s.sweepgen == sg-1 { //正在回收
// 当前mspan正在清理,继续下一轮
continue
}
//1. 从清理过的、包含空闲空间的列表的(nonempty或partial)结构中中查找可以使用的内存管理单元
c.nonempty.remove(s) //已经回收
c.empty.insertBack(s)
unlock(&c.lock)
goto havespan
}
//3. 获取未被清理的、不包含空闲空间的(empty或full)结构中获取内存管理单元并通过sweep()函数清理它的内存空间;
for s = c.empty.first; s != nil; s = s.next {
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
c.empty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
s.sweep(true)
// 清理过后获取了空间,直接跳到分配
freeIndex := s.nextFreeIndex()
if freeIndex != s.nelems {
s.freeindex = freeIndex
goto havespan
}
lock(&c.lock)
goto retry //不包含空闲对象
}
if s.sweepgen == sg-1 {
continue
}
break
}
unlock(&c.lock)
// 省略部分代码
// 调用runtime.mcentral.grow 从堆中申请新的内存管理单元;
s = c.grow()
if s == nil {
return nil
}
lock(&c.lock)
c.empty.insertBack(s)
unlock(&c.lock)
havespan:
//省略部分代码
//调整已分配mspan的个数
atomic.Xadd64(&c.nmalloc, int64(n))
usedBytes := uintptr(s.allocCount) * s.elemsize
atomic.Xadd64(&memstats.heap_live, int64(spanBytes)-int64(usedBytes))
//调整allocBits和 allocCache等字段,让运行时在分配内存时能够快速找到空闲的对象
freeByteBase := s.freeindex &^ (64 - 1)
whichByte := freeByteBase / 8
s.refillAllocCache(whichByte)
s.allocCache >>= s.freeindex % 64
return s
}
- 不同版本结构有所变化,但是mcache向 mcentral申请空间的方法mheap_.central[spc].mcentral.cacheSpan()的流程基本没变,主要分为以下几个部分
- 首先从mcentral 中的 allocCache 中获取一个非空闲的 span,返回
- 如果没有则通过mcentral 中未清理过的 nonempty 非空闲链表或 partial部分空闲的内存块链表中查找可以使用的内存管理单元,因为这些 span 中包含了一定数量的空闲对象
- 如果还没有找到,需要扫描 nonempty 链表或 partial 链表,将其中不包含任何对象的空闲 span ,从它们所在的链表移动到 empty链表中,以减少后续扫描的时间(这一步是异步的?)
- 如果仍为找到,则通过 empty 列表中查找一个大小合适的 span,通过执行 sweep()对其中的对象进行标记、回收等清理它的内存空间
- 如果仍未找到合适的 span,则调用 runtime.mcentral.grow 函数从堆中申请新的内存管理单元;
- 最后更新内存管理单元的 allocCache 等字段帮助快速分配内存
- 注意在中心缓存的非空链表中查找可用的mspan时,会根据 sweepgen 字段分别进行不同的处理:
- 当内存单元等待回收时,将其插入 empty 链表、调用 runtime.mspan.sweep 清理该单元并返回;
- 当内存单元正在被后台回收时,跳过该内存单元;
- 当内存单元已经被回收时,将内存单元插入 empty 链表并返回
- 如果中心缓存没有可用内存时,会判断是否需要回收的,会触发 runtime.mspan.sweep 进行清理,如果清理后的内存单元仍然不包含空闲对象,就会重新执行相应的代码,也就是上方代码第3步骤的for循环
- 会调用 runtime.mcentral.grow 触发扩容操作从堆中申请新的内存
- 无论通过哪种方法获取到了内存单元,该方法的最后都会对内存单元的 allocBits 和 allocCache 等字段进行更新,让运行时在分配内存时能够快速找到空闲的对象
2. mcentral的扩容
- 如果 runtime.mcentral 在nonempty 和 partial 链表中都没有找到可用的内存单元,会调用 runtime.mcentral.grow 触发扩容操作从堆中申请新的内存
- mcentral.grow()扩容时会根据预先计算的 class_to_allocnpages 和 class_to_size 获取待分配的页数以及跨度类并调用 runtime.mheap.alloc 获取新的 runtime.mspan 结构
func (c *mcentral) grow() *mspan {
// 通过spanClass获取所需要的Page页数,以及大小.
npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
size := uintptr(class_to_size[c.spanclass.sizeclass()])
// 向mheap申请对应页数的mspan下来
s := mheap_.alloc(npages, c.spanclass, true)
if s == nil {
return nil
}
// 更新mspan中的一些字段,以及初始化mspan
// n := (npages << _PageShift) / size
n := (npages << _PageShift) >> s.divShift * uintptr(s.divMul) >> s.divShift2
s.limit = s.base() + size*n
heapBitsForAddr(s.base()).initSpan(s)
return s
}
mheap 页堆
- mheap作为一个全局变量管理整个申请到的堆内存,持有了134个mcentral,mheap在内存分配篇中比较重点的部分就是一个heapArena的二维数组,以及持有的134个mcentral,Go 语言程序只会存在一个全局的结构
1. mheap 初始化
- 堆区的初始化会使用 runtime.mheap.init 方法
- spanalloc、cachealloc 以及 arenaHintAlloc 等 runtime.fixalloc 类型的空闲链表分配器;
- central 切片中 runtime.mcentral 类型的中心缓存
func (h *mheap) init() {
//初始化分配器
h.spanalloc.init(unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys)
h.cachealloc.init(unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys)
h.specialfinalizeralloc.init(unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys)
h.specialprofilealloc.init(unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys)
h.arenaHintAlloc.init(unsafe.Sizeof(arenaHint{}), nil, nil, &memstats.other_sys)
h.spanalloc.zero = false
//初始化mcentral中心缓存
for i := range h.central {
h.central[i].mcentral.init(spanClass(i))
}
h.pages.init(&h.lock, &memstats.gc_sys)
}
2. 申请内存
- mcentral内存不足时执行扩容方法,最终会通过mheap申请内存,底层会执行alloc(),在该方法中:
- 为了阻止内存的大量占用和堆的增长,在分配对应页数的内存前先调用 runtime.mheap.reclaim 方法回收一部分内存,
- 然后通过 runtime.mheap.allocSpan 分配新的内存管理单元
func (h *mheap) alloc(npages uintptr, spanclass spanClass, needzero bool) *mspan {
var s *mspan
systemstack(func() {
//为了阻止堆内存的大量增长,在分配对应页数的内存之前,会调用reclaim方法来回收一部分内存.
if h.sweepdone == 0 {
h.reclaim(npages)
}
//在分配内存以后,会先初始化mspan然后返回
//如果内存不够了,会进行扩容,即通过sysAlloc方法向操作系统申请内存,此处不展开细说.
s = h.allocSpan(npages, spanAllocHeap, spanclass)
})
if s != nil {
if needzero && s.needzero != 0 {
memclrNoHeapPointers(unsafe.Pointer(s.base()), s.npages<<_PageShift)
}
s.needzero = 0
}
return s
}
- 继续查看allocSpan()有 runtime.pageCache处理器的页缓存和runtime.pageAlloc全局的页分配器 两种途径从堆中申请内存(不同版本这里变化好像挺大的):
- 如果申请的内存比较小,获取申请内存的处理器并尝试调用 runtime.pageCache.alloc 获取内存区域的基地址和大小;
- 如果申请的内存比较大或者线程的页缓存中内存不足,会通过 runtime.pageAlloc.alloc 在页堆上申请内存;
- 如果发现页堆上的内存不足,会尝试通过 runtime.mheap.grow 进行扩容并重新调用 runtime.pageAlloc.alloc 申请内存;
func (h *mheap) allocSpan(npages uintptr, manual bool, spanclass spanClass, sysStat *uint64) (s *mspan) {
gp := getg()
base, scav := uintptr(0), uintptr(0)
pp := gp.m.p.ptr()
if pp != nil && npages < pageCachePages/4 {
c := &pp.pcache
base, scav = c.alloc(npages)
if base != 0 {
s = h.tryAllocMSpan()
if s != nil && gcBlackenEnabled == 0 && (manual || spanclass.sizeclass() != 0) {
goto HaveSpan
}
}
}
if base == 0 {
base, scav = h.pages.alloc(npages)
if base == 0 {
h.grow(npages) base, scav = h.pages.alloc(npages)
if base == 0 {
throw("grew heap, but no adequate free space found")
}
}
}
if s == nil {
s = h.allocMSpanLocked()
}
...
}
- 如果申请到内存标识成功,否则意味着扩容失败,宿主机可能不存在空闲内存,会直接中止当前程序;
- 无论通过哪种方式获得内存页,我们都会在该函数中分配新的 runtime.mspan 结构体;该方法的剩余部分会通过页数、内存空间以及跨度类等参数初始化它的多个字段:
func (h *mheap) alloc(npages uintptr, spanclass spanClass, needzero bool) *mspan {
...
HaveSpan:
s.init(base, npages)
...
s.freeindex = 0
s.allocCache = ^uint64(0)
s.gcmarkBits = newMarkBits(s.nelems)
s.allocBits = newAllocBits(s.nelems)
h.setSpans(s.base(), npages, s)
return s
}
3. 扩容
- runtime.mheap.grow 方法会向操作系统申请更多的内存空间,传入的页数经过对齐可以得到期望的内存大小,我们可以将该方法的执行过程分成以下几个部分:
- 通过传入的页数获取期望分配的内存空间大小以及内存的基地址;
- 如果 arena 区域没有足够的空间,调用 runtime.mheap.sysAlloc 从操作系统中申请更多的内存;
- 扩容 runtime.mheap 持有的 arena 区域并更新页分配器的元信息;
- 在某些场景下,调用 runtime.pageAlloc.scavenge 回收不再使用的空闲内存页
- 在页堆扩容的过程中,runtime.mheap.sysAlloc 是页堆用来申请虚拟内存的方法,我们会分几部分介绍该方法的实现。首先,该方法会尝试在预保留的区域申请内存:
- 先执行alloc()在预先保留的内存中申请一块可以使用的空间
- 如果没有可用的空间,会根据页堆的 arenaHints 在目标地址上尝试扩容,
- 会执行sysReserve() 与sysMap()从操作系统中申请内存并将内存转换至 Prepared 状态
- sysAlloc 方法在最后会初始化一个新的 runtime.heapArena 结构体来管理刚刚申请的内存空间,该结构体会被加入页堆的二维矩阵中
func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
n = alignUp(n, heapArenaBytes)
//1.在预先保留的内存中申请一块可以使用的空间
//如果没有可用的空间,会根据页堆的 arenaHints 在目标地址上尝试扩容
v = h.arena.alloc(n, heapArenaBytes, &memstats.heap_sys)
if v != nil {
size = n
goto mapped
}
......
// 2.判断arenaHints,在目标地址上尝试扩容
for h.arenaHints != nil {
hint := h.arenaHints
p := hint.addr
v = sysReserve(unsafe.Pointer(p), n)
if p == uintptr(v) {
hint.addr = p
size = n
break
}
h.arenaHints = hint.next
h.arenaHintAlloc.free(unsafe.Pointer(hint))
}
...
//重点
sysMap(v, size, &memstats.heap_sys)
......
mapped:
//重点
for ri := arenaIndex(uintptr(v)); ri <= arenaIndex(uintptr(v)+size-1); ri++ {
l2 := h.arenas[ri.l1()]
r := (*heapArena)(h.heapArenaAlloc.alloc(unsafe.Sizeof(*r), sys.PtrSize, &memstats.gc_sys))
...
h.allArenas = h.allArenas[:len(h.allArenas)+1]
h.allArenas[len(h.allArenas)-1] = ri
atomic.StorepNoWB(unsafe.Pointer(&l2[ri.l2()]), unsafe.Pointer(r))
}
return
}
四. golang 内存分配之 分配流程详解
- 上面我们知道了golang中内存分配相关组件,接下来该学习实际的内存分配流程了
- 前面说过Go语言以内存大小将内存分为了三类:
- 微对象 (0, 16B) : 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;
- 小对象 [16B, 32KB]: 依次尝试使用线程缓存、中心缓存和堆分配内存;
- 大对象 (32KB, +∞): 直接在堆上分配内存;
- 堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数, 在mallocgc()函数中也分为对应微, 小, 大的三部分
在mallocgc()函数中会判断当前对象size, 如果大于maxSmallSize,走大对象分配逻辑
如果size小于maxSmallSize,并且noscan 为true走微对象分配逻辑
如果小于等于maxSmallSize,但是noscan 为false走小对象分配逻辑
maxSmallSize默认16
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
}
- maxTinySize 是可以调整的,在默认情况下,内存块的大小为 16 字节。maxTinySize 的值越大,组合多个对象的可能性就越高,内存浪费也就越严重;maxTinySize 越小,内存浪费就会越少,不过无论如何调整,8 的倍数都是一个很好的选择
微对象分配
- Go 语言运行时将小于 16 字节的对象划分为微对象,为提高分配性能会使用线程缓存上的微分配器进行分配
- 微分配器在底层实现上对应mspan结构体上的三个字段,上面mspan中也有过示例
//指向分配微对象的首地址
tiny uintptr
//指向当前已经分配对象的尾地址,即下一个可以分配空间的首地址
tinyoffset uintptr
//记录微对象分配器中已分配对象的个数
local_tinyallocs uintptr
1. 微对象分配器特点
- 微分配器主要用来分配较小的字符串以及逃逸的临时变量。管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize 是可以调整的.在默认情况下,内存块的大小为 16 字节
- 微分配器可以将多个较小的内存分配请求合入同一个内存块中,微对象分配器中所有对象都标记为垃圾才会被整块回收,整片内存才可能被回收
2. 问题
- 问题: 如下图中,微对象分配器中已经被分配了12B的内存,现在仅剩下4B空闲, 如果此时有小于等于4B的对象需要被分配内存,那么这个对象会直接使用tinyoffset之后剩余的空间,如果需要被分配的对象大小大于4B那么会被算在小对象分配的过程中,不使用微对象分配器.要注意的是
2. 如果微对象分配器一开始没有被初始化,但是又有微对象需要被分配,就会走小对象分配的过程,但是申请到的空间会作为微对象分配器的空间,剩下的空间可以用于分配另外的微对象
3. 源码
- 判断,当size 小于 maxTinySize 时,并且noscan 为true,执行微对象分配逻辑
- 如果当前块中还包含大小合适的空闲内存,运行时会通过基地址和偏移量获取并返回这块内存
- 当内存块中不包含空闲的内存时,会先在线程缓存找到跨度类对应的内存管理单元 runtime.mspan,调用 runtime.nextFreeFast 获取空闲的内存;
- 当不存在空闲内存时,会调用 runtime.mcache.nextFree 从中心缓存或者页堆中获取可分配的内存块
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
}
...
}
- 获取新的空闲内存块之后,会清空空闲内存中的数据、更新构成微对象分配器的几个字段 tiny 和 tinyoffset 并返回新的空闲内存
小对象分配
- 小对象是指大小为 16 字节到 32,768 字节的对象以及所有小于 16 字节的指针类型的对象,小对象的分配可以被分成以下的三个步骤:
- 确定分配对象的大小以及跨度类 runtime.spanClass;
- 从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;
- 调用 runtime.memclrNoHeapPointers 清空空闲内存中的所有数据
- 查看mallocgc()中小对象分配逻辑,判断size小于等于maxTinySize,但是noscan 为false时,执行小对象分配
- 计算构建spanClass: 通过size_to_class8、size_to_class128 以及 class_to_size
- 执行nextFreeFast() 和 mcache.nextFree()这两个函数会获取空闲的内存空间并返回
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
//微对象分配
...
} else {
//小对象分配
var sizeclass uint8
// smallSizeMax = 1024
// size_to_class8与size_to_class128都是用于将内存向上对齐到spanClass类型的序号
if size <= smallSizeMax-8 {
sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
} else {
sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
}
// 通过对应的spanClass类型再反向获取内存大小
size = uintptr(class_to_size[sizeclass])
// 通过对应大小以及有无指针获取到spanClass
spc := makeSpanClass(sizeclass, noscan)
// 最后通过spanClass获取mcache中缓存的mspan
span := c.alloc[spc]
// 调用mcache中缓存的mspan获取内存.
v := nextFreeFast(span)
if v == 0 {
// 同样是获取mcache中的缓存,但是更加耗时
// 如果mcache中没获取到则获取mcentral中的mspan用于分配(调用refill方法)
// 如果mcentral也没有则去找mheap.
v, span, _ = c.nextFree(spc)
}
// 转为指针返回
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
// 清空空闲内存中的所有数据;
memclrNoHeapPointers(unsafe.Pointer(v), size)
}
}
} else {
// 大对象分配
...
}
...
return x
}
- 不管是在微对象分配的过程中,还是在小对象分配的过程中,都会执行两个函数,nextFreeFast()与nextFree(),接下来我们看一下这两个函数
1. nextFreeFast() 在mcache线程缓存中的span中寻找空间
- nextFreeFast(),会先在mcache线程缓存中的span中寻找空间,如果没有找到则返回0,
runtime.nextFreeFast 会利用内存管理单元中的 allocCache 字段,快速找到该字段中位 1 的位数,我们在上面介绍过 1 表示该位对应的内存空间是空闲的, 如果没有返回0
func nextFreeFast(s *mspan) gclinkptr {
// allocCache前文中提到,是通过每一位对应到span中的内存
// Ctz64用于判断第几位开始不是0,从低位(右边)开始,如果是64,则所有都是0.
theBit := sys.Ctz64(s.allocCache)
if theBit < 64 {
// 在从右往左找到一个空闲的单元(spanClass大小)时,会与freeindex相加,如果超出了就是没有空间了
// 如果找到的结果比mspan能存储的最大对象少,即还有空闲内存
// 这块的freeindex不一定是第一个空闲的空间,可以前面还有被回收的内存.
result := s.freeindex + uintptr(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
// 找到了空闲的对象后更新内存管理单元的allocCache、freeindex等字段并返回该片内存
s.allocCache >>= uint(theBit + 1)
// 这里的freeindex是在不断后移的
s.freeindex = freeidx
s.allocCount++
return gclinkptr(result*s.elemsize + s.base())
}
}
return 0
}
- 返回0,即没有找到空闲空间以后,会调用nextFree()
2. nextFree() 获取新的内存管理单元
- 该方法中重点是调用refill()去mcentral中心缓存中的内存管理单元替换已经不存在可用对象的结构体,该方法会调用新结构体的 runtime.mspan.nextFreeIndex 获取空闲的内存并返回
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
s = c.alloc[spc]
shouldhelpgc = false
// 获取mspan中下一个空闲对象.
// 这里获取到的freeindex就是准确的第一个空闲空间,并且在nextFreeIndex中会更新值.
freeIndex := s.nextFreeIndex()
// 如果获取到的freeindex等于nelems,则这个mspan已经满了,需要去mcentral中获取空间.
if freeIndex == s.nelems {
// The span is full.
if uintptr(s.allocCount) != s.nelems {
println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount != s.nelems && freeIndex == s.nelems")
}
c.refill(spc)
shouldhelpgc = true
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
if freeIndex >= s.nelems {
throw("freeIndex is not valid")
}
// 获取指针并且返回
v = gclinkptr(freeIndex*s.elemsize + s.base())
// 更新mspan的状态
s.allocCount++
if uintptr(s.allocCount) > s.nelems {
println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount > s.nelems")
}
return
}
大对象分配
- 运行时对于大于 32KB 的大对象会单独处理,不会从线程缓存或者中心缓存中获取内存管理单元,而是直接调用 allocLarge 分配大片内存
- 查看mallocgc()函数中大对象分配逻辑,会调用largeAlloc()分配大片的内存
...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
//微对象分配
...
} else {
//小对象分配
...
}
} else {
// 大对象分配
var s *mspan
systemstack(func() {
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
}
- 在allocLarge()中会调用mheap直接进行分配,并生成一个宽度为0的spanClass,并执行alloc()分配一个管理对应内存的管理单元返回
func largeAlloc(size uintptr, needzero bool, noscan bool) *mspan {
if size+_PageSize < size {
throw("out of memory")
}
//通过大小获取需要分配的Page数量
npages := size >> _PageShift
if size&_PageMask != 0 {
npages++
}
deductSweepCredit(npages*_PageSize, npages)
//直接调用mheap进行分配.创建一个跨度类为 0 的 spanClass
spc := makeSpanClass(0, noscan)
//分配一个管理对应内存的管理单元
s := mheap_.alloc(npages, spc, needzero)
if s == nil {
throw("out of memory")
}
if go115NewMCentralImpl {
mheap_.central[spc].mcentral.fullSwept(mheap_.sweepgen).push(s)
}
s.limit = s.base() + size
heapBitsForAddr(s.base()).initSpan(s)
return s
}
五. 总结
- 参考博客
- 了解Go内存分配前,先了解一下几种内存分配方式
- 通过分配方式引出Go中根据对象大小的分级分配与,内存管理组件
常见的内存分配方式总结
1.了解内存分配,首先要了解内存分配方式,通常有:线性分配,空闲链表分配,按级别分配三种
2. 线性分配: 维护一个指针指向空闲内存区域,移动指针到空闲区域分配空闲内存即可,实现复杂度低,执行速度快, 如果已经分配的内存被回收,线性分配器是无法重新使用,容易造成内存碎片,所以需要配合合适的垃圾收集算法,例如标记压缩, 复制,分代收集等可以处理内存碎片的一块使用
3. 空闲链表分配: 维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存然后申请新的资源并修改链表,进而做到可以重用已经被释放的内存,虽然实现了内存回收后重复利用,但是再次分配时获取空闲内存块,需要遍历链表,时间复杂度就是 O(n),进而提出来几种内存选择策略
- 首次适应First-Fit: 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应Next-Fit: 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应Best-Fit: 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应Segregated-Fit: 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
- 分级分配: 使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略:小于16字节的是微对象,大于16字节小于32k的是小对象,大于32k的是大对象
Go 内存组件的划分总结
- Go中将对象按照大小分成了微,小,大三个级别,还对整个内存进行了划分引入了: 线程缓存Thread Cache,中心缓存Central Cache,和页堆Page Heap的概念用来分级管理内存
- mcache 线程缓存: Go 程序启动时初始化内存布局,每一个处理器都会被分配一个线程缓存runtime.mcache,用于处理微对象以及小对象的内存分配
- mcentral 中心缓存: 当mcache线程缓存空间不足时,会通过mcentral申请
- mheap 页堆: 管理整个申请到的堆内存,持有134个mcentral,当mcentral内存不足时执行扩容方法,最终会通过mheap申请内存
- 如果再去细分Go中内存结构由:
- spanClass: 对象的映射,根据对象的大小go中提供了68种spanClass,不同大小的对象在需要mcache分配内存时,会通过映射对应到不同的spanClass上, spanClass也可以看成一个8位的数据,前七位用于存储当前spanClass属于67种的哪一种,最后一位代表当前spanClass(当前对象)是否存储了指针,这个非常重要,因为是否存在指针意味着是否需要在垃圾回收的时候进行扫描
- mspan: Go 语言内存管理的基本单元,用来管理 span 内存块的数据结构,spanClass也可以看为mspan中的一个重要属性,查看mspan结构体内部存在next 和 prev 两个字段,最终形成一个双向链表,并且存在state 状态字段有mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 四种情况,用来跟踪哪些内存已经被分配,哪些内存可以被释放,防止两个不同的goroutine同时分配同一块内存空间
- mcache: 根据GMP模型,一个g必须与p绑定才会运行,每个p都有一个对应的线程缓存也就是mcache,mcache中的缓存对象数组一共有67 * 2个,其中*2是将spanClass分成了有指针和没有指针两种,用于存储分配小对象的缓存内存,当一个goroutine需要分配一个小对象时,它首先检查是否有空闲的mcache,有则从cache中分配内存,没有则通过mcentral申请
- mcentral: 中央缓存,每一个mcentral只负责管理一种spanClass类型的mspan,在mcentral中存在nonempty 存储空闲的span列表,与empty已经被使用的span列表,当mcache内存不足时会通过mcentral申请,会执行mcentral的cacheSpan()方法
- mheap: 管理整个申请到的堆内存,持有了134个mcentral, 就是堆空间,内部包含了多个mspan,程序启动时mheap会初始化一部分堆内存,称为“初始堆大小”,当一个Go程序进行内存分配时,会先从mcache中申请已经被缓存的对象,然后再从mcentral中获取未被缓存的对象,如果mcentral空间不足,会从mheap中申请一段物理内存,将对象放到申请得到的mspan上,再将此mspan挂载到mcache或者mcentral上
1. mcentral底层结构与mcache通过mcentral的内存申请
- 查看mcentral底层结构,重点关注,allocCache,nonempty,partial,empty 这几个链表:
- allocCache 链表:用于缓存当前可用的内存块,它是 mcentral 中的一个重要优化点,可以在内存分配请求到来时快速分配内存,并减少系统调用的频率。当程序第一次申请某个尺寸类的内存块时,会先尝试从 allocCache 中取出一个内存块,如果有则直接返回;如果没有则需要进行以下三个步骤:在 nonempty、partial 和 empty 链表中查找可用的内存块,如果找到则将其移到 allocCache 链表末尾并返回,否则需要执行第二步和第三步。
- nonempty 链表:用于保存正在被使用的、不需要回收的内存块,也就是说,这些内存块中至少还有一个空闲对象。当程序释放某个尺寸类的内存块时,会将其插入到 nonempty 链表中,以便后续的内存分配请求可以快速获取可用的内存块。
- partial 链表:该链表用于保存已经使用过一部分空闲对象的内存块。如果在 nonempty 链表中无法找到满足要求的内存块,则会继续在 partial 链表中查找,如果找到了满足要求的内存块,则需要先进行一些处理(如把一部分空闲对象移到 allocCache 中),然后再将其加入到 nonempty 链表中。
- empty 链表:该链表用于保存已经被回收但尚未被清理的内存块。当程序释放某个尺寸类的内存块时,会将其加入到 empty 链表中,等待 sweep 函数来回收并清理它。如果在 nonempty 和 partial 链表中均未找到满足要求的内存块,则需要从 empty 链表中选择一个尺寸类最接近申请大小的内存块,进行清理和复用
- mcache某个spanClass类型的Span不足时向mcentral申请内存底层会执行mcentral的cacheSpan()方法,主要分为以下几个部分
- 首先从mcentral 中的 allocCache 中获取一个非空闲的 span,返回
- 如果没有则通过mcentral 中未清理过的 nonempty 非空闲链表或 partial部分空闲的内存块链表中查找可以使用的内存管理单元,因为这些 span 中包含了一定数量的空闲对象
- 如果还没有找到,需要扫描 nonempty 链表或 partial 链表,将其中不包含任何对象的空闲 span ,从它们所在的链表移动到 empty链表中,以减少后续扫描的时间(这一步是异步的?)
- 如果仍为找到,则通过 empty 列表中查找一个大小合适的 span,通过执行 sweep()对其中的对象进行标记、回收等清理它的内存空间
- 如果仍未找到合适的 span,则调用 runtime.mcentral.grow 函数从堆中申请新的内存管理单元;
- 最后更新内存管理单元的 allocCache 等字段帮助快速分配内存
- 注意在中心缓存的非空链表中查找可用的mspan时,会根据 sweepgen 字段分别进行不同的处理:
- 当内存单元等待回收时,将其插入 empty 链表、调用 runtime.mspan.sweep 清理该单元并返回;
- 当内存单元正在被后台回收时,跳过该内存单元;
- 当内存单元已经被回收时,将内存单元插入 empty 链表并返回
2. mcentral 的扩容
- 如果 mcentral 在nonempty 和 partial 链表中都没有找到可用的内存单元,会调用 runtime.mcentral.grow 触发扩容操作从堆中申请新的内存,该函数执行扩容时会根据预先计算的 class_to_allocnpages 和 class_to_size 获取待分配的页数以及跨度类并调用 runtime.mheap.alloc 获取新的 runtime.mspan 结构
class_to_allocnpages 和 class_to_size是两个数组,class_to_size记录了每个 spanClass 中包含的对象大小,class_to_allocnpages记录了每个 spanClass 所需的页数,当需要分配新的 span 时,根据要分配对象的大小,选取相应的 span class,并根据 class_to_allocnpages 数组中存储的信息计算所需的页数。这种设计有助于提高 Go 内存分配器的性能,因为可以快速计算出所需的 span 大小,从而在内存池中查找可用的 span
3. mheap堆与堆的内存申请
- mheap作为一个全局变量管理整个申请到的堆内存,持有了134个mcentral这里的134对应了67个无指针的spanClass和67个有指针的spanClass,
- mcache在内存不足时会通过mcentral申请内存,mcentral内存不足时会执行.mcentral.grow 扩容方法进行扩容,最终会通过mheap申请内存,底层会执行alloc(),在该方法中:
- 为了阻止内存的大量占用和堆的增长,在分配对应页数的内存前先调用 runtime.mheap.reclaim 方法回收一部分内存,
- 然后通过 runtime.mheap.allocSpan 分配新的内存管理单元
- 查看allocSpan()有 runtime.pageCache处理器的页缓存和runtime.pageAlloc全局的页分配器 两种途径从堆中申请内存:
- 如果申请的内存比较小(小于等于32kb?),获取申请内存的处理器并尝试调用 runtime.pageCache.alloc 获取内存区域的基地址和大小;
- 如果申请的内存比较大(大于32kb?)或者线程的页缓存中内存不足,会通过 runtime.pageAlloc.alloc 在页堆上申请内存;
- 如果发现页堆上的内存不足,会尝试通过 runtime.mheap.grow 进行扩容并重新调用 runtime.pageAlloc.alloc 申请内存;
4. mheap堆的扩容
- 如果发现页堆上的内存不足,会尝试通过 runtime.mheap.grow 进行扩容,在扩容时会判断arena也就是mheap内存池中是否有足够的空闲空间,如果有直接返回,如果没有调用runtime.mheap.sysAlloc 从操作系统中申请更多的内存
- 然后扩容 runtime.mheap 持有的 arena 区域并更新页分配器的元信息
- 在某些场景下,调用 runtime.pageAlloc.scavenge 回收不再使用的空闲内存页
- mheap.sysAlloc 是页堆用来申请虚拟内存的方法
- 先执行alloc()在预先保留的内存中申请一块可以使用的空间
- 如果没有可用的空间,会根据页堆的 arenaHints 在目标地址上尝试扩容,
- 会执行sysReserve() 与sysMap()从操作系统中申请内存并将内存转换至 Prepared 状态
- sysAlloc 方法在最后会初始化一个新的 runtime.heapArena 结构体来管理刚刚申请的内存空间,该结构体会被加入页堆的二维矩阵中
创建对象时的内存分配
- Go中创建一个对象内存分配的大致过程为:
- 首先在Go中根据对象的大小和是否包含指针将对象分为三类:小于16字节且无指针tiny微对象、16字节到32KB且有指针的small小对象和大于32KB的large大对象
- 在给对象分配内存时会根据对象的类别,选择不同的内存分配器:微对象使用微对象分配器将多个对象合并到同一个内存块中;小对象使用FixAllOC分配器从mcache或mcentral中获取合适的mspan进行分配;大对象使用堆分配器直接从mheap中申请内存
- 最后根据对象是否包含指针,设置相应的bitmap和spanclass信息,用于GC时的扫描和标记。
- 堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数, 在mallocgc()函数中也分为对应微, 小, 大的三部分
- 在mallocgc()函数中会判断当前对象size, 如果大于maxSmallSize默认16,走大对象分配逻辑
- 如果size小于maxSmallSize,并且noscan 为true走微对象分配逻辑
- 如果小于等于maxSmallSize,但是noscan 为false走小对象分配逻辑
- 不管是在微对象分配的过程中,还是在小对象分配的过程中,都会执行两个函数,nextFreeFast()与nextFree()
- nextFreeFast(),会先在mcache线程缓存中的span中寻找空间,如果没有找到则返回0
- 返回0时会调用nextFree(), 该方法中重点是调用refill()去mcentral中心缓存中的内存管理单元替换已经不存在可用对象的结构体
- 大于 32KB 的大对象分配时会单独处理,不会从线程缓存或者中心缓存中获取内存管理单元,而是直接调用 allocLarge 分配大片内存,在allocLarge()中会调用mheap直接进行分配,并生成一个宽度为0的spanClass,并执行alloc()分配一个管理对应内存的管理单元返回