Golang内存管理保留了TCMalloc中的Page、Span、Size Class等概念。
1.Page
与TCMalloc的Page一致。Golang内存管理模型延续了TCMalloc的概念,一个Page的大小依然是8KB。Page表示Golang内存管理与虚拟内存交互内存的最小单元。操作系统虚拟内存对于Golang来说,依然是划分成等分的N个Page组成的一块大内存公共池,如图3.21所示。
2.mSpan
与TCMalloc中的Span一致。mSpan概念依然延续TCMalloc中的Span概念,在Golang中将Span的名称改为mSpan,依然表示一组连续的Page。
3.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在1Byte~8Byte之间的Object属于Size Class 1级别,Object Size 在8B~16Byte之间的属于Size Class 2级别。
(3)Span Class,这个是Golang内存管理额外定义的规格属性,是针对Span来进行划分的,是Span大小的级别。一个Size Class会对应两个Span Class,其中一个Span为存放需要GC扫描的对象(包含指针的对象),另一个Span为存放不需要GC扫描的对象(不包含指针的对象),具体Span Class与Size Class的逻辑结构关系如图所示。
这里makeSpanClass()函数为通过Size Class来得到对应的Span Class,其中第二个形参noscan表示当前对象是否需要GC扫描,不难看出来Span Class 和Size Class的对应关系公式如表3-5所示。
4.Size Class明细
如果再具体一些,则通过Golang的源码可以看到,Golang给内存池中固定划分了66[7]个Size Class,这里面列举了详细的Size Class和Object大小、存放Object数量,以及每个Size Class对应的Span内存大小关系,代码如下:
//usr/local/go/src/runtime/sizeclasses.go
package runtime
// 标题Title解释:
// [class]: Size Class
// [bytes/obj]: Object Size,一次对外提供内存Object的大小
// [bytes/span]: 当前Object所对应Span的内存大小
// [objects]: 当前Span一共有多少个Object
// [tail wastre]: 为当前Span平均分层N份Object,会有多少内存浪费
// [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%
下面分别解释一下每一列的含义:
(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最大浪费的内存百分比,具体如图所示。
图中以Size Class 为7的Span为例,通过源代码runtime/sizeclasses.go的详细Size Class数据可以得知具体Span细节如下:
// class bytes/obj bytes/span objects tail waste max waste
// … …
// 6 80 8192 102 32 19.07%
// 7 96 8192 85 32 15.95%
// … …
从图可以看出,Size Class为7的Span如果每个Object均超过Size Class为7中的Object一个字节。那么就会导致Size Class为7的Span出现最大空间浪费情况。综上可以得出计算最大浪费空间比例的算法公式如下:
(本级Object Size – (上级Object Size + 1)*本级Object数量) / 本级Span Size
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上获取内存是不需要加锁的,因为一个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。
在图3.36中应该会发现,对于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{}数据,因为不会申请任何内存,能够适当节省一部分内存空间,测试代码如下:
//第一篇/chapter3/MyGolang/zeroBase.go
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])) //取任意元素
}
运行结果如下 从结果可以看出,全部的0内存对象分配,返回的都是一个固定的地址。
$ go run zeroBase.go
0x11aac78
0x11aac78
0x11aac78
0x11aac78
MCentral
MCentral与TCMalloc中的Central概念依然相似。向MCentral申请Span是同样是需要加锁的。当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的内部构造如图:
MCentral与MCCache不同的是,每个级别保存的不是一个Span,而是一个Span List链表。与TCMalloc中的Central不同的是,MCentral每个级别都保存了两个Span List。
注意 图38中MCentral是表示一层抽象的概念,实际上每个Span Class对应的内存数据结构是一个mcentral,即在MCentral这层数据管理中,实际上有Span Class个mcentral小内存管理单元。
1)NonEmpty Span List
表示还有可用空间的Span链表。链表中的所有Span都至少有1个空闲的Object空间。如果MCentral上游MCache退还Span,会将退还的Span加入到NonEmpty Span List链表中。
2)Empty Span List
表示没有可用空间的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层级中其中一个Size Class级别的MCentral的定义Golang源代码(V1.14版本):
//usr/local/go/src/runtime/mcentral.go , Go V1.14
// Central list of free objects of a given size.
// go:notinheap
type mcentral struct {
lock mutex //申请MCentral内存分配时需要加的锁
spanclass spanClass //当前哪个Size Class级别的
// list of spans with a free object, ie a nonempty free list
// 还有可用空间的Span 链表
nonempty mSpanList
// list of spans with no free objects (or cached in an mcache)
// 没有可用空间的Span链表,或者当前链表里的Span已经交给mcache
empty mSpanList
// nmalloc is the cumulative count of objects allocated from
// this mcentral, assuming all spans in mcaches are
// fully-allocated. Written atomically, read under STW.
// nmalloc是从该mcentral分配的对象的累积计数
// 假设mcaches中的所有跨度都已完全分配。
// 以原子方式书写,在STW下阅读。
nmalloc uint64
}
在GolangV1.16及之后版本(截止本书编写最新时间)的相关MCentral结构代码如下:
//usr/local/go/src/runtime/mcentral.go , Go V1.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。
如果再详细剖析MHeap里面相关的数据结构和指针依赖关系,可以下图,这里不做过多解释,如果更像详细理解MHeap建议研读源代码/usr/local/go/src/runtime/mheap.go文件。
MHeap中HeapArena占用了绝大部分的空间,其中每个HeapArean包含一个bitmap,其作用是用于标记当前这个HeapArena的内存使用情况。其主要是服务于GC垃圾回收模块,bitmap共有两种标记,一个是标记对应地址中是否存在对象,一个是标记此对象是否被GC模块标记过,所以当前HeapArena中的所有Page均会被bitmap所标记。
ArenaHint为寻址HeapArena的结构,其有三个成员:
(1)addr,为指向的对应HeapArena首地址。
(2)down,为当前的HeapArena是否可以扩容。
(3)next,指向下一个HeapArena所对应的ArenaHint首地址。
从图3.40中可以看出,MCentral实际上就是隶属于MHeap的一部分,从数据结构来看,每个Span Class对应一个MCentral,而之前在分析Golang内存管理中的逻辑分层中,是将这些MCentral集合统一归类为MCentral层。
Tiny对象分配流程
在之前章节的表3-4中可以得到TCMalloc将对象分为了小对象、中对象、和大对象,而Golang内存管理将对象的分类进行了更细的一些划分,具体的划分区别对比如表6所示。
针对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的空间,进而导致有一定的内存空间浪费,如图所示。
可以看出来这样当大量的使用微小对象可能会对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对象的申请也是达不到内存利用率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得到内存,流程结束。