一文带你了解,虚拟内存、内存分页、分段、段页式内存管理
[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
可以总结为以下步骤
在此,你可以想一下,使用快表和不使用快表,分别访问了几次内存?(使用快表访问一次,不使用访问两次)
多级页表占用的空间很大,如果以该二级页表为例,映射4GB的地址空间,需要4KB+4MB,足足多了4MB。所以操作系统仅会将顶级页表存储在内存中。
多级页表的求址过程和单级也没有多少区别。
在分段存储中,一般分为以下几个段
段式的求址过程和页式也差不太多,就不再介绍了。
用户程序先分段,每个段内部再分页(内部原理同基本的分页、分段相同)
段页式地址变换中要得到物理地址须经过三次内存访问:
这部分内容我觉得原理差不多,目前就先不写了。
有很多流程图或结构图,以后有时间可以再补上。
既然有虚拟,那肯定也有实际,所以有以下两个概念
虚拟内存是计算机操作系统中的一个概念,它允许程序使用比实际物理内存更大的内存空间。实际上,计算机上的物理内存容量通常是有限的,但是虚拟内存允许操作系统和应用程序似乎具有更大的内存空间。
虚拟存储系统在两方面有改进
程序运行时,如果要访问的页面(段)已装入内存,便可继续执行下去,如果它要访问的页面(段)尚未装入内存,则发生缺页中断,此时,系统将启动请求调页(段)功能,将作业所需的页(段)装入内存。如果此时内存已满,则会使用相应的页面置换算法来进行页面置换。
虚拟存储器的实现,必须建立在离散存储器管理方式的基础之上,因此可以有三种实现方式:请求分页,请求分段和请求段页式,当前主流的方法式请求分页方式,因此下面的内容将以请求分页存储管理方式的实现机制为例来说明。
这里的页表和之前所提到的有不同,但其基本作用仍然是实现页号到物理块号的映射。
要知道固定分配局部置换、可变分配全部置换、可变分配局部置换的意思,首先需要知道以下几个概念:
1.** 固定分配:操作系统为每个进程分配一组固定数目大小的物理块。在程序运行过程中,不允许改变!即驻留集大小固定不变。
2.可变分配:先为每个进程分配一定大小的物理块,在程序运行过程中,可以动态改变物理块的大小。即,驻留集大小可变。
3.局部置换:进程发生缺页时,只能选择当前进程中的物理块进行置换。
4.全局置换**:可以将操作系统进程中保留的空闲物理块分配给缺页进程,还可以将别的进程持有的物理块置换到外存,再将这个物理块分配给缺页的进程。
概念:系统为每个进程分配一定数量的内存块(物理块),在整个运行期都不改变。若进程在运行过程中发生了缺页,则只能在本进程的内存页面中选出一个进行换出,然后再调用需要的页面。
缺点:很难确定一个进程到底应该分配多大的实际内存才合理
概念:刚开始会为每个进程分配一定数量的物理块。当进程发生缺页时,只允许从当前进程的物理块中选出一个换出外存。如果当前进程在运行的时候频繁缺页,系统会为该进程动态增加一些物理块,直到该进程缺页率趋于适中程度;如果说一个进程在运行过程中缺页率很低或者不缺页,则可以适当减少该进程分配的物理块。通过这些操作可以保持多道程序的并发度较高。
缺点:使用可变分配局部置换时,每个进程会被分配一个可变大小的页面块,这可能会导致内存中出现许多不同大小的空闲块,从而增加内存碎片的问题。这些碎片可能会导致内存空间不连续,使得无法为较大的进程分配足够的内存。
概念:系统为每个进程分配一定数量的内存块(物理块)。操作系统还会保持一个空闲物理块的队列。若某进程发生缺页,可以从空闲物理块中取出一块分配给该进程。如果空闲物理块没有了,那么会选择一个未锁定(不是那么重要)的页面换出到外存,再将物理块分配给缺页的进程。
缺点:在空闲物理块没有的情况下,如果将其他进程的页面调出到外存,那么这个进程就会拥有较小的驻留集,如此会导致该进程的缺页率上升
看到这,为什么没有固定分配全局置换呢?
如果理解上面的分配和置换,那应该很容易明白,这种情况下,固定和全局本身就是矛盾的,不可能在固定的情况下,还进行全局的置换,因为全局的置换会分配其他的空闲物理块。
预调页策略:基于局部性原理,一次调入若干个相邻页面可能比一次调入一个页面更高效。
缺点:如果调入的若干页面是不会被马上访问的,那么这样效率又会很低。
请求调页策略:只有在进程处于运行期,且发生缺页的时候才被调入内存。
缺点:缺页时每次只会调入一页,每次从外存到内存的调入都会进行I/O操作,因此I/O开销较大。
这个部分的内容暂且就先简略的介绍一下,因为感觉理论基本都很容易看明白。
OPT是最好的页面置换算法,所有的置换算法都是为了接近它的性能而进行优化。
页面缓冲思想(Page Buffering)是指在计算机系统中,通过缓存(缓冲)页面数据来优化数据访问和管理的策略。这种思想通常应用于磁盘和文件系统等存储系统,旨在减少数据读取和写入的延迟,提高数据访问速度和效率。
页面缓冲的核心思想是将最常用的数据页(或数据块)暂时存储在内存中,以便在需要时能够更快地访问。具体来说,当应用程序需要从磁盘读取数据时,系统首先检查缓冲区中是否已经存在所需的数据页。如果数据页已经在缓冲区中,那么可以直接从内存中读取,避免了慢速的磁盘访问。如果数据页不在缓冲区中,系统会将它从磁盘读取到缓冲区,并且可能还会替换掉缓冲区中的其他数据页。
页面缓冲的优点包括:
然而,页面缓冲也可能带来一些挑战,如:
TCMalloc(Thread Cache Malloc)。Golang 的内存管理就是基于 TCMalloc 的核心思想来构建的。本节将介绍 TCMalloc 的基础理念和结构。
TCMalloc 中的 Page 与之前章节介绍操作系统对虚拟内存管理的 MMU 定义的物理页有相似的定义,TCMalloc 将虚拟内存空间划分为多份同等大小的 Page,每个 Page 默认是 8KB。
将 Page 进行编号的好处是,可以根据任意内存的地址指针,进行固定算法偏移计算来算出所在的 Page。
多个连续的 Page 称之为是一个 Span,其定义含义有操作系统的管理的页表相似,Page 和 Span 的关系如图所示。
TCMalloc 是以 Span 为单位向操作系统申请内存的。每个 Span 记录了第一个起始 Page 的编号 Start,和一共有多少个连续 Page 的数量 Length。
在 256KB 以内的小对象,TCMalloc 会将这些小对象集合划分成多个内存刻度 [6],同属于一个刻度类别下的内存集合称之为属于一个 Size Class。这与之前章节自定义实现的内存池,将 Buf 划分多个刻度的 BufList 类似。
每个 Size Class 都对应一个大小比如 8 字节、16 字节、32 字节等。在申请小对象内存的时候,TCMalloc 会根据使用方申请的空间大小就近向上取最接近的一个 Size Class 的 Span(由多个等空间的 Page 组成)内存块返回给使用方。
(这图是不是有问题?我感觉应该是KB而不是B)
在 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 缓存的 Size Class 和 ThreadCache 的一样,这些缓存都被放在 CentralFreeList 中,当 ThreadCache 中的某个 Size Class 刻度下的缓存小对象不够用,就会向 CentralCache 对应的 Size Class 刻度的 CentralFreeList 获取,同样的如果 ThreadCache 有多余的缓存对象也会退还给响应的 CentralFreeList,流程和关系如图
CentralCache 与 PageHeap 的角色关系与 ThreadCache 与 CentralCache 的角色关系相似,当 CentralCache 出现 Span 不足时,会从 PageHeap 申请 Span,以及将不再使用的 Span 退还给 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 容器)来存放。
小对象为占用内存小于等于 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 一次申请小对象的全部详细流程,接下来分析中对象的分配流程。
中对象为大于 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 对于中对象分配的详细流程。
对于超过 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 等概念。
与 TCMalloc 的 Page 一致。Golang 内存管理模型延续了 TCMalloc 的概念,一个 Page 的大小依然是 8KB。Page 表示 Golang 内存管理与虚拟内存交互内存的最小单元。操作系统虚拟内存对于 Golang 来说,依然是划分成等分的 N 个 Page 组成的一块大内存公共池。
与 TCMalloc 中的 Span 一致。mSpan 概念依然延续 TCMalloc 中的 Span 概念,在 Golang 中将 Span 的名称改为 mSpan,依然表示一组连续的 Page。
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
/*
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 与 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 与 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 垃圾回收来使用的,其中一个集合是已扫描的,另一个集合是未扫描的。
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 微小对象的分配,实际上 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 内存分配器中的对象有以下对齐规则:
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语言》
手机扫一扫
移动阅读更方便
你可能感兴趣的文章