Golang之旅——内存管理
阅读原文时间:2023年08月13日阅读:1

一文带你了解,虚拟内存、内存分页、分段、段页式内存管理
[Golang三关-典藏版]一站式Golang内存洗髓经 | Go 技术论坛 刘丹冰Aceld
感谢以上文章作者,收获满满

分页存储管理基本原理

在逻辑空间中的块称为页面,物理空间中的称为物理块,或者页框和帧。

页与页框


分页系统的逻辑地址结构包括两部分,页号P和页内地址d(又称为页内偏移量)。
以32位计算机的逻辑地址为例,其地址结构如图所示
逻辑地址为32位,设页面大小为4KB,则12位。则12-31位则为页号,共20位。
这时候可以轻松计算得出,内存大小为212*220=232=4GB

页表

在上面我们可以看到,在地址映射的适合需要知道页面对应的物理块,系统为每个进程设置了一张页号到物理块号的映射表,称为页表。
页表的每个表项PTE(PAge Table Entry)由页号P和其对应的物理块号F组成,以页号为序建立。
注意,页表存储在内存中,只存储物理块号,页号不占用存储空间,然后将页表的起始地址及长度保存在进程的PCB中,当以后调度进程到CPU上执行时,再将PCB中保存的页表始地址及长度写入CPU的页表寄存器中。

地址映射与越界保护

我们设A为物理地址,F为页面对应的物理块号,L为页面的大小,w为页内的地址,在以上条件下,我们可以得出物理地址:
A=F*L + w

可以总结为以下步骤

  1. 当调度一个进程时,切换程序会将下一个运行进程的页表始地址及页表长度写入页表寄存器PTR中。
  2. 进行地址映射时,首先根据逻辑地址获取页号P和页内偏移量W
  3. 首先检查快表,快表是地址映射机构中一个具有并行查找能力的高速缓冲存储器,如果快表命中,则直接获取块号。若快表不命中,则仍然需要访问内存页表。
  4. 如果快表未命中,判断页号是否超过页表长度,如果超过,产生“地址越界”中断。
  5. 访问内存页表,获取块号,并且更新快表,将本次访问的页表项加入快表中。
  6. 通过块号和页内偏移量W计算物理地址A。

在此,你可以想一下,使用快表和不使用快表,分别访问了几次内存?(使用快表访问一次,不使用访问两次)

两级和多级页表


多级页表占用的空间很大,如果以该二级页表为例,映射4GB的地址空间,需要4KB+4MB,足足多了4MB。所以操作系统仅会将顶级页表存储在内存中。
多级页表的求址过程和单级也没有多少区别。

分段存储管理方式

分段

在分段存储中,一般分为以下几个段

  1. 代码段(Code Segment): 存储程序的指令,也称为可执行代码。这部分内存包含了程序的机器指令,用于实际执行程序的操作。
  2. 数据段(Data Segment): 存储已初始化的全局和静态变量。这些变量在程序开始运行时就已经被初始化并占用内存空间。
  3. 未初始化数据段(BSS Segment): 存储未初始化的全局和静态变量。这些变量在程序开始运行时会被初始化为默认值(通常为0),但实际的初始化操作会在运行时完成。
  4. 堆栈段(Stack Segment): 存储程序的执行堆栈。堆栈用于存储函数调用和局部变量等数据,以及管理函数的调用和返回。
  5. 堆段(Heap Segment): 存储动态分配的内存,用于程序在运行时动态申请和释放的内存。例如,使用 malloc() 或 new 函数分配的内存就位于堆段。

段表

  • 段内地址的位数可以决定段的大小
  • 逻辑地址=段号&段内地址(&号是连接符号,是将段号作为逻辑地址的最高位)


段式的求址过程和页式也差不太多,就不再介绍了。

段页式存储

用户程序先分段,每个段内部再分页(内部原理同基本的分页、分段相同)

段页式逻辑地址结构

段页式地址变换中要得到物理地址须经过三次内存访问:

  • 第一次访问段表,得到页表起始地址;
  • 第二次访问页表,得到物理页号;
  • 第三次将物理页号与页内位移组合,得到物理地址。

这部分内容我觉得原理差不多,目前就先不写了。
有很多流程图或结构图,以后有时间可以再补上。

虚拟内存

既然有虚拟,那肯定也有实际,所以有以下两个概念

  • 我们程序所使用的内存地址叫做虚拟内存地址Virtual Memory Address
  • 实际存在硬件里面的空间地址叫物理内存地址Physical Memory Address)。

虚拟内存是计算机操作系统中的一个概念,它允许程序使用比实际物理内存更大的内存空间。实际上,计算机上的物理内存容量通常是有限的,但是虚拟内存允许操作系统和应用程序似乎具有更大的内存空间。
虚拟存储系统在两方面有改进

  • 将进程装入的一次性或者整体性改为多次性
  • 将进程的驻留性改为置换性

程序运行时,如果要访问的页面(段)已装入内存,便可继续执行下去,如果它要访问的页面(段)尚未装入内存,则发生缺页中断,此时,系统将启动请求调页(段)功能,将作业所需的页(段)装入内存。如果此时内存已满,则会使用相应的页面置换算法来进行页面置换。
虚拟存储器的实现,必须建立在离散存储器管理方式的基础之上,因此可以有三种实现方式:请求分页,请求分段和请求段页式,当前主流的方法式请求分页方式,因此下面的内容将以请求分页存储管理方式的实现机制为例来说明。

页表

这里的页表和之前所提到的有不同,但其基本作用仍然是实现页号到物理块号的映射。

  1. 状态位P:表示页面是否在内存,若不在内存,则产生缺页中断。
  2. 访问字段A:记录页面被访问的情况,依据所采用的页面置换算法,可能是被访问次数或者未访问时间,供页面置换时参考。
  3. 修改位M:表示页面装入内存后是否有被修改过。
  4. 外存地址:记录页面在外存上的地址,通常是物理块号,供调入页面时参考。

缺页中断流程

页面置换策略

要知道固定分配局部置换、可变分配全部置换、可变分配局部置换的意思,首先需要知道以下几个概念:
1.** 固定分配:操作系统为每个进程分配一组固定数目大小的物理块。在程序运行过程中,不允许改变!即驻留集大小固定不变。 2.可变分配:先为每个进程分配一定大小的物理块,在程序运行过程中,可以动态改变物理块的大小。即,驻留集大小可变。 3.局部置换:进程发生缺页时,只能选择当前进程中的物理块进行置换。 4.全局置换**:可以将操作系统进程中保留的空闲物理块分配给缺页进程,还可以将别的进程持有的物理块置换到外存,再将这个物理块分配给缺页的进程。

固定分配局部置换

概念:系统为每个进程分配一定数量内存块(物理块),在整个运行期都不改变。若进程在运行过程中发生了缺页,则只能在本进程的内存页面中选出一个进行换出,然后再调用需要的页面。
缺点:很难确定一个进程到底应该分配多大的实际内存才合理

可变分配局部置换

概念:刚开始会为每个进程分配一定数量的物理块。当进程发生缺页时,只允许从当前进程的物理块中选出一个换出外存。如果当前进程在运行的时候频繁缺页,系统会为该进程动态增加一些物理块,直到该进程缺页率趋于适中程度;如果说一个进程在运行过程中缺页率很低或者不缺页,则可以适当减少该进程分配的物理块。通过这些操作可以保持多道程序的并发度较高。
缺点:使用可变分配局部置换时,每个进程会被分配一个可变大小的页面块,这可能会导致内存中出现许多不同大小的空闲块,从而增加内存碎片的问题。这些碎片可能会导致内存空间不连续,使得无法为较大的进程分配足够的内存。

可变分配全局置换

概念:系统为每个进程分配一定数量的内存块(物理块)。操作系统还会保持一个空闲物理块的队列。若某进程发生缺页,可以从空闲物理块中取出一块分配给该进程。如果空闲物理块没有了,那么会选择一个未锁定(不是那么重要)的页面换出到外存,再将物理块分配给缺页的进程。
缺点:在空闲物理块没有的情况下,如果将其他进程的页面调出到外存,那么这个进程就会拥有较小的驻留集,如此会导致该进程的缺页率上升
看到这,为什么没有固定分配全局置换呢?
如果理解上面的分配和置换,那应该很容易明白,这种情况下,固定和全局本身就是矛盾的,不可能在固定的情况下,还进行全局的置换,因为全局的置换会分配其他的空闲物理块。

页面调度时机

预调页策略:基于局部性原理,一次调入若干个相邻页面可能比一次调入一个页面更高效。
缺点:如果调入的若干页面是不会被马上访问的,那么这样效率又会很低。
请求调页策略:只有在进程处于运行期,且发生缺页的时候才被调入内存。
缺点:缺页时每次只会调入一页,每次从外存到内存的调入都会进行I/O操作,因此I/O开销较大。

页面置换算法

这个部分的内容暂且就先简略的介绍一下,因为感觉理论基本都很容易看明白。
OPT是最好的页面置换算法,所有的置换算法都是为了接近它的性能而进行优化。

  1. 最佳(OPT,Optimal): 这是一种理论上的置换策略,它总是选择未来最长时间内不会被访问的页面进行置换。虽然 OPT 算法可以获得最小的缺页次数,但是在实际情况下,无法预知未来的访问模式,因此很难实现。
  2. 先进先出(FIFO): 这是最简单的页面置换策略之一。它总是选择最早进入物理内存的页面进行置换。然而,FIFO 算法可能会导致 "Belady's Anomaly",即增加页面数时,缺页次数反而增加。
  3. 最近未使用(LRU,Least Recently Used): 这个策略选择最长时间未被使用的页面进行置换。具体来说,它会将最近最少被访问的页面换出。LRU 算法可以有效地减少缺页次数,但实现起来较为复杂,可能需要较大的开销来维护页面的访问历史。
  4. 最近最少使用(LFU,Least Frequently Used): 这个策略选择在一段时间内被访问次数最少的页面进行置换。LFU 算法考虑了页面的使用频率,但同样需要维护访问计数,可能增加开销。
  5. 时钟置换算法(Clock Replacement Algorithm):也称为"二次机会"(Second-Chance)页面置换算法,是一种用于虚拟内存管理的页面置换策略。它是基于近似最近未使用(LRU)算法的一种改进,旨在降低实现复杂度,同时在某种程度上模拟LRU的效果。

页面缓冲思想

页面缓冲思想(Page Buffering)是指在计算机系统中,通过缓存(缓冲)页面数据来优化数据访问和管理的策略。这种思想通常应用于磁盘和文件系统等存储系统,旨在减少数据读取和写入的延迟,提高数据访问速度和效率。
页面缓冲的核心思想是将最常用的数据页(或数据块)暂时存储在内存中,以便在需要时能够更快地访问。具体来说,当应用程序需要从磁盘读取数据时,系统首先检查缓冲区中是否已经存在所需的数据页。如果数据页已经在缓冲区中,那么可以直接从内存中读取,避免了慢速的磁盘访问。如果数据页不在缓冲区中,系统会将它从磁盘读取到缓冲区,并且可能还会替换掉缓冲区中的其他数据页。
页面缓冲的优点包括:

  1. 加速数据访问: 缓冲数据可以显著加快数据的读取速度,因为内存的访问速度比磁盘快得多。
  2. 降低磁盘访问频率: 缓冲数据可以减少对磁盘的频繁访问,从而减少磁盘的负载,延长磁盘寿命,并提高整体系统性能。
  3. 平滑访问流量: 缓冲数据可以平滑应用程序对存储系统的访问流量,避免突发性的大量磁盘访问,从而提高系统的稳定性。
  4. 缓解抖动: 页面缓冲可以减少抖动问题,即减少频繁的页面置换,从而避免系统性能下降。

然而,页面缓冲也可能带来一些挑战,如:

  1. 内存管理: 页面缓冲需要占用一部分内存空间,因此需要合理管理内存资源,避免内存不足导致性能下降。
  2. 缓冲命中率: 缓冲命中率指缓冲区中已缓存的数据在总的数据访问中的比例。缓冲命中率越高,性能提升越明显。但如果缓冲命中率很低,则可能带来额外的开销。
  3. 一致性: 缓冲数据与磁盘上的数据之间需要保持一致性,因此需要适当的缓冲管理机制,以确保数据的正确性。

TCMalloc(Thread Cache Malloc)。Golang 的内存管理就是基于 TCMalloc 的核心思想来构建的。本节将介绍 TCMalloc 的基础理念和结构。

基础数据结构

Page

TCMalloc 中的 Page 与之前章节介绍操作系统对虚拟内存管理的 MMU 定义的物理页有相似的定义,TCMalloc 将虚拟内存空间划分为多份同等大小的 Page,每个 Page 默认是 8KB。

将 Page 进行编号的好处是,可以根据任意内存的地址指针,进行固定算法偏移计算来算出所在的 Page。

Span

多个连续的 Page 称之为是一个 Span,其定义含义有操作系统的管理的页表相似,Page 和 Span 的关系如图所示。


TCMalloc 是以 Span 为单位向操作系统申请内存的。每个 Span 记录了第一个起始 Page 的编号 Start,和一共有多少个连续 Page 的数量 Length。

Size Class

在 256KB 以内的小对象,TCMalloc 会将这些小对象集合划分成多个内存刻度 [6],同属于一个刻度类别下的内存集合称之为属于一个 Size Class。这与之前章节自定义实现的内存池,将 Buf 划分多个刻度的 BufList 类似。
每个 Size Class 都对应一个大小比如 8 字节、16 字节、32 字节等。在申请小对象内存的时候,TCMalloc 会根据使用方申请的空间大小就近向上取最接近的一个 Size Class 的 Span(由多个等空间的 Page 组成)内存块返回给使用方。
(这图是不是有问题?我感觉应该是KB而不是B)

ThreadCache

在 TCMalloc 中每个线程都会有一份单独的缓存,就是 ThreadCache。ThreadCache 中对于每个 Size Class 都会有一个对应的 FreeList,FreeList 表示当前缓存中还有多少个空闲的内存可用,具体的结构布局如图 25 所示。


使用方对于从 TCMalloc 申请的小对象,会直接从 TreadCache 获取,实则是从 FreeList 中返回一个空闲的对象,如果对应的 Size Class 刻度下已经没有空闲的 Span 可以被获取了,则 ThreadCache 会从 CentralCache 中获取。当使用方使用完内存之后,归还也是直接归还给当前的 ThreadCache 中对应刻度下的的 FreeList 中。
整条申请和归还的流程是不需要加锁的,因为 ThreadCache 为当前线程独享,但如果 ThreadCache 不够用,需要从 CentralCache 申请内存时,这个动作是需要加锁的,因为CentralCache是一个全局的对象。不同 Thread 之间的 ThreadCache 是以双向链表的结构进行关联,是为了方便 TCMalloc 统计和管理。

CentralCache

CentralCache 是各个线程共用的,所以与 CentralCache 获取内存交互是需要加锁的。CentralCache 缓存的 Size Class 和 ThreadCache 的一样,这些缓存都被放在 CentralFreeList 中,当 ThreadCache 中的某个 Size Class 刻度下的缓存小对象不够用,就会向 CentralCache 对应的 Size Class 刻度的 CentralFreeList 获取,同样的如果 ThreadCache 有多余的缓存对象也会退还给响应的 CentralFreeList,流程和关系如图

CentralCache 与 PageHeap 的角色关系与 ThreadCache 与 CentralCache 的角色关系相似,当 CentralCache 出现 Span 不足时,会从 PageHeap 申请 Span,以及将不再使用的 Span 退还给 PageHeap。

PageHeap

PageHeap 是提供 CentralCache 的内存来源。PageHead 与 CentralCache 不同的是 CentralCache 是与 ThreadCache 布局一模一样的缓存,主要是起到针对 ThreadCache 的一层二级缓存作用,且只支持小对象内存分配。而 PageHeap 则是针对 CentralCache 的三级缓存。弥补对于中对象内存和大对象内存的分配,PageHeap 也是直接和操作系统虚拟内存衔接的一层缓存,当找不到 ThreadCache、CentralCache、PageHeap 都找不到合适的 Span,PageHeap 则会调用操作系统内存申请系统调用函数来从虚拟内存的堆区中取出内存填充到 PageHeap 当中,具体的结构如图 27 所示。

PageHeap 内部的 Span 管理,采用两种不同的方式,对于 128 个 Page 以内的 Span 申请,每个 Page 刻度都会用一个链表形式的缓存来存储。对于 128 个 Page 以上内存申请,PageHeap 是以有序集合(C++ 标准库 STL 中的 Std::Set 容器)来存放。

TCMalloc的小对象分配


小对象为占用内存小于等于 256KB 的内存,参考图中的流程,下面将介绍详细流程步骤:

(1)Thread 用户线程应用逻辑申请内存,当前 Thread 访问对应的 ThreadCache 获取内存,此过程不需要加锁。
(2)ThreadCache 的得到申请内存的 SizeClass(一般向上取整,大于等于申请的内存大小),通过 SizeClass 索引去请求自身对应的 FreeList。
(3)判断得到的 FreeList 是否为非空。
(4)如果 FreeList 非空,则表示目前有对应内存空间供 Thread 使用,得到 FreeList 第一个空闲 Span 返回给 Thread 用户逻辑,流程结束。
(5)如果 FreeList 为空,则表示目前没有对应 SizeClass 的空闲 Span 可使用,请求 CentralCache 并告知 CentralCache 具体的 SizeClass。
(6)CentralCache 收到请求后,加锁访问 CentralFreeList,根据 SizeClass 进行索引找到对应的 CentralFreeList。
(7)判断得到的 CentralFreeList 是否为非空。
(8)如果 CentralFreeList 非空,则表示目前有空闲的 Span 可使用。返回多个 Span,将这些 Span(除了第一个 Span)放置 ThreadCache 的 FreeList 中,并且将第一个 Span 返回给 Thread 用户逻辑,流程结束。
(9)如果 CentralFreeList 为空,则表示目前没有可用是 Span 可使用,向 PageHeap 申请对应大小的 Span。
(10)PageHeap 得到 CentralCache 的申请,加锁请求对应的 Page 刻度的 Span 链表。
(11)PageHeap 将得到的 Span 根据本次流程请求的 SizeClass 大小为刻度进行拆分,分成 N 份 SizeClass 大小的 Span 返回给 CentralCache,如果有多余的 Span 则放回 PageHeap 对应 Page 的 Span 链表中。
(12)CentralCache 得到对应的 N 个 Span,添加至 CentralFreeList 中,跳转至第(8)步。
综上是 TCMalloc 一次申请小对象的全部详细流程,接下来分析中对象的分配流程。

TCMalloc的中对象分配

中对象为大于 256KB 且小于等于 1MB 的内存。对于中对象申请分配的流程 TCMalloc 与处理小对象分配有一定的区别。对于中对象分配,Thread 不再按照小对象的流程路径向 ThreadCache 获取,而是直接从 PageHeap 获取

PageHeap 将 128 个 Page 以内大小的 Span 定义为小 Span,将 128 个 Page 以上大小的 Span 定义为大 Span。由于一个 Page 为 8KB,那么 128 个 Page 即为 1MB,所以对于中对象的申请,PageHeap 均是按照小 Span 的申请流程,具体如下:
(1)Thread 用户逻辑层提交内存申请处理,如果本次申请内存超过 256KB 但不超过 1MB 则属于中对象申请。TCMalloc 将直接向 PageHeap 发起申请 Span 请求。
(2)PageHeap 接收到申请后需要判断本次申请是否属于小 Span(128 个 Page 以内),如果是,则走小 Span,即中对象申请流程,如果不是,则进入大对象申请流程。
(3)PageHeap 根据申请的 Span 在小 Span 的链表中向上取整,得到最适应的第 K 个 Page 刻度的 Span 链表。
(4)得到第 K 个 Page 链表刻度后,将 K 作为起始点,向下遍历找到第一个非空链表,直至 128 个 Page 刻度位置,找到则停止,将停止处的非空 Span 链表作为提供此次返回的内存 Span,将链表中的第一个 Span 取出。如果找不到非空链表,则当错本次申请为大 Span 申请,则进入大对象申请流程。
(5)假设本次获取到的 Span 由 N 个 Page 组成。PageHeap 将 N 个 Page 的 Span 拆分成两个 Span,其中一个为 K 个 Page 组成的 Span,作为本次内存申请的返回,给到 Thread,另一个为 N-K 个 Page 组成的 Span,重新插入到 N-K 个 Page 对应的 Span 链表中。
综上是 TCMalloc 对于中对象分配的详细流程。

TCMalloc的大对象分配

对于超过 128 个 Page(即 1MB)的内存分配则为大对象分配流程。大对象分配与中对象分配情况类似,Thread 绕过 ThreadCache 和 CentralCache,直接向 PageHeap 获取。

进入大对象分配流程除了申请的 Span 大于 128 个 Page 之外,对于中对象分配如果找不到非空链表也会进入大对象分配流程,大对象分配的具体流程如下:
(1)Thread 用户逻辑层提交内存申请处理,如果本次申请内存超过 1MB 则属于大对象申请。TCMalloc 将直接向 PageHeap 发起申请 Span 。
(2)PageHeap 接收到申请后需要判断本次申请是否属于小 Span(128 个 Page 以内),如果是,则走小 Span 中对象申请流程(上一节已介绍),如果不是,则进入大对象申请流程。
(3)PageHeap 根据 Span 的大小按照 Page 单元进行除法运算,向上取整,得到最接近 Span 的且大于 Span 的 Page 倍数 K,此时的 K 应该是大于 128。如果是从中对象流程分过来的(中对象申请流程可能没有非空链表提供 Span),则 K 值应该小于 128。
(4)搜索 Large Span Set 集合,找到不小于 K 个 Page 的最小 Span(N 个 Page)。如果没有找到合适的 Span,则说明 PageHeap 已经无法满足需求,则向操作系统虚拟内存的堆空间申请一堆内存,将申请到的内存安置在 PageHeap 的内存结构中,重新执行(3)步骤。
(5)将从 Large Span Set 集合得到的 N 个 Page 组成的 Span 拆分成两个 Span,K 个 Page 的 Span 直接返回给 Thread 用户逻辑,N-K 个 Span 退还给 PageHeap。其中如果 N-K 大于 128 则退还到 Large Span Set 集合中,如果 N-K 小于 128,则退还到 Page 链表中。
综上是 TCMalloc 对于大对象分配的详细流程。


Golang 内存管理中依然保留 TCMalloc 中的 Page、Span、Size Class 等概念。

Page

与 TCMalloc 的 Page 一致。Golang 内存管理模型延续了 TCMalloc 的概念,一个 Page 的大小依然是 8KB。Page 表示 Golang 内存管理与虚拟内存交互内存的最小单元。操作系统虚拟内存对于 Golang 来说,依然是划分成等分的 N 个 Page 组成的一块大内存公共池。

mSpan

与 TCMalloc 中的 Span 一致。mSpan 概念依然延续 TCMalloc 中的 Span 概念,在 Golang 中将 Span 的名称改为 mSpan,依然表示一组连续的 Page。

Size Class 相关

Golang 内存管理针对 Size Class 对衡量内存的的概念又更加详细了很多,这里面介绍一些基础的有关内存大小的名词及算法。
(1)Object Size,是指协程应用逻辑一次向 Golang 内存申请的对象 Object 大小。Object 是 Golang 内存管理模块针对内存管理更加细化的内存管理单元。一个 Span 在初始化时会被分成多个 Object。比如 Object Size 是 8B(8 字节)大小的 Object,所属的 Span 大小是 8KB(8192 字节),那么这个 Span 就会被平均分割成 1024(8192/8=1024)个 Object。逻辑层向 Golang 内存模型取内存,实则是分配一个 Object 出去。为了更好的让读者理解,这里假设了几个数据来标识 Object Size 和 Span 的关系。

上图中的 Num Of Object 表示当前 Span 中一共存在多少个 Object。
注意 Page是Golang内存管理与操作系统交互衡量内存容量的基本单元Golang 内存管理内部本身用来给对象存储内存的基本单元是 Object。
(2)Size Class,Golang 内存管理中的 Size Class 与 TCMalloc 所表示的设计含义是一致的,都表示一块内存的所属规格或者刻度。Golang 内存管理中的 Size Class 是针对 Object Size 来划分内存的。也是划分 Object 大小的级别。比如 Object Size 在 1Byte8Byte 之间的 Object 属于 Size Class 1 级别,Object Size 在 8B16Byte 之间的属于 Size Class 2 级别。
(3)Span Class,这个是 Golang 内存管理额外定义的规格属性,是针对 Span 来进行划分的,是 Span 大小的级别。一个 Size Class 会对应两个 Span Class,其中一个 Span 为存放需要 GC 扫描的对象(包含指针的对象),另一个 Span 为存放不需要 GC 扫描的对象(不包含指针的对象)。

其实看着这个图表,也能猜出来一下对应公式。

需要 GC 扫描
Span Class = Size Class * 2 + 0
不需要 GC 扫描
Span Class = Size Class * 2 + 1

Size Class对应明细

/*
class: class ID,每个span结构中都有一个class ID, 表示该span可处理的对象类型
bytes/obj:该class代表对象的字节数
bytes/span:每个span占用堆的字节数,也即页数*页大小
objects: 每个span可分配的对象个数,也即(bytes/spans)/(bytes/obj)
tail waste 列为当前 Span 平均分层 N 份 Object,会有多少内存浪费,这个值是通过 bytes/span 对 bytes/obj 求余得出,即 span% obj。
max waste:列当前 Size Class 最大可能浪费的空间所占百分比。
*/
// class  bytes/obj  bytes/span  objects  tail waste  max waste
//     1          8        8192     1024           0        87.50%
//     2         16        8192      512           0        43.75%
//     3         32        8192      256           0        46.88%
//     4         48        8192      170          32        31.52%
//     5         64        8192      128           0        23.44%
//     6         80        8192      102          32        19.07%
//     7         96        8192       85          32        15.95%
//     8        112        8192       73          16        13.56%
//     9        128        8192       64           0        11.72%
//    10        144        8192       56         128        11.82%
//    11        160        8192       51          32        9.73%
//    12        176        8192       46          96        9.59%
//    13        192        8192       42         128        9.25%
//    14        208        8192       39          80        8.12%
//    15        224        8192       36         128        8.15%
//    16        240        8192       34          32        6.62%
//    17        256        8192       32           0        5.86%
//    18        288        8192       28         128        12.16%
//    19        320        8192       25         192        11.80%
//    20        352        8192       23          96        9.88%
//    21        384        8192       21         128        9.51%
//    22        416        8192       19         288        10.71%
//    23        448        8192       18         128        8.37%
//    24        480        8192       17          32        6.82%
//    25        512        8192       16           0        6.05%
//    26        576        8192       14         128        12.33%
//    27        640        8192       12         512        15.48%
//    28        704        8192       11         448        13.93%
//    29        768        8192       10         512        13.94%
//    30        896        8192        9         128        15.52%
//    31       1024        8192        8           0        12.40%
//    32       1152        8192        7         128        12.41%
//    33       1280        8192        6         512        15.55%
//    34       1408       16384       11         896        14.00%
//    35       1536        8192        5         512        14.00%
//    36       1792       16384        9         256        15.57%
//    37       2048        8192        4           0        12.45%
//    38       2304       16384        7         256       12.46%
//    39       2688        8192        3         128        15.59%
//    40       3072       24576        8           0        12.47%
//    41       3200       16384        5         384        6.22%
//    42       3456       24576        7         384        8.83%
//    43       4096        8192        2           0        15.60%
//    44       4864       24576        5         256        16.65%
//    45       5376       16384        3         256        10.92%
//    46       6144       24576        4           0        12.48%
//    47       6528       32768        5         128        6.23%
//    48       6784       40960        6         256        4.36%
//    49       6912       49152        7         768        3.37%
//    50       8192        8192        1           0        15.61%
//    51       9472       57344        6         512        14.28%
//    52       9728       49152        5         512        3.64%
//    53      10240       40960        4           0        4.99%
//    54      10880       32768        3         128        6.24%
//    55      12288       24576        2           0        11.45%
//    56      13568       40960        3         256        9.99%
//    57      14336       57344        4           0        5.35%
//    58      16384       16384        1           0        12.49%
//    59      18432       73728        4           0        11.11%
//    60      19072       57344        3         128        3.57%
//    61      20480       40960        2           0        6.87%
//    62      21760       65536        3         256        6.25%
//    63      24576       24576        1           0        11.45%
//    64      27264       81920        3         128        10.00%
//    65      28672       57344        2           0        4.91%
//    66      32768       32768        1           0        12.50%

上表可见最大的对象是32K大小,超过32K大小的由特殊的class表示,该class ID为0,每个class只包含一个对象。

MCache

从概念来讲 MCache 与 TCMalloc 的 ThreadCache 十分相似,访问 mcache 依然不需要加锁而是直接访问,且 MCache 中依然保存各种大小的 Span。
虽然 MCache 与 ThreadCache 概念相似,二者还是存在一定的区别的,MCache 是与 Golang 协程调度模型 GPM 中的 P 所绑定,而不是和线程绑定。因为 Golang 调度的 GPM 模型,真正可运行的线程 M 的数量与 P 的数量一致,即 GOMAXPROCS 个,所以 MCache 与 P 进行绑定更能节省内存空间使用,可以保证每个 G 使用 MCache 时不需要加锁就可以获取到内存。而 TCMalloc 中的 ThreadCache 随着 Thread 的增多,ThreadCache 的数量也就相对成正比增多。

下图,看一下MCache的内部构造

协程逻辑层从 mcache 上获取内存是不需要加锁的,因为一个 P 只有一个 M 在其上运行,不可能出现竞争,由于没有锁限制,mcache 则其到了加速内存分配。
MCache 中每个 Span Class 都会对应一个 MSpan,不同 Span Class 的 MSpan 的总体长度不同,参考 runtime/sizeclasses.go 的标准规定划分。比如对于 Span Class 为 4 的 MSpan 来说,存放内存大小为 1Page,即 8KB。每个对外提供的 Object 大小为 16B,共存放 512 个 Object。其他 Span Class 的存放方式类似。当其中某个 Span Class 的 MSpan 已经没有可提供的 Object 时,MCache 则会向 MCentral 申请一个对应的 MSpan。
在上图中应该会发现,对于 Span Class 为 0 和 1 的,也就是对应 Size Class 为 0 的规格刻度内存,mcache 实际上是没有分配任何内存的。因为 Golang 内存管理对内存为 0 的数据申请做了特殊处理,如果申请的数据大小为 0 将直接返回一个固定内存地址,不会走 Golang 内存管理的正常逻辑,相关 Golang 源代码如下:

//usr/local/go/src/runtime/malloc.go

// Al Allocate an object of size bytes.
// Sm Small objects are allocated from the per-P cache's free lists.
// La Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ……(省略部分代码)

if size == 0 {
return unsafe.Pointer(&zerobase)
}

//……(省略部分代码)
}

上述代码可以看见,如果申请的 size 为 0,则直接 return 一个固定地址 zerobase。下面来测试一下有关 0 空间申请的情况,在 Golang 中如 [0] int、 struct {} 所需要大小均是 0,这也是为什么很多开发者在通过 Channel 做同步时,发送一个 struct {} 数据,因为不会申请任何内存,能够适当节省一部分内存空间,测试代码如下:

package main

import (
"fmt"
)

func main() {
var (
//0内存对象
a struct{}
b [0]int

//100个0内存struct{}
c [100]struct{}

//100个0内存struct{},make申请形式
d = make([]struct{}, 100)
)

fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)
fmt.Printf("%p\n", &c[50])    //取任意元素
fmt.Printf("%p\n", &(d[50]))  //取任意元素
}
// 运行结果
0x11aac78
0x11aac78
0x11aac78
0x11aac78

从结果可以看出,全部的 0 内存对象分配,返回的都是一个固定的地址。

MCentral

MCentral 与 TCMalloc 中的 Central 概念依然相似。向 MCentral 申请 Span 是同样是需要加锁的。到这应该可以轻松理解了,因为MCentral是一个全局的对象,所以在使用的时候需要加锁。
当 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 只有一个。

MCentral 与 MCCache 不同的是,每个级别保存的不是一个 Span,而是一个 Span List 链表。与 TCMalloc 中的 Central 不同的是,MCentral 每个级别都保存了两个 Span List。
注意图中MCentral 是表示一层抽象的概念,实际上每个 Span Class 对应的内存数据结构是一个 mcentral,即在 MCentral 这层数据管理中,实际上有 Span Class 个 mcentral 小内存管理单元。
mcentral的简化数据结构(go.16前)

type mcentral struct {
    lock      mutex     // 申请MCentral内存分配时需要加的锁
    sizeclass int       // 当前 mcentral 管理的对象的Size Class级别的
    nonempty  mSpanList // 非空的 Span 列表,包含未满足分配请求的 Span
    empty     mSpanList // 没有可用空间的Span链表,或者当前链表里的Span已经交给mcache
}

type mSpanList struct {
    first *mspan // 第一个 Span
    last  *mspan // 最后一个 Span
}

nonempty
表示还有可用空间的 Span 链表。链表中的所有 Span 都至少有 1 个空闲的 Object 空间。如果 MCentral 上游 MCache 退还 Span,会将退还的 Span 加入到 NonEmpty Span List 链表中。
empty
表示没有可用空间的 Span 链表。该链表上的 Span 都不确定否还有有空闲的 Object 空间。如果 MCentral 提供给一个 Span 给到上游 MCache,那么被提供的 Span 就会加入到 Empty List 链表中。
注意 在 Golang 1.16 版本之后,MCentral 中的 NonEmpty Span List 和 Empty Span List
均由链表管理改成集合管理,分别对应 Partial Span Set 和 Full Span Set。虽然存储的数据结构有变化,但是基本的作用和职责没有区别。
mcentral的简化数据结构(go1.16后)

type mcentral struct {
// mcentral对应的spanClass
spanclass spanClass

partial  [2]spanSet // 维护全部空闲的Span集合
full     [2]spanSet // 维护存在非空闲的Span集合
}

新版本的改进是将 List 变成了两个 Set 集合,Partial 集合与 NonEmpty Span List 责任类似,Full 集合与 Empty Span List 责任类似。可以看见 Partial 和 Full 都是一个 [2] spanSet 类型,也就每个 Partial 和 Full 都各有两个 spanSet 集合,这是为了给 GC 垃圾回收来使用的,其中一个集合是已扫描的,另一个集合是未扫描的。

MHeap

Golang 内存管理的 MHeap 依然是继承 TCMalloc 的 PageHeap 设计。MHeap 的上游是 MCentral,MCentral 中的 Span 不够时会向 MHeap 申请。MHeap 的下游是操作系统,MHeap 的内存不够时会向操作系统的虚拟内存空间申请。访问 MHeap 获取内存依然是需要加锁的。
MHeap 是对内存块的管理对象,是通过 Page 为内存单元进行管理。那么用来详细管理每一系列 Page 的结构称之为一个 HeapArena,它们的逻辑层级关系如图所示。

一个 HeapArena 占用内存 64MB[8],其中里面的内存的是一个一个的 mspan,当然最小单元依然是 Page,图中没有表示出 mspan,因为多个连续的 page 就是一个 mspan。所有的 HeapArena 组成的集合是一个 Arenas,也就是 MHeap 针对堆内存的管理。MHeap 是 Golang 进程全局唯一的所以访问依然加锁。图中又出现了 MCentral,因为 MCentral 本也属于 MHeap 中的一部分。只不过会优先从 MCentral 获取内存,如果没有 MCentral 会从 Arenas 中的某个 HeapArena 获取 Page。

Golang 内存与 TCMalloc 对内存的分类对比

TCMalloc

Golang

小对象

Tiny 对象

中对象

小对象

大对象

大对象

Tiny对象分配流程

针对 Tiny 微小对象的分配,实际上 Golang 做了比较特殊的处理,之前在介绍 MCache 的时候并没有提及有关 Tiny 的存储和分配问题,MCache 中不仅保存着各个 Span Class 级别的内存块空间,还有一个比较特殊的 Tiny 存储空间。

Tiny 空间是从 Size Class = 2(对应 Span Class = 4 或 5)中获取一个 16B 的 Object,作为 Tiny 对象的分配空间。对于 Golang 内存管理为什么需要一个 Tiny 这样的 16B 空间,原因是因为如果协程逻辑层申请的内存空间小于等于 8B,那么根据正常的 Size Class 匹配会匹配到 Size Class = 1(对应 Span Class = 2 或 3),所以像 int32、 byte、 bool 以及小字符串等经常使用的 Tiny 微小对象,也都会使用从 Size Class = 1 申请的这 8B 的空间。但是类似 bool 或者 1 个字节的 byte,也都会各自独享这 8B 的空间,进而导致有一定的内存空间浪费,如图 42 所示。

可以看出来这样当大量的使用微小对象可能会对 Size Class = 1 的 Span 造成大量的浪费。所以 Golang 内存管理决定尽量不使用 Size Class = 1 的 Span,而是将申请的 Object 小于 16B 的申请统一归类为 Tiny 对象申请。具体的申请流程如图所示。

MCache 中对于 Tiny 微小对象的申请流程如下:
(1)P 向 MCache 申请微小对象如一个 Bool 变量。如果申请的 Object 在 Tiny 对象的大小范围则进入 Tiny 对象申请流程,否则进入小对象或大对象申请流程。
(2)判断申请的 Tiny 对象是否包含指针,如果包含则进入小对象申请流程(不会放在 Tiny 缓冲区,因为需要 GC 走扫描等流程)。
(3)如果 Tiny 空间的 16B 没有多余的存储容量,则从 Size Class = 2(即 Span Class = 4 或 5)的 Span 中获取一个 16B 的 Object 放置 Tiny 缓冲区。
(4)将 1B 的 Bool 类型放置在 16B 的 Tiny 空间中,以字节对齐的方式。

对于 Tiny 内存分配器,对象的对齐是根据其大小进行的。tiny 内存分配器中的对象有以下对齐规则:

  1. 对象大小小于等于 8 字节时,对象的大小保持不变,不进行对齐。例如,大小为 4 字节的对象占用 4 字节的内存。
  2. 对象大小大于 8 字节,且小于等于 16 字节时,对象的大小进行对齐到 8 字节。例如,大小为 12 字节的对象会占用 16 字节的内存。

Tiny 对象的申请也是达不到内存利用率 100% 的,就上述图 43 为例,当前 Tiny 缓冲 16B 的内存利用率为,而如果不用 Tiny 微小对象的方式来存储,那么内存的布局将如图

小对象分配流程

上面已经介绍了分配在 1B 至 16B 的 Tiny 对象的分配流程,那么对于对象在 16B 至 32B 的内存分配,Golang 会采用小对象的分配流程。
分配小对象的标准流程是按照 Span Class 规格匹配的。在之前介绍 MCache 的内部构造已经介绍了,MCache 一共有 67 份 Size Class 其中 Size Class 为 0 的做了特殊的处理直接返回一个固定的地址。Span Class 为 Size Class 的二倍,也就是从 0 至 133 共 134 个 Span Class。
当协程逻辑层 P 主动申请一个小对象的时候,Golang 内存管理的内存申请流程如图所示。

下面来分析一下具体的流程过程:
(1)首先协程逻辑层 P 向 Golang 内存管理申请一个对象所需的内存空间。
(2)MCache 在接收到请求后,会根据对象所需的内存空间计算出具体的大小 Size。
(3)判断 Size 是否小于 16B,如果小于 16B 则进入 Tiny 微对象申请流程,否则进入小对象申请流程。
(4)根据 Size 匹配对应的 Size Class 内存规格,再根据 Size Class 和该对象是否包含指针,来定位是从 noscan Span Class 还是 scan Span Class 获取空间,没有指针则锁定 noscan。
(5)在定位的 Span Class 中的 Span 取出一个 Object 返回给协程逻辑层 P,P 得到内存空间,流程结束。
(6)如果定位的 Span Class 中的 Span 所有的内存块 Object 都被占用,则 MCache 会向 MCentral 申请一个 Span。
(7)MCentral 收到内存申请后,优先从相对应的 Span Class 中的 NonEmpty Span List(或 Partial Set,Golang V1.16+)里取出 Span(多个 Object 组成),NonEmpty Span List 没有则从 Empty List(或 Full Set Golang V1.16+)中取,返回给 MCache。
(8)MCache 得到 MCentral 返回的 Span,补充到对应的 Span Class 中,之后再次执行第(5)步流程。
(9)如果 Empty Span List(或 Full Set)中没有符合条件的 Span,则 MCentral 会向 MHeap 申请内存。
(10)MHeap 收到内存请求从其中一个 HeapArena 从取出一部分 Pages 返回给 MCentral,当 MHeap 没有足够的内存时,MHeap 会向操作系统申请内存,将申请的内存也保存到 HeapArena 中的 mspan 中。MCentral 将从 MHeap 获取的由 Pages 组成的 Span 添加到对应的 Span Class 链表或集合中,作为新的补充,之后再次执行第(7)步。
(11)最后协程业务逻辑层得到该对象申请到的内存,流程结束。

大对象分配流程

小对象是在 MCache 中分配的,而大对象是直接从 MHeap 中分配。对于不满足 MCache 分配范围的对象,均是按照大对象分配流程处理。
大对象分配流程是协程逻辑层直接向 MHeap 申请对象所需要的适当 Pages,从而绕过从 MCaceh 到 MCentral 的繁琐申请内存流程,大对象的内存分配流程相对比较简单,具体的流程如图

下面来分析一下具体的大对象内存分配流程:
(1)协程逻辑层申请大对象所需的内存空间,如果超过 32KB,则直接绕过 MCache 和 MCentral 直接向 MHeap 申请。
(2)MHeap 根据对象所需的空间计算得到需要多少个 Page。
(3)MHeap 向 Arenas 中的 HeapArena 申请相对应的 Pages。
(4)如果 Arenas 中没有 HeapA 可提供合适的 Pages 内存,则向操作系统的虚拟内存申请,且填充至 Arenas 中。
(5)MHeap 返回大对象的内存空间。
(6)协程逻辑层 P 得到内存,流程结束。

内容真的太硬核了,博主差不多看了两天才过完一遍,还有很多不熟悉的地方,还要继续多学习。
打算看一下刘丹冰作者的《深入理解go语言》