前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一文搞懂Go1.20内存分配器

一文搞懂Go1.20内存分配器

原创
作者头像
涂明光
发布2024-03-10 16:07:19
2050
发布2024-03-10 16:07:19
举报
文章被收录于专栏:后台技术学习后台技术学习

关于Go内存分配器的分析文章很多,看到的比较经典的有刘丹冰Aceld的一站式Golang内存管理洗髓经,最近学习了该篇文章和其他相关文章,结合Go1.20最新的源码,复习了下Go内存分配的知识,输出了自己的学习笔记。要学习Go GC实现,需要先搞定内存分配,内存分配是GC垃圾回收的前传。

结论

1. TCMalloc内存分配的核心思想是:多级缓存机制,每个线程Thread自行维护一个无锁的线程本地缓存ThreadCache,小对象优先从本地缓存申请内存,内存不足时向加锁的中央缓存CentralCache申请,中央缓存不足时向页堆PageHeap申请,大对象直接向页堆PageHeap申请。

2. Go内存分配器的设计思想主要来自TCMalloc,也是包含三级缓存机制:每个线程 M 所在的 P 维护一个无锁的线程本地缓存mcache,内存不足时向对应spanClass规格的中央缓存mcentral加锁申请资源,中央缓存不足时向页堆mheap申请,大对象直接向页堆mheap申请。

3. Go内存分配器与操作系统虚拟内存交互的最小单元是Page,即虚拟内存页多个连续的Page称为一个mspan,mspan 是 Go 内存分配的基本单元;每个mspan有个字段叫spanClass跨度类,是对mspan大小级别的划分,每个mspan能够存放指定范围大小的对象,32KB以内的小对象在Go中,会对应不同大小的内存刻度Size Class,Size Class和Object Size是一一对应的,前者指序号 0、1、2、3,后者指具体对象大小 0B、8B、16B、24B;每一个Size Class根据是否有指针,对应两个spanClass规格的mspan;每个mspan都会有特定的Size Class内存规格、spanClass跨度规格、存储特定Object Size的对象,并且包含特定数量的页数Pages。

4. Go中,mcache线程缓存负责微对象和小对象(<32KB)的分配;弄清楚了mspan的内存结构,就很容易理解mcache,mcache只是包含了全部spanClass规格的mspan的一个内存结构,绑定在本地P上,访问无需加锁。和mcache不一样,每个spanClass规格对应一个mcentral中央缓存,全部spanClass规格的mcentral共同构成了中央缓存层MCentral,访问需要加锁;mcentral主要包含 partial Set 空闲集合和 full Set 非空闲集合,支持并发高效存取mspanmheap主要由多个64M的heapArena组成,每个heapArena主要包含8192页的一个列表,每页分别对应一个mspan

5. 微对象的分配逻辑是:多个小于16B的无指针微对象的内存分配请求,会合并向Tiny微对象空间申请,微对象的 16B 内存空间从 spanClass 为 4 或 5(无GC扫描)的mspan中获取。

6. 小对象的分配逻辑是:先向mcache申请,mcache内存空间不够时,向mcentral申请,mcentral不够,则向页堆mheap申请,再不够就向操作系统申请。大对象直接向页堆mheap申请。

1. TCMalloc内存分配思想

Go语言的内存分配器采用了TCMalloc多级缓存分配思想来构建的。

如图1.1所示,是TCMalloc内存分配器的主要模块。根据 TCMalloc : Thread-Caching Malloc 的描述,TCMalloc为每个线程Thread分配一个线程本地缓存。线程本地缓存满足小对象(<= 32KB)的分配。每个线程Thread在申请内存时首先会从这个线程缓存ThreadCache申请,所有线程缓存ThreadCache共享一个中央缓存CentralCache。当线程缓存不足时,ThreadCache会向中央缓存CentralCache申请内存,当中央缓存CentralCache内存不足时,会向页堆PageHeap申请,页堆内存不足,会向操作系统的虚拟内存申请。

图1.1 TCMalloc内存分配模型
图1.1 TCMalloc内存分配模型

线程对于大对象(>32KB)的分配是直接向页堆PageHeap申请,不经过线程缓存ThreadCache和中央缓存CentralCache。

CentralCache由于共享,它的访问是需要加锁的。ThreadCache作为线程独立的第一交互内存,访问无需加锁。CentralCache则作为ThreadCache临时补充缓存。PageHeap也是一次系统调用从虚拟内存中申请的,PageHeap明显是全局的,其访问一定要加锁。

对于内存的释放,遵循逐级释放的策略。当ThreadCache的缓存充足或者过多时,则会将内存退还给CentralCache。当CentralCache内存过多或者充足,则将低命中内存块退还PageHeap。

总之,TCMalloc的核心原理是:把内存分为多级管理,从而降低锁的粒度。每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争

2. Go内存分配相关概念

Go内存分配器的设计思想主要来自TCMalloc,接下来详细分析一下Go内存管理模型的相关概念和核心模块。如图2.1所示,是Go的内存管理模型。

图2.1 Go内存管理模型
图2.1 Go内存管理模型

3. 线程缓存mcache

要分析Go内存模型,需要先弄清楚内存分配相关的几个基本概念,有Page,mspan,Size Class等。

3.1 虚拟内存页 Page

Page,也叫虚拟内存页,表示Go内存管理与操作系统虚拟内存交互内存的最小单元。一个Page的大小是8KB。操作系统虚拟内存对于Go来说,是划分成等分的N个Page组成的一大块公共内存池。

图3.1 虚拟内存是N个Page组成的一大块公共内存池
图3.1 虚拟内存是N个Page组成的一大块公共内存池

3.2 内存管理单元 mspan

多个连续的Page称为一个mspan,mspan 是 Go 内存管理的基本单元。Go内存管理组件是以 mspan 为单位向操作系统虚拟申请内存的。每个mspan记录了第一个起始 Page 的地址 startAddr,和一共有多少个连续 Page 的数量 npages。

为了方便 mspan 和 mspan 之间的管理,mspan 集合是以双向链表的形式构建。

图3.2 mspan代表一块连续的Page
图3.2 mspan代表一块连续的Page

runtime.mspan的源码如下:

代码语言:javascript
复制
// src/runtime/mheap.go
type mspan struct {
	_    sys.NotInHeap
	next *mspan     // 下一个span节点
	prev *mspan     // 上一个span节点 

	startAddr uintptr // span开始地址
	npages    uintptr // span包含的页数
	
	freeindex uintptr  // 空闲对象的索引
	
	nelems uintptr    // span中存放的对象数量

	allocCache uint64    // allocBits 的补码,可以用于快速查找内存中未被使用的内存

	allocBits  *gcBits   // 标记内存的占用
	gcmarkBits *gcBits   // 标记内存的GC回收情况

	spanclass             spanClass     // spanClass规格和是否GC扫描
	state                 mSpanStateBox // 标记mspan的状态,状态可能处于 mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 四种情况,在空闲堆或被分配或处于GC不同阶段,会有不同状态
}

runtime.mspan的主要字段有:

next 和 prev 两个字段,它们分别指向了前一个和后一个 runtime.mspan;

startAddr 和 npages 分别代表mspan管理的堆页的起始地址和数量;

freeindex 和 nelems 分别表示空闲对象的索引和这个mspan中存放的对象数量;

allocBits 和 gcmarkBits 分别用于标记内存的占用和GC回收情况;

allocCache 是 allocBits 的补码,用于快速查找内存中未被使用的内存;

spanclass 表示 mspan所属的大小规格和GC扫描信息,每个mspan都有不同的大小规格,存放小于32KB的不同大小的对象;就像下一小节说的,一个Size Class对应两个spanClass,其中一个Span为存放需要GC扫描的对象(包含指针的对象),另一个Span为存放不需要GC扫描的对象(不包含指针的对象)

state 标记 mspan 的状态,状态可能处于 mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 四种情况,在空闲堆或被分配或处于GC不同阶段,会有不同状态。

3.3 spanClass、Size Class和Object Class

mspan有个字段 spanClass,是跨度类,是对mspan大小级别的划分。

1)提到跨度类spanClass,就不得不提内存刻度进行衡量的 Size Class。Go 对于 32KB 以内的小对象,会将这些小对象按不同大小划分为多个内存刻度 Size Class,是不同大小对象Object Size按顺序排序的序号,如0,1,2,3。每个Size Class都对应一个对象大小即 Object Size,如8B、16B、32B等。在申请小对象内存时,Go 会根据使用方申请的对象大小,就近向上取最接近的一个Object Size,找到其所在的序号Size Class,和所代表的spanClass跨度类的mspan。

Go 内存管理模块中一共包含 68 中内存刻度Size Class,每一个Size Class都会存储特定大小即Object Size的对象,并且包含特定数量的页数npages,Size Class 和 Object Size 及页数 npages 的关系,存储在 runtime.class_to_size 和 runtime.class_to_allocnpages 等变量中:

代码语言:javascript
复制
const (
 ...
 _NumSizeClasses = 68
 ...
)

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}

2)在Go中,spanClass和Size Class的关系是,一个Size Class对应两个spanClass,其中一个Span为存放需要GC扫描的对象(包含指针的对象),另一个Span为存放不需要GC扫描的对象(不包含指针的对象)。

图3.3 Go中一个Size Class对应两个spanClass
图3.3 Go中一个Size Class对应两个spanClass

如图3.3所示,不同对象占用内存大小不同,会申请不同规格的mspan来存放,比如一个对象的大小是14B(向上取最靠近的Object 大小 16B),所属的 mspan 大小是8KB(一页Page),则这个 mspan 会被平均分割为512个Object(8192/16=512),当 Go 程序向 Go内存分配器申请内存,实际上是分配该规格 SizeClass=2(spanClass=4或5)的mspan的一个对象大小的空间出去,剩下512-1=511个对象空间待分配。这里,Size Class和Object Size是一一对应的,只不过一个是序号,一个是具体对象大小

Size Class和spanClass的对应关系参考如下源码:

代码语言:javascript
复制
// src/runtime/mheap.go
type spanClass uint8     // spanClass的个数

const (
        // spanClass的个数是Size Class个数的两倍
	numSpanClasses = _NumSizeClasses << 1
)

// 通过Size Class来得到对应的Span Class,第二个形参noscan表示当前对象是否需要GC扫描
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
	return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

对makeSpanClass()函数的逻辑解释如下表所示:

mspan类型

Size Class与spanClass对应公式

需要GC扫描

spanClass = Size Class * 2 + 0

不需要GC扫描

spanClass = Size Class * 2 + 1

通过Go的源码,可以知道Go内存池固定划分了67个Size Class(算上0是68个),并列举了详细的Size Class和Object大小、存放Object数量,以及每个Size Class对应的Span内存大小关系:

代码语言:javascript
复制
// src/runtime/sizeclasses.go

// 标题Title解释:
// [class]:  Size Class
// [bytes/obj]:  Object Size,一次对外提供内存Object的大小
// [bytes/span]:  当前Object所对应Span的内存大小
// [objects]:  当前Span一共能存放多少个Object
// [tail waste]:  为当前Span平均分层N份Object,会有多少内存浪费
// [max waste]:  当前Size Class最大可能浪费的空间所占百分比

// class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
//     1          8        8192     1024           0     87.50%          8
//     2         16        8192      512           0     43.75%         16
//     3         24        8192      341           8     29.24%          8
//     4         32        8192      256           0     21.88%         32
//     5         48        8192      170          32     31.52%         16
//     6         64        8192      128           0     23.44%         64
//     7         80        8192      102          32     19.07%         16
//     8         96        8192       85          32     15.95%         32

//    35       1408       16384       11         896     14.00%        128
//    36       1536        8192        5         512     14.00%        512
//    37       1792       16384        9         256     15.57%        256
//    38       2048        8192        4           0     12.45%       2048
...
//    65      27264       81920        3         128     10.00%        128
//    66      28672       57344        2           0      4.91%       4096
//    67      32768       32768        1           0     12.50%       8192

每列的含义是:

1)class: Size Class规格; 2)bytes/obj: Object Size,一次对外提供内存Object的大小; 3)bytes/span: 当前Object所对应Span的内存大小; 4)objects: 当前Span一共能存放多少个Object; 5)tail waste: 为当前Span平均分层N份Object,会有多少内存浪费; 6)max waste: 当前Size Class最大可能浪费的空间所占百分比。

以Size Class为35的一行为例,Object Size是1408B,对应的mspan的大小是16KB,占2Page,能存放的Object个数是16KB/1408B=11.636,向下取整,该mspan最多能存11个1408B大小的对象。

如果存的对象大小就是1408B,尾部会浪费16KB-1408B*11 = 896B,即是tail waste尾部浪费列的值。

如果存放的对象是该Object Size为1408B的档位能存放的最小的对象,即上一档位的Object Size+1=1280+1=1281B,则该mspan会产生最大浪费,比例是 [(1408-1281)*11 + 896]B /16KB = 2293/16384=14%,即是max waste最大浪费列的值。

4. 线程缓存mcache

将runtime.mspan的内存结构剖析清楚了,再理解runtime.mcache就会非常简单。

如图4.1所示,runtime.mcache是与Go协程调度模型GPM中的P所绑定,而不是和线程M绑定,因为在Go调度的GPM模型中,真正可运行的线程M的数量与P的数量一致,即GOMAXPROCS个,跟P绑定节省了P移动到其他M上去的mcahe的切换开销。每个G使用MCache时不需要加锁就可以获取到内存。

图4.1 runtime.mcache 与 P 绑定
图4.1 runtime.mcache 与 P 绑定

runtime.mcache的源码是:

代码语言:javascript
复制
// src/runtime/mcache.go
type mcache struct {
	
        // 分配tiny对象的参数
	tiny       uintptr      // 申请tiny对象的起始地址
	tinyoffset uintptr   // 从tiny地址开始的偏移量 
	tinyAllocs uintptr  // tiny对象分配的数量

	alloc [numSpanClasses]*mspan // 待分配的mspan列表,通过spanClass索引

}

runtime.mcache的字段主要包含两部分:

tiny,tinyoffset,tinyAllocs是跟tiny微对象分配相关的参数;

alloc 是待分配的mspan列表,不同规格的mspan通过spanClass值索引。

如图4.2所示,是runtime.mcache的内存结构,主要包含两部分的内容,Tiny对象的分配空间,和alloc列表代表的对于小对象的分配空间,其实是由0到135(28*2+1)个spanClass规格大小的mspan组成的列表。

图4.2 mcache内存结构
图4.2 mcache内存结构

每一个线程缓存runtime.mcache都持有 68 * 2 个 runtime.mspan,这些内存管理单元都存储在结构体的 alloc 字段中。

5. 中心缓存mcentral

当mcache中某个Size Class对应的Span的一个个Object被应用分配走后,如果出现当前Size Class的mspan空缺情况,mcache则会向mcentral申请对应的mspan。

图5.1 mcache在内存不足时向中心缓存mcentral申请资源
图5.1 mcache在内存不足时向中心缓存mcentral申请资源

runtime.mcentral的源码如下:

代码语言:javascript
复制
// src/runtime/mcentral.go

//给定Size Class规格的中央缓存mcentral
type mcentral struct {
	// 该mcentral的spanClass规格大小
	spanclass spanClass
	partial [2]spanSet // 维护全部空闲的span集合,partial有两个spanSet集合,其中一个是GC已经扫描的,一个是GC未扫描的
	full    [2]spanSet  //  维护已经被使用的span集合,full也有两个spanSet集合,一个GC已扫描,一个未扫描
}

runtime.mcentral有个字段spanclass,代表这个mcentral的类型,不同的Size Class规格的mspan对应有不同的 runtime.mcentral管理。Size Class总共有0,8B,16B,24B,32B 到 32KB 共 68 种,因此 Go 内存分配器的中央缓存模型MCentral 总共有排除掉 0 的 67 种runtime.mcentral。

partial 和 full 分别维护空闲的 mspan 集合,和已经被使用的 mspan 集合。mcache 向 mcentral 申请资源,当然是从 partial 集合获取。partial 和 full 都是一个[2]spanSet类型,也就每个 partial 和 full 都各有两个 spanSet 集合,这是为了给 GC 垃圾回收来使用的,其中一个集合是已扫描的,另一个集合是未扫描的。

spanSet 简单理解是 mspan 的集合,详细理解需要看它的源码:

代码语言:javascript
复制
// src/runtime/mspanset.go

// spanSet是并发安全的存取mspan的集合,本质是个二级数据结构,一级是index字段存储指向spanSetBlock的索引,第二级是spanSetBlock存储指向512个mspan数组的索引
type spanSet struct {
	
	spineLock mutex    // 锁,往spanSet放入push和获取pop mspan需要加锁
	spine     atomicSpanSetSpinePointer // 指向spanSetBlock集合的地址
	spineLen  atomic.Uintptr            // spanSetBlock集合的 length
	spineCap  uintptr                     // spanSetBlock集合的 cap

	index atomicHeadTailIndex   // 指向spanSetBlock集合的头尾指针组成的一个int64位的数字
}

spanSet 是并发安全的存取 mspan 的集合,实际上是个二级数据结构,一级是 index 字段存储指向 spanSetBlock 数据块的索引,第二级是 spanSetBlock 存储指向 512 个 mspan 数组的索引。

图5.2 mcentral中的spanSet内存结构
图5.2 mcentral中的spanSet内存结构

如图5.2所示,是 runtime.mcentral 中的 spanSet 的内存结构,index 字段是一个uint64类型数字的地址,该uint64的数字按32位分为前后两半部分head和tail,向spanSet中插入和获取mspan有其提供的push()和pop()函数,以push()函数为例,会根据index的head,对spanSetBlock数据块包含的mspan的个数512取商,得到spanSetBlock数据块所在的地址,然后head对512取余,得到要插入的mspan在该spanSetBlock数据块的具体地址。之所以是512,因为spanSet指向的spanSetBlock数据块是一个包含512个mspan的集合。

如图5.3所示,是中央缓存模型MCentral的内存结构,是由全部spanClass规格的runtime.mcentral共同组成。

图5.3 MCentral中央缓存模型,由全部spanClass规格的runtime.mcentral共同组成
图5.3 MCentral中央缓存模型,由全部spanClass规格的runtime.mcentral共同组成

6. 页堆mheap

先看runtime.mheap的源码:

代码语言:javascript
复制
// src/runtime/mheap.go
// 页堆mheap
type mheap struct {
	lock mutex   // mheap的锁

	pages pageAlloc // 页分配数据结构

	allspans []*mspan // 所有的 spans 都是通过 mheap_ 申请,所有申请过的 mspan 都会记录在 allspans,可以随着堆的增长重新分配和移动

        // heapArena二维数组集合
	arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
        ...
        // arenas的地址集合,用来管理heapArena的增加
	arenaHints *arenaHint
        ...
	// 全部规格的mcental集合,中央缓存MCentral本身也是MHeap的一部分
	central [numSpanClasses]struct {
		mcentral mcentral
		pad      [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
	}
        // 各种分配器
	spanalloc             fixalloc // span* 分配器
	cachealloc            fixalloc // mcache* 分配器
	specialfinalizeralloc fixalloc // specialfinalizer* 分配器
	specialprofilealloc   fixalloc // specialprofile* 分配器
	specialReachableAlloc fixalloc // specialReachable 分配器
	speciallock           mutex    // 特殊记录分配器的锁
	arenaHintAlloc        fixalloc // allocator for arenaHints
        ...
}

runtime.mheap页堆主要包含两种数据结构arenas和central:

arenas是heapArena的二维数组的集合;

central是全部规格的中央缓存runtime.central的集合,中央缓存层MCentral本身也是页堆mheap的一部分。

heapArena类型, 用来存储 heap arena 元数据:

代码语言:javascript
复制
const(
     pageSize = 8192  // 1<<13=8K
     heapArenaBytes = 67108864 //一个heapArena是64MB
     heapArenaWords = heapArenaBytes / 8  // 64位的Linux系统,一个heapArena有8M个word,一个word占8个字节
     heapArenaBitmapWords = heapArenaWords /  // 一个heapArena的bitmap占用8M/64=131072,即128K
     pagesPerArena = heapArenaBytes / pageSize  // 一个heapArena包含8192个页
)

type heapArena struct {
	// bitmap 中每个bit标记arena中的一个word
	bitmap [heapArenaBitmapWords]uintptr
        // bitmap的8个比特表示一个字长,这个字长是否包含指针用下面的数组记录
        noMorePtrs [heapArenaBitmapWords / 8]uint8
	// 记录当前arena中每一页对应到哪一个mspan
	spans [pagesPerArena]*mspan
	// 位图类型,标记哪些spans 处于 mSpanInUse 状态
	pageInUse [pagesPerArena / 8]uint8
	// 位图类型,记录哪些 spans 已被标记
	pageMarks [pagesPerArena / 8]uint8
	// 位图类型,指哪些spans有specials (finalizers or other)
	pageSpecials [pagesPerArena / 8]uint8
	// 零基地址,标记arena页中首个未被使用的页的地址
	zeroedBase uintptr
}

Go1.20的runtime.heapArena和Go1.18之前的有不同的是bitmap的一个比特位表示一个word字,而不是一个字节Byte,在Linux 64系统中,一个heapArena管理的内存大小是64MB,那么用一个比特位指代一个字长,则需要64MB/(8*64)=128K个Bit,而且一个占有8Bword的对象是否有指针是用另一个字段noMorePtrs来记录的,而不是都在bitmap中体现。

runtime.heapArena的最重要的字段依然是spans,表示一页arena对应的是哪个mspan。

如图6.1所示,是runtime.mheap的内存结构。

图6.1 mheap内存结构
图6.1 mheap内存结构

7. 微、小、大对象分配过程

Go中根据对象大小分为三种对象类型:

对象类型

对象大小

tiny微对象

< 16B

small小对象

[16B, 32KB]

large大对象

> 32KB

不同大小的对象有不同的内存分配机制。

7.1 微对象的分配过程

Go 将小于 16 字节的对象划分为微对象,它会使用线程缓存上的微分配器Tiny allocator提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合并存入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。

微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize 是可以调整的,在默认情况下,内存块的大小为 16 字节。maxTinySize 的值越大,组合多个对象的可能性就越高,内存浪费也就越严重;maxTinySize 越小,内存浪费就会越少,不过无论如何调整,8 的倍数都是一个很好的选择。

如图7.1所示,微对象的 16B 内存空间从 spanClass 为 4 或 5(无GC扫描)的mspan中获取,因为spanClass为2或3的mspan的Object大小是8B,而不是16B。

图7.1 微对象的 16B 内存空间从 spanClass 为 4 或 5(无GC扫描)的mspan中获取
图7.1 微对象的 16B 内存空间从 spanClass 为 4 或 5(无GC扫描)的mspan中获取

如图7.2所示,是以bool变量申请1B内存为例说明的Tiny微对象的分配过程。

图7.2 微对象分配过程
图7.2 微对象分配过程

微对象分配的具体源码如下:

代码语言:javascript
复制
// src/runtime/malloc.go
// 堆上所有的对象都会通过调用runtime.newobject函数分配内存,该函数会调用runtime.mallocgc()函数
// 根据对象的大小(字节数)来分配内存,微对象、小对象从每个P的mcache本地缓存分配,大对象从堆分配
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	......
	// 获取当前G所在的M线程
	mp := acquirem()
	.....
        // 获取M的mcache本地缓存
	c := getMCache(mp)
	if c == nil {
		throw("mallocgc called without a P or outside bootstrapping")
	}
	var span *mspan
	var x unsafe.Pointer
        // noscan变量记录对象是否包含指针,true为不包含
	noscan := typ == nil || typ.ptrdata == 0
	
	if size <= maxSmallSize {   // size是对象的字节数,如果对象小于 32KB
		if noscan && size < maxTinySize {  // 如果对象小于16B且不包含指针
			// 这里有一段注释,说明微分配器 Tiny allocator 的内存空间大小为何是16B.
			// Tiny内存空间越大,如32B,小对象(小字符串、数字、布尔等)组合的可能性越大,浪费也越严重,综合考虑,16B是合适的,

                        // 一组json benchmark测试显示,通过Tiny分配器,可以减少12%的分配次数和20%的堆大小;
			// 获取mcache的变量tinyoffset,表示Tiny空间空闲的位移
			off := c.tinyoffset
			// 内存对齐
			if size&7 == 0 {  // size 在 (0, 16) 范围,如果低三位是0,则跟8位对齐
				off = alignUp(off, 8)
			} else if goarch.PtrSize == 4 && size == 12 {
				// 地址长度是4,字节数是12,也是跟8位对齐
				off = alignUp(off, 8)
			} else if size&3 == 0 { // size低二位是0,跟4位对齐
				off = alignUp(off, 4)
			} else if size&1 == 0 { // size低一位是0,跟2位对齐
				off = alignUp(off, 2)
			}
			if off+size <= maxTinySize && c.tiny != 0 {
				// 如果对象的大小和off偏移量加起来仍然比16B小,且Tiny空间存在,则当前对象可以从已有的Tiny空间分配
                                // 在当前的Tiny空间分配,增加相关变量大小 
				x = unsafe.Pointer(c.tiny + off)
				c.tinyoffset = off + size
				c.tinyAllocs++
				mp.mallocing = 0
				releasem(mp)
				return x    // 分配后直接返回
			}
			// 从当前P的mcache的spanClass为4或5的mspan中,申请一个新的16B Object的 Tiny空间
			span = c.alloc[tinySpanClass]
			v := nextFreeFast(span)
			if v == 0 {
				v, span, shouldhelpgc = c.nextFree(tinySpanClass)
			}
			x = unsafe.Pointer(v)
                        // 将申请的16B内存空间置0,16B由两个长度为uint64地址指向
			(*[2]uint64)(x)[0] = 0
			(*[2]uint64)(x)[1] = 0
			// 非竞态下,如果申请的内存块分配了新对象后,将剩下的给 tiny,用 tinyoffset 记录分配了多少
			if !raceenabled && (size < c.tinyoffset || c.tiny == 0) {
				
				c.tiny = uintptr(x)
				c.tinyoffset = size
			}
			size = maxTinySize
		} else {
			// 小对象分配逻辑
                        .....
		}
	} else {
		// 大对象分配逻辑
                ......
	}
        ......
}

7.2 小对象的分配过程

对于对象在16B至32KB的内存分配,Go会采用小对象的分配流程。

图7.3 小对象内存分配流程
图7.3 小对象内存分配流程

如图7.3所示是小对象的内存分配流程:

1)首先是Goroutine用户程序向Go内存管理模块申请一个对象所需的内存空间;

2)Go内存管理模块的runtime.mallocgc()函数检查传入的对象大小是否大于32KB,如果是则进入大对象分配逻辑,不是则接着往下走;

3)如果对象小于32KB,接着判断对象是否小于16B,如果是,则进入Tiny微对象分配逻辑,否则进入小对象分配逻辑;

4)根据对象的字节数Size匹配得到对应的Size Class内存规格,再根据Size Class和对象是否包含指针得到spanClass,从mcache的对应spanClass的mspan中获取所需内存空间;

5)在找到的mcache的对应spanClass的mspan中如果有空闲的内存空间,则直接获取并返回;

6)如果定位的mcache的对应spanClass的mspan中没有空闲内存空间,则mcache会向MCentral中央缓存层对应spanClass的mcental申请一个mspan;

7)MCentral层收到mcache的内存申请请求后,优先从相对应的spanClass的partial Set空闲集合中取出mspan,partial Set没有则从full Set非空闲集合中获取,取到了则返回给mcache;

8)mcache得到了mcentral 返回的mspan,补充到自己对应的spanClass列表中,返回第5)步,获取内存空间,结束流程;

9)如果mcentral的partial Set 和 full Set都没有符合条件的mspan,则MCentral层会向MHeap页堆申请内存;

10)MHeap收到内存请求从其中一个heapArena从取出一部分Pages返回给MCentral,当MHeap没有足够的内存时,MHeap会向操作系统申请内存,将申请的内存也保存到heapArena中的mspan中。MCentral将从MHeap获取的由Pages组成的mspan添加到对应的spanClass集合中,作为新的补充,之后再次执行第(7)步。

11)最后,Goroutine用户程序获取了对象所需内存,流程结束。

runtime.mallocgc()函数中关于小对象分配的源码如下:

代码语言:javascript
复制
// Allocate an object of size bytes.
// Small objects are allocated from the per-P cache's free lists.
// Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	......
	// 获取线程M
	mp := acquirem()
	.....
        // 获取G所在P的mcache
	c := getMCache(mp)
	......
        // 对象是否包含指针
	noscan := typ == nil || typ.ptrdata == 0
	......
	if size <= maxSmallSize {   // 对象小于32KB
		if noscan && size < maxTinySize {    // 对象小于16B
			......
		} else {     // 对象在(16B, 32KB),进入小对象分配流程
			var sizeclass uint8
                        // 根据对象大小获取对象所属的Size Class
			if size <= smallSizeMax-8 {
				sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
			} else {
				sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
			}
			size = uintptr(class_to_size[sizeclass])
                        // 根据Size Class和对象是否包含指针,获取所属的spanClass
			spc := makeSpanClass(sizeclass, noscan)
                        // 从mcache中获取对应spanClass的mspan
			span = c.alloc[spc]
                        // 从mpsan分配一个空闲的Object
			v := nextFreeFast(span)
			if v == 0 {
                                // mcache没有空闲的Object,则从mcentral申请可用的mspan给到mcache
				v, span, shouldhelpgc = c.nextFree(spc)
			}
                        // 这时,mcache中有了空闲的Object空间,获取并返回
			x = unsafe.Pointer(v)
			if needzero && span.needzero != 0 {
				memclrNoHeapPointers(x, size)
			}
		}
	} else { // 大对象分配流程
		......
	}
        ......
	return x
}

注释中整体的流程对应前面的流程图,需要继续补充说明的是mcache.nextFree()函数,其作用是在mcache的mspan没有空闲的Object时,会通过该函数从mcentral获取:

代码语言:javascript
复制
// src/runtime/malloc.go
// 从当前的mspan获取下一个空闲的Object空间,如果没有则从mcentral获取,否则从mheap获取
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
	s = c.alloc[spc]
	shouldhelpgc = false
        // 当前mspan找到空闲的Object空间的index索引
	freeIndex := s.nextFreeIndex()
	if freeIndex == s.nelems {  // 如果mspan已满
		// 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")
		}
                // 从mcentral获取可用的mspan,并替换当前的mcache的mspan
		c.refill(spc)
		shouldhelpgc = true
		s = c.alloc[spc]
                // 再次到新的mspan里查找空闲的Object索引
		freeIndex = s.nextFreeIndex()
	}

	if freeIndex >= s.nelems {
		throw("freeIndex is not valid")
	}
        // 计算要使用的Object内存块在mspan中的地址,并返回该mspan
	v = gclinkptr(freeIndex*s.elemsize + s.base())
	s.allocCount++
	if uintptr(s.allocCount) > s.nelems {
		println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
		throw("s.allocCount > s.nelems")
	}
	return
}

mcache.nextFree()函数会检查当前mspan是否有空闲的Object内存块,如果满了就调用mcache.refill()方法从 mcentral 中获取可用的mspan,并替换掉当前 mcache里面的 mspan。至于mcache.refill()方法的具体逻辑,这里不再赘述。

7.3 大对象的分配过程

从runtime.mallocgc()函数可以看到,大对象的内存分配主要通过调用mcache.allocLarge()函数实现:

代码语言:javascript
复制
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	......
	// 获取当前mcache
	c := getMCache(mp)
	......
        // 对象是否包含指针
	noscan := typ == nil || typ.ptrdata == 0
	......
	if size <= maxSmallSize {   // 对象小于32KB
		if noscan && size < maxTinySize {
			......
		} else {
			......
		}
	} else {  // 大对象内存分配逻辑
		shouldhelpgc = true
		// 调用mcache.allocLarge()根据对象的大小size和是否包含指针分配大内存mspan
		span = c.allocLarge(size, noscan)
		span.freeindex = 1
		span.allocCount = 1
		size = span.elemsize
		x = unsafe.Pointer(span.base())
		......
	}
        ......
}

mcache.allocLarge()函数主要根据对象的大小获取要分配的页数npages,然后调用mheap.alloc()函数从mheap中直接分配npages页的mspan:

代码语言:javascript
复制
// src/runtime/mcache.go
// allocLarge()函数为大对象申请一个mspan
func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {
        // Linux 64位系统下_PageSize为8K,如果对象太大发生溢出,甩出错误
	if size+_PageSize < size {
		throw("out of memory")
	}
        // _PageShift=13,2的13次方是8192,用对象大小size/8192得到需要分配的页数npages
	npages := size >> _PageShift
        // 页数npages不是整数,多出来一些小数,加1
	if size&_PageMask != 0 {
		npages++
	}
        ......
	// 从mheap页堆上分配npages页Size Class为0的mspan,spanClass根据对象是否包含指针为0或1
	spc := makeSpanClass(0, noscan)
	s := mheap_.alloc(npages, spc)
	if s == nil {
		throw("out of memory")
	}
        ......
	return s
}

mheap.alloc()函数主要在系统栈上调用mheap.allocSpan()获取具体的npages页mspan:

代码语言:javascript
复制
// src/runtime/mheap.go
// alloc()函数从GC的堆上分配npages页的mspan
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
	var s *mspan
	systemstack(func() {
		// 为了阻止额外的堆增长,如果GC扫描未结束,在分配前需要先回收npages个页的内存
		if !isSweepDone() {
			h.reclaim(npages)
		}
                // 调用mheap.allocSpan()获取具体的npages页mspan
		s = h.allocSpan(npages, spanAllocHeap, spanclass)
	})
	return s
}

mheap.allocSpan()是获取大对象所需内存页的具体实现:

代码语言:javascript
复制
// src/runtime/mheap.go
func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
	// 获取当前G
	gp := getg()
	base, scav := uintptr(0), uintptr(0)
	growth := uintptr(0)

	// 在某些平台上,需要进行物理页对齐
	needPhysPageAlign := physPageAlignedStacks && typ == spanAllocStack && pageSize < physPageSize

	// 当对象足够小,且无需进行物理页对齐,优先从本地P的缓存分配内存
	pp := gp.m.p.ptr()
	if !needPhysPageAlign && pp != nil && npages < pageCachePages/4 {
		c := &pp.pcache     

		// 本地P的缓存为空,从mheap分配缓存pageCache给它
		if c.empty() {
			lock(&h.lock)
			*c = h.pages.allocToCache()
			unlock(&h.lock)
		}

		// 从本地P的缓存pageCache获取npages页内存
		base, scav = c.alloc(npages)
		if base != 0 {
			s = h.tryAllocMSpan()
			if s != nil {   // 获取到了内存,调整到HaveSpan位置
				goto HaveSpan
			}
		}
	}

	lock(&h.lock)

	if needPhysPageAlign {  // 需要物理页对齐
		// 进行物理页对齐需要增加的额外页数
		extraPages := physPageSize / pageSize

               // 从mheap页堆的pages页分配数据结构中查找所需物理对齐后的页数
		base, _ = h.pages.find(npages + extraPages)
		if base == 0 {    // 如果没有获取到,调用mheap.grow()函数增长页堆
			var ok bool
			growth, ok = h.grow(npages + extraPages)
			if !ok {
				unlock(&h.lock)
				return nil
			}
			base, _ = h.pages.find(npages + extraPages)
			if base == 0 {
				throw("grew heap, but no adequate free space found")
			}
		}
               // 将获取的内存地址base进行物理页对齐,并获取内存大小scav
		base = alignUp(base, physPageSize)
		scav = h.pages.allocRange(base, npages)
	}

	if base == 0 {   // 如果在上面两种情况下,依然没有获取到所需内存页
		// 从mheap的pages页分配数据结构中获取
		base, scav = h.pages.alloc(npages)
		if base == 0 { // 内存不够,调用mheap.grow()扩容
			var ok bool
			growth, ok = h.grow(npages)
			if !ok {
				unlock(&h.lock)
				return nil
			}
                        // 重新从mheap的pages页分配数据结构中获取内存
			base, scav = h.pages.alloc(npages)
                        // 内存还是不足,则抛出异常
			if base == 0 {
				throw("grew heap, but no adequate free space found")
			}
		}
	}
	if s == nil {
		// 分配一个mspan对象
		s = h.allocMSpanLocked()
	}
	unlock(&h.lock)

HaveSpan:
        .....
	// 初始化得到的 mspan,并将其与mheap关联起来
	h.initSpan(s, typ, spanclass, base, npages)
        .....
	return s
}

mheap.allocSpan() 函数的主要逻辑是:

1)根据平台差异,检查对象所需内存是否需要进行物理页对齐;

2)当无需进行物理页对齐,且对象足够小,即小于pageCachePages/4=64/4=16页时,优先从本地P的缓存分配内存;此时,如果本地P的缓存为空,从mheap分配缓存pageCache给它,然后再获取所需内存;

3)当需要物理页对齐,从mheap页堆的pages页分配数据结构中查找(mheap.pages.find函数)所需物理对齐后的页数,如果没有获取到,调用mheap.grow()函数扩容mheap,之后再获取;

4)如果在上面两种情况下,依然没有获取到所需内存页,则从mheap的pages页分配数据结构中获取(mheap.pages.alloc函数),如内存不够,则调用mheap.grow()扩容,之后,再重新从mheap的pages页分配数据结构中获取内存;

5)获取了所需内存页后,分配一个新的mspan,初始化相关参数,并将二者绑定,结束流程。

8. 总结

Go内存分配器中使用到的多级缓存机制,是程序开发中常用的设计理念,将对象根据大小分为不同规格,通过提高数据局部性和细粒度内存的复用率,能够有效提升不同大小对象的内存分配的整体效率。

Reference

一站式Golang内存管理洗髓经 https://www.yuque.com/aceld/golang/qzyivn#a7sjw

内存分配器 https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/#%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86%E5%8D%95%E5%85%83

内存分配 https://golang.design/under-the-hood/zh-cn/part2runtime/ch07alloc/

golang 学习之路 -- 内存分配器 https://xie.infoq.cn/article/e760c46349bd7b443e38ac332

详解Go中内存分配源码实现 https://www.luozhiyun.com/archives/434

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 结论
  • 1. TCMalloc内存分配思想
  • 2. Go内存分配相关概念
  • 3. 线程缓存mcache
    • 3.1 虚拟内存页 Page
      • 3.2 内存管理单元 mspan
        • 3.3 spanClass、Size Class和Object Class
        • 4. 线程缓存mcache
        • 5. 中心缓存mcentral
        • 6. 页堆mheap
        • 7. 微、小、大对象分配过程
          • 7.1 微对象的分配过程
            • 7.2 小对象的分配过程
              • 7.3 大对象的分配过程
              • 8. 总结
              • Reference
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档