在了解Golang的内存管理之前,需要了解下基本申请内存模式,即TCMalloc(Thread Cache malloc)。

golang的内存管理就是基于TCMalloc的核心思想来构建的。

1.TCMalloc

1.1TCMalloc介绍

TCMalloc最大优势就是每个线程都会维护自己的独立内存池。

Golang内存管理_内存管理

下面分别介绍下相关内存池。

Golang内存管理_内存管理_02

1.1.1ThreadCache(小对象内存快的申请):

ThreadCache是每个线程独立的缓存,能够明显提高Thread获取高命中的数据。

Thread Cache作为线程独立的交互内存,访问无需加锁。

ThreadCache是从堆空间一次性申请,只触发一次系统调用。

1.1.2CentralCache(小对象内存快的申请):

当ThreadCache缓存不足时,就会从CentralCache获取。

当ThreadCache缓存充足或过多时,则会将内存退还给Central Cache。

Central Cache由于共享,访问的时候时候需要加锁。

1.1.3PageHeap(中、大对象内存快的申请):

Page Heap也是一次系统调用从虚拟内存中申请。

Page Heap也是全局的,访问需要加锁。

Central Cache没有足够的内存时,就会从Page Heap获取。

当Central Cache内存过多或者充足时,会直接从Page Heap获取。

1.2TCMalloc模型相关基础结构(Page、Span、Size Class)

1.2.1Page

TCMalloc将虚拟内存空间划分为同等大小的Page,每个Page默认是8KB,并且每分Page都标记了ID编号,ID编号的好处是:可以根据任意内存的地址指针,根据固定算法偏移计算出所在的Page。

Golang内存管理_内存管理_03

1.2.2Span

多个连续的Page称为一个Span。

TCMalloc是以Span为单位向操作系统申请内存的。

每个span记录了起始Page的编号start和一共有多少个连续Page的数量length。

Span和Span之间的管理是以双向链表的形式构建。

Golang内存管理_链表_04


1.2.3Size Class

同属于同一个内存大小的集合,该集合为一个Size Class 。

例如:内存块大小为8B 的都属于SizeClass1,内存块大小为16B 的都属于SizeClass2。

SizeClass、Span、Page用一张图表示如下。

Golang内存管理_golang_05

接下来剖析一下Thread Cache、Central Cache、Page Heap的内存管理结构。

1.3Thread Cache内存管理结构

在TCMalloc中,每个县城都有一份单独的缓存,就是ThreadCache,申请内存不需要加锁。

ThreadCache中对于每个Size Class都有对应的FreeList,FreeList表示当前缓存中还有多少个空闲的内存可用。

从TCMalloc申请小对象,直接从Thread Cache中获取,实则是从Free List中返回一个空闲对象。

Golang内存管理_golang_06

1.4Central Cache内存管理结构

Central Cache是各个线程公用的,所以从Central Cache中获取内存是需要加锁的。

Central Cache缓存的Size Class和Thread Cache一样,也都存放在CentralFreeList中。

当Thread Cache中某个Size Class刻度下的缓存中小对象不足,就会向CentralCache对应的Size Class刻度的Central Freelist获取。

如果Thread Cache中有多余的,会退还给Central Free List。

Central Cache是与Thread Cache布局一模一样的缓存。

Golang内存管理_链表_07

1.5Page Heap内存管理结构

Page Heap是只针对Central Cache的三级缓存。用于对中对象和大对象的分配。

当找不到 ThreadCache、CentralCache、PageHeap 都找不到合适的 Span,PageHeap 则会调用操作系统内存申请系统调用函数来从虚拟内存的堆区中取出内存填充到 PageHeap 当中。

Page Heap内部的Span管理采取两种不同的方式。

对于128个Page以为的span申请,每个Page会用一个链表来存储。

对于128个Page以上的内存申请,Page Heap时以有序集合来存储。

Golang内存管理_链表_08

1.6TCMalloc的小对象分配流程详解

Golang内存管理_golang_09

小对象为占用内存小于等于 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 一次申请小对象的全部详细流程,接下来分析中对象的分配流程。

1.7TCMalloc的中对象分配流程详解

Golang内存管理_内存管理_10

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 对于中对象分配的详细流程。

1.8TCMalloc的大对象分配流程详解

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

Golang内存管理_链表_11

进入大对象分配流程除了申请的 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 对于大对象分配的详细流程。

2.Golang 内存管理

Golang内存管理模型与TCMalloc设计很相似,只是一些规则和流程存在差异。

Golang内存管理_内存管理_12

2.1Golang内存管理单元相关概念

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

2.1.1Page(同TCMalloc相同)

与TCMalloc中的Page一样,一个Page大小仍然时8KB。

Page是内存管理与虚拟内存交互的最小单元。

2.1.2mSpan(同TCMalloc相同)

与TCMalloc中的span一致,mspn也是一组连续的Page。

2.1.3object(TCMalloc中没有)

一个span在初始化时,会被切割成一堆相同大小的object.

例如:一个object大小为16b,span大小时8K ,那么就会初始化出8+1024/16=512个object。

Page是Golang内存管理与操作系统交互的基本单元。

Object是对象存储的基本单元。

Golang内存管理_内存管理_13

2.1.4Size Class--划分Object大小的类别(不同于TCMalloc)

与TCMalloc相同,表示一块内存所属的规格。

Golang内存管理中的SizeClass是针对Object Size来划分的。所以Size Class是划分Object大小的级别。

如 Object Size 在 1Byte--8Byte 之间的 Object 属于 Size Class 1 级别,Object Size 在 8B--16Byte 之间的属于 Size Class 2 级别。

size class明细:

下面分别解释一下每一列的含义:

(1)Class 列为 Size Class 规格级别。

(2)bytes/obj 列为 Object Size,即一次对外提供内存 Object 的大小(单位为 Byte),可能有一定的浪费,比如业务逻辑层需要 2B 的数据,实则会定位到 Size Class 为 1,返回一个 Object 即 8B 的内存空间。

(3)bytes/span 列为当前 Object 所对应 Span 的内存大小(单位为 Byte)。

(4)objects 列为当前 Span 一共有多少个 Object,该字段是通过 bytes/span 和 bytes/obj 相除计算而来。

(5)tail waste 列为当前 Span 平均分层 N 份 Object,会有多少内存浪费,这个值是通过 bytes/span 对 bytes/obj 求余得出,即 span% obj。

(6)max waste 列当前 Size Class 最大可能浪费的空间所占百分比。这里面最大的情况就是一个 Object 保存的实际数据刚好是上一级 Size Class 的 Object 大小加上 1B。当前 Size Class 的 Object 所保存的真实数据对象都是这一种情况,这些全部空间的浪费再加上最后的 tail waste 就是 max waste 最大浪费的内存百分比,具体如图所示。

Max Waste = (本级 Object Size – (上级 Object Size + 1)* 本级 Object 数量) / 本级 Span Size

Golang内存管理_内存管理_14

2.1.5Span Class--针对span来划分(TCMalloc中没有)

是针对span来划分的,是span大小的级别。

一个SizeClass对应两个Span class。

一个Span存放需要GC扫描的对象(包含指针对象),另一个Span存放不需要GC扫描的对象(不包含指针的对象)。

Golang内存管理_内存管理_15


Golang内存管理_内存管理_16

2.2MCache

2.2.1Mcache与GMP中的P绑定(与TCMaloc不同)

Mcache与GMP 中的P绑定,Thread Cache与M绑定。

因为Golang的GMP模型,真正可运行的线程M的数量==P的数量,即GOMAXPROCS个,所以Mcache与P绑定能节省内存空间,可以保证每个G使用M cache时不需要加锁。

Golang内存管理_golang_17

2.2.2Mcache中每个span class对应一个Mspan

Mcache 中每个span class都会对应一个MSpan,不同的Span class的MSpan长度不同。

如:Span Class为4的MSpan,存放内存大小为1Page ,即8KB。每个Object大小为 16B,那么一个Mspan存放512个Object。其他Span Class存放类似。

Golang内存管理_golang_18

2.2.3SpanClass0和SpanClass1的特别之处--用于为内存大小为0的数据返回固定地址

从上图可以发现,SpanClass0和SpanClass1,mcache实际上没有分配任何内存。

因为Golang内存管理对内存为0的数据的申请做了特殊处理,如果申请的数据大小为0,将直接返回一个固定的内存地址,不会走Golang内存管理的正常逻辑。

从上述代码可以看到,如果申请的size为0,直接return 一个固定的地址&zerobase。

下面测试

运行结果如下:

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

2.3MCentral

向Mcentral申请span同样是需要加锁的。当MCache中某个Size Class对应的Span中的object空缺后,MCache就会向MCentral申请Span。

2.3.1Goroutine、MCache、MCentral、MHeap 互相交换的内存单位不同(与TCMalloc不同)

P与MCache交互的单位是Object。

MCache 与MCentral交互的单位时Span。

Mcentral与MHeap交互的单位是Page。

Golang内存管理_内存管理_19

2.3.2MCentral中每个Span Class有两个Span链表(与TCMalloc不同)

MCentral 与 TCMalloc 中的 Central 不同的是 MCentral 针对每个 Span Class 级别有两个 Span 链表,而 TCMalloc 中的 Central 只有一个。

Golang内存管理_链表_20

MCentral是抽象概念,实际上每个span class对应的内存数据结构是一个mcentral。

即在 Mcentral这层中,实际上有多个mcentral管理单元。

1)NonEmpty Span List

表示还有可用空间的Span链表。----链表中的所有Span都至少有1个空闲的Object空间。

如果MCentral 提供给Mcache 一个Span,那么这个Span会加入到 EmptyList链表中。

2)Empty Span List

表示没有可用空间的span链表。----该链表上的Span都不确定是否还有空闲的Object空间。

如果MCentral 提供给Mcache 一个Span,那么这个Span会加入到 EmptyList链表中。

注意 在 Golang 1.16 版本之后,MCentral 中的 NonEmpty Span List 和 Empty Span List均由链表管理改成集合管理,分别对应 Partial Span Set 和 Full Span Set。虽然存储的数据结构有变化,但是基本的作用和职责没有区别。

源码查看:

下面是 MCentral 层级中其中一个 Size Class 级别的 MCentral 的定义 Golang 源代码(V1.14 版本)

在 GolangV1.16 及之后版本(截止本书编写最新时间)的相关 MCentral 结构代码如下:

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

2.4MHeap

Golang 内存管理的 MHeap 依然是继承 TCMalloc 的 PageHeap 设计。

MHeap 的上游是 MCentral,MCentral 中的 Span 不够时会向 MHeap 申请。

MHeap 的下游是操作系统,MHeap 的内存不够时会向操作系统的虚拟内存空间申请。

访问 MHeap 获取内存依然是需要加锁的。

2.4.1Heap Arena

MHeap是对内存块的管理对象,是通过Page为内存单元进行管理。那么用来详细管理每一系列Page的结构,称为一个Heap Arena。

Golang内存管理_链表_21

一个Heap Arena占用内存64MB,其中是一个一个的mspan,最小单元仍然是Page。

所有的HeapArena组成的集合是一个Arenas,也就是MHeap针对堆内存的管理。

MHeap 是 Golang 进程全局唯一的所以访问依然加锁。

图中又出现了 MCentral,因为 MCentral 本也属于 MHeap 中的一部分。只不过会优先从 MCentral 获取内存,如果没有 MCentral 会从 Arenas 中的某个 HeapArena 获取 Page。

2.5Tiny对象分配流程

2.5.1Tiny空间介绍

Golang内存管理_内存管理_22

针对Tiny对象的分配,Golang做了特殊的处理。Mcache中还有一个比较特殊的Tiny存储空间。

Tiny空间时从SizeClass=2(对应Span Class=4或5)中获取一个16B的Object作为Tiny对象的分配空间。

Golang内存管理_golang_23

2.5.2为什么需要Tiny这样的16B空间

原因:

如果写成逻辑层申请的内存空间小于8B,那么正常会匹配到Size Class=1(对应Span class=2或3),所以像int32、byte、bool及小字符串等常用的Tiny微小对象,也都会从SizeClass=1申请8B的空间。这样会存在空间浪费,如下图:

Golang内存管理_golang_24

Golang内存管理_golang_25

Golang内存管理_内存管理_26

所以golang内存管理尽量不使用Size class=1的span,而是将申请的object小于16B的申请统一归类为Tiny对象申请。

2.5.3Tiny对象的申请流程

Golang内存管理_golang_27

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 对象的申请也是达不到内存利用率 100% 的,就上述图 为例,当前 Tiny 缓冲 16B 的内存利用率为,而如果不用 Tiny 微小对象的方式来存储,那么内存的布局将如图 所示。

可以算出利用率为。Golang 内存管理通过 Tiny 对象的处理,可以平均节省 20% 左右的内存。

Golang内存管理_链表_28

2.6小对象分配流程

Golang内存管理_链表_29

下面来分析一下具体的流程过程:

(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)最后协程业务逻辑层得到该对象申请到的内存,流程结束。

2.7大对象分配流程

Golang内存管理_内存管理_30

下面来分析一下具体的大对象内存分配流程:

(1)协程逻辑层申请大对象所需的内存空间,如果超过 32KB,则直接绕过 MCache 和 MCentral 直接向 MHeap 申请。

(2)MHeap 根据对象所需的空间计算得到需要多少个 Page。

(3)MHeap 向 Arenas 中的 HeapArena 申请相对应的 Pages。

(4)如果 Arenas 中没有 HeapA 可提供合适的 Pages 内存,则向操作系统的虚拟内存申请,且填充至 Arenas 中。

(5)MHeap 返回大对象的内存空间。

(6)协程逻辑层 P 得到内存,流程结束。

参考文献

感谢作者的详细讲解,我这只是方便自己记忆,归纳了一下