本章节,就来学习一下go语言的内存模型,看一下内存的分配,存储都是如何实现的,与此同时,在正式开始今天的主题之前,首先先来学习操作系统基于这一方面的内容,来看看是如何管理内存的吧
本章及节内容参考小徐先生和刘丹冰老师的内容,加上一些个人注解,go语言版本是1.24.1,由于文章的内容是在语雀,这里就附上我的语雀链接,方便大家更好的查看https://www.yuque.com/chenxiangyang-n12yg/pli7v5/eadkhxswioy3w746?singleDoc# 《内存管理》
一.内存管理机制
1.1 操作系统的存储模型
在学习go语言的内存模型之前,先来熟悉一下操作系统金典的多级存储模型,如上图所示,差不多大家都清楚这一些东西。
1.2 虚拟内存和物理内存
虚拟内存:是一种内存管理技术,它为每一个进程提供了一个非常大的,一致的和独立且连续的地址空间。虚拟内存通过地址翻译硬件和页表,提供了内存保护和简化内存管理的能力,将物理内存和磁盘空间结合管理
它的作用如下:
- 在用户与硬件间添加中间代理层(没有什么是加一个中间层解决不了的)
- 优化用户体验(进程感知到获得的内存空间是“连续”的)
- “放大”可用内存(虚拟内存可以由物理内存+磁盘补足,并根据冷热动态置换,用户无感知)
物理内存:是指计算机硬件实际的内存,即RAM(随机存取存储器)直接和CPU交互,用于临时存储运行中的程序和数据。
特点:
- 有限性:容量受硬件限制(如8GB、16GB等)
- 高速访问:读写速度远超磁盘,但断电后数据丢失。
- 直接寻址:CPU通过物理地址直接访问内存单元。
1.3 分页管理
操作系统中通常会将虚拟内存和物理内存切割成固定的尺寸,于虚拟内存而言叫作“页”,于物理内存而言叫作“帧”,原因及要点如下:
• 提高内存空间利用(以页为粒度后,消灭了不稳定的外部碎片,取而代之的是相对可控的内部碎片)
• 提高内外存交换效率(更细的粒度带来了更高的灵活度)
• 与虚拟内存机制呼应,便于建立虚拟地址->物理地址的映射关系(聚合映射关系的数据结构,称为页表)
• linux 页/帧的大小固定,为 4KB(这实际是由实践推动的经验值,太粗会增加碎片率,太细会增加分配频率影响效率)
二.Golang的内存管理机制
前面的一小节对操作系统的内存模型,做了一个简单的介绍,如果想要了解更多这一方面的知识可以上网查询一下,下面将迈入正题,来看看golang世界中内存模型的设计
2.1 golang的内存管理的模型图
首先先来看一下go语言的内存模型长什么样,然后我们在进一步去了解
看完它的大致样子之后,我们来介绍介绍这些东西都是什么:
- mheap:全局的内存起源,访问要加全局锁
- mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内
- mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁
这些内容,我们会在后续详细展开说明
Golang内存模型设计的几个核心要点
- 以时间换空间,一次缓存,多次使用
首先我们要做到,每次向操作系统申请空间的操作都是很重的,那不妨一次性咱多要一点,以备后续使用。
Golang中的mheap正是基于这种思想,产生的数据结构。接下来我们从两个不同的角度去看这个堆:
- 对于操作系统而言:这个堆就是用户进程中缓存的内存。
- 对于Go进程内部:堆则是所有对象的起源。
- 多级缓存,以实现无/细锁化
为什么要设置多级缓存?
实际上,堆是Go运行时中最大的临界内存资源,这意味着每次存取都要加锁,在性能层面上看是一件非常可怕的事情.
正是为了解决这个问题,Golang在堆mheap上,做了更加细微的处理,建立了 mcentral、mcache。
通过设置不同规格的mcentral,从而实现对不同大小的对象进行管理,分开加锁,避免了直接对mheap直接加锁导致的性能问题。
- 多级规格,提高利用率
首先先来了解一下page和mspan这两个概念:
- page:最小的存储单元
Golang 借鉴操作系统分页管理的思想,每个最小的存储单元也称之为页 page,但大小为 8 KB
- mspan:最小的管理单元
mspan 大小为 page 的整数倍,且从 8B 到 80 KB 被划分为 67 种不同的规格,分配对象时,会根据大小映射到不同规格的 mspan,从中获取空间,实际上有一个更大的规格,所以说是68种,后续会在涉及
为什么要将mspan划分为多个规格呢?
- 有了规格化,便产生了等级制度,有了等级,才支持mcentral实现细琐化
- 消除了外部碎片,但是不能避免内部碎片,宏观上提高了整体空间的利用率
可以看一下整体的架构图,我们后续会不断对细节进行一个补充的。
2.2 内存单元 mspan
mspan,其实就是类似双向链表中的一个结点,我们来看下mspan有那些特点:
- mspan 是 Golang 内存管理的最小单元
- mspan 大小是 page 的整数倍(Go 中的 page 大小为 8KB),且内部的页是连续的(至少在虚拟内存的视角中是这样)
- 每个 mspan 根据空间大小以及面向分配对象的大小,会被划分为不同的等级(2.2小节展开)
- 同等级的 mspan 会从属同一个 mcentral,最终会被组织成链表,因此带有前后指针(prev、next)
- 由于同等级的 mspan 内聚于同一个 mcentral,所以会基于同一把互斥锁管理
- mspan 会基于 bitMap 辅助快速找到空闲内存块(块大小为对应等级下的 object 大小),此时需要使用到 Ctz64 算法.
type mspan struct {// 标识前后节点的指针 next *mspan prev *mspan // ...// 起始地址startAddr uintptr // 包含几页,页是连续的npages uintptr // 标识此前的位置都已被占用 freeindex uintptr// 最多可以存放多少个 objectnelems uintptr // number of object in the span.// bitmap 每个 bit 对应一个 object 块,标识该块是否已被占用allocCache uint64// ...// 标识 mspan 等级,包含 class 和 noscan 两部分信息spanclass spanClass // ...
}
2.3 内存单元等级 spanClass
mspan根据空间大小和面向分配对象的大小,被划分为67种不同的等级(1-67,实际上还有一种隐藏的0级,用于处理更大的对象,上不封顶)
实际上对于不同规格的等级,都会产生对应的浪费,简单说一下这个浪费是怎么造成的,比如:
此时我们创建了一个对象,它的大小是1b,但是最小的mspan是8b,他不会分配1b大小的mspan,所以就会导致7b被浪费掉,比如这样:
除了上面谈及的根据大小确定的 mspan 等级外,每个 object 还有一个重要的属性叫做 noscan,标识了 object 是否包含指针,在 gc 时是否需要展开标记.
(这里做一个简单的拓展:Go的垃圾回收器GC需要找到存活的数据,为此GC会从根对象比如栈,全局变量,出发,遍历所有可以到达的对象,过程是标记所有存活对象,检查每个对象的内部字段,递归标记其引用的对象,如果对象不包含指针,扫描它就是多余,反而浪费CPU时间,所有noscan作用就是告诉GC,这家伙没有指针,跳过扫描。)
在 Golang 中,会将 span class + noscan 两部分信息组装成一个 uint8,形成完整的 spanClass 标识. 8 个 bit 中,高 7 位表示了上表的 span 等级(总共 67 + 1 个等级,8 个 bit 足够用了),最低位表示 nocan 信息.
2.4 线程缓存 mcache
要点:
(1)mcache 是每个 P 独有的缓存,因此交互无锁
(2)mcache 将每种 spanClass 等级的 mspan 各缓存了一个,总数为 2(nocan 维度) * 68(大小维度)= 136
(3)mcache 中还有一个为对象分配器 tiny allocator,用于处理小于 16B 对象的内存分配
const numSpanClasses = 136
type mcache struct {// 微对象分配器相关tiny uintptrtinyoffset uintptrtinyAllocs uintptr// mcache 中缓存的 mspan,每种 spanClass 各一个alloc [numSpanClasses]*mspan // ...
}
这里可能大家大家可能会疑惑为什么是136个大小,而不是68个,从图中不难看出它分为了scan和noscan两种,他表示是否含有指针,在spanclass里说过,这个指针就是告诉GC要不要扫描,如果我直接在他的上层直接将其分开,也就是有scan在一块,没有的在另外一块,这样就可以提高GC的效率,不用每次都去扫描mspan,判断有无指针了。
mcache具体的申请形式
通过alloc区分出了136个指针,指向对应的mspan,用完时,会重新向mcentral申请,并更新alloc中的指针哦。
2.5 中心缓存 mcentral
要点:
(1)每个 mcentral 对应一种 spanClass
(2)每个 mcentral 下聚合了该 spanClass 下的 mspan
(3)mcentral 下的 mspan 分为两个链表,分别为有空间 mspan 链表 partial 和满空间 mspan 链表 full
(4)每个 mcentral 都有一把属于自己的锁
type mcentral struct {_ sys.NotInHeap// 对应的 spanClassspanclass spanClass// 有空位的 mspan 集合,数组长度为 2 是用于抗一轮 GCpartial [2]spanSet // 无空位的 mspan 集合full [2]spanSet
}
简单的说一下这两个链表的作用:
内存分配的时候,mcache从partical链表获取mspan,当其mcache需要新的mspan时(例如mspan已满),会向mcentral请求。mcentral会优先从partial链表中取出一个mspan,返回给mcache
对于这个已满的mspan会归还到mcache的full链表。
当 GC 扫描并释放某些对象时,原本在 full
链表中的 mspan
可能重新获得空闲对象。此时,这些 mspan
会从 full
链表移回 partial
链表。
2.6 全局堆缓存 mheap
- 对于 Golang 上层应用而言,堆是操作系统虚拟内存的抽象
- 以页(8KB)为单位,作为最小内存存储单元
- 负责将连续页组装成 mspan
- 全局内存基于 bitMap 标识页使用情况,每个 bit 对应一页,为 0 则自由,为 1 则已被 mspan 组装(也就是说全部的页是否使用,都会在一个巨大的bitmap里面标记)
- 通过 heapArena 聚合页,记录了页到 mspan 的映射信息(后续2.8小节展开)
- 建立空闲页基数树索引 radix tree index,辅助快速寻找空闲页(后续2.7小节展开)
- 是 mcentral 的持有者,持有所有 spanClass 下的 mcentral,作为自身的缓存
- 内存不够时,向操作系统申请,申请单位为 heapArena(64M)
内存的包含形式
这里做一个简简单单的说明哈,可能之前的图会误导大家,就是mcache和mcentral都是mheap的一部分哦,可不是分开的。
type mheap struct {// 堆的全局锁lock mutex// 空闲页分配器,底层是多棵基数树组成的索引,每棵树对应 16 GB 内存空间pages pageAlloc // 记录了所有的 mspan. 需要知道,所有 mspan 都是经由 mheap,使用连续空闲页组装生成的allspans []*mspan// heapAreana 数组,64 位系统下,二维数组容量为 [1][2^22]// 每个 heapArena 大小 64M,因此理论上,Golang 堆上限为 2^22*64M = 256T// 通过这个来间接管理bitmaparenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena// ...// 多个 mcentral,总个数为 spanClass 的个数central [numSpanClasses]struct {mcentral mcentral// 用于内存地址对齐pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte}// ...
}
2.7 空闲页索引 pageAlloc
这一小节的内容主要讲解空闲页寻址分配的计数树索引有关的内容,可能比较苦涩难懂,就做一个简单的介绍吧,gogogo出发咯。
pageAlloc的底层是基数树组成的一个索引,它是Go语言高效管理大内存空间的核心机制。尤其是在空闲页的快速寻址和分配中。这一设计在 Google 的提案文档(Scaling the Page Allocator)中有详细描述。
接下来我会做一个简单的介绍
为什么要使用基数树(radix tree),而不采用传统的bitmap呢?
- 主要就是现代程序的内存都是TB级别,传统的bitmap扫描算法时间复杂度为O(N),无法满足需求
- 通过基数树可以降低时间复杂度为O(1)或O(logN)
该数据结构的一些必备知识点
- 一颗基数树的大小为16GB,聚合了内存中各页的使用情况,帮助mheap快速找到指定长度的连续的空闲页所在的位置
- mheap内存的上限为256TB,所以它不是单单由一颗基数树组成,而是森林,一共有2的14次方棵。
- mheap会对被使用过的页进行标记,1表示已使用,0表示空闲
基数树的节点设置
基数树中,每个节点称之为 PallocSum,是一个 uint64 类型,体现了索引的聚合信息,包含以下四部分:
- start:最右侧 21 个 bit,标识了当前节点映射的 bitMap 范围中首端有多少个连续的 0 bit(空闲页),称之为 start;
- max:中间 21 个 bit,标识了当前节点映射的 bitMap 范围中最多有多少个连续的 0 bit(空闲页),称之为 max;
- end:左侧 21 个 bit,标识了当前节点映射的 bitMap 范围中最末端有多少个连续的 0 bit(空闲页),称之为 end.
- 最左侧一个 bit,弃置不用
(这样做的目的就是为了避免内存碎片化,这里的start和end只关心首端和尾端是不是0,如果不是0,那么他们的结果就是0,只有当首端和尾端是0的时候才会开始计算)
start和end主要用于为父节点提供合并之后的max是否发生改变,每个PallocSum下都有8个,相邻的两个节点的strat和end可能会合成比原本更大的一个max,所以需要记录,后续会讲到。
父子节点的关系
- 每个父 pallocSum 有 8 个子 pallocSum
- 根 pallocSum 总览全局,映射的 bitMap 范围为全局的 16 GB 空间(其 max 最大值为 2^21,因此总空间大小为 2^21*8KB=16GB);
- 从首层向下是一个依次八等分的过程,每一个 pallocSum 映射其父节点 bitMap 范围的八分之一,因此第二层 pallocSum 的 bitMap 范围为 16GB/8 = 2GB,以此类推,第五层节点的范围为 16GB / (8^4) = 4 MB,已经很小
- 聚合信息时,自底向上. 每个父 pallocSum 聚合 8 个子 pallocSum 的 start、max、end 信息,形成自己的信息,直到根 pallocSum,坐拥全局 16 GB 的 start、max、end 信息
- mheap 寻页时,自顶向下. 对于遍历到的每个 pallocSum,先看起 start 是否符合,是则寻页成功;再看 max 是否符合,是则进入其下层孩子 pallocSum 中进一步寻访;最后看 end 和下一个同辈 pallocSum 的 start 聚合后是否满足,是则寻页成功.
2.8 heapArena
- 每个 heapArena 包含 8192 个页,大小为 8192 * 8KB = 64 MB
- heapArena 记录了页到 mspan 的映射. 因为 GC 时,通过地址偏移找到页很方便,但找到其所属的 mspan 不容易. 因此需要通过这个映射信息进行辅助.
- heapArena 是 mheap 向操作系统申请内存的单位(64MB)
三.分配流程流程
下面来串联 Golang 中分配对象的流程,不论是以下哪种方式,最终都会殊途同归步入 mallocgc 方法中,并且根据 3.1 小节中的策略执行分配流程:
- new(T)
- &T{}
- make(xxxx)
3.1 分配对象
golang中,会依据object的大小,分为三类对象,从而对其进行内存的分配,接下来就来看看吧:
不同类型的对象,会有不同的分配策略,这些内容在mallocgc方法中都有体现:
核心流程类似于读多级缓存的过程,自上而下,每一步只要成功就立即返回,若失败,则由下层方法兜底。
对于微对象的分配流程:
(1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)
(2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)
(3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)
(4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)
(5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4)
对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;
对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.
3.2 分配流程
(1)微小对象分配(Tiny Allocator)
- 条件:对象大小 < 16 B 且 不包含指针(避免影响垃圾回收扫描)。
- 流程:
-
- 尝试合并:若当前
mcache.tiny
块剩余空间足够,直接在当前tiny
块分配(无锁)。 - 申请新块:若空间不足,向
mheap
申请一个新的 16 B 内存块(可能触发垃圾回收)。
- 尝试合并:若当前
- 优化:通过合并多个微小对象减少内存碎片。
(2)小对象分配(mcache -> mcentral)
- 步骤:
-
- 确定 Size Class:根据对象大小匹配预定义的 Size Class(Go 有 67 个预定义规格,如 8 B、16 B、32 B ... 32 KB)。
- 从 mcache 获取 Span:
-
-
mcache
为每个 P(处理器)维护本地缓存 Span,包含对应 Size Class 的空闲对象链表。- 若当前 Span 有空闲对象,直接分配(无锁)。
-
-
- mcache 向 mcentral 申请新 Span:
-
-
- 若
mcache
的 Span 耗尽,向对应的mcentral
请求新的 Span。 mcentral
维护全局 Span 列表(nonempty
和empty
链表),通过锁保证并发安全。
- 若
-
-
- mcentral 向 mheap 扩容:
-
-
- 若
mcentral
无可用 Span,向mheap
申请新的 Span(通常为 8 KB 的倍数)。 mheap
通过基数树查找连续空闲页,切割为所需大小的 Span 返回给mcentral
。
- 若
-
(3)大对象分配(直接通过 mheap)
- 触发条件:对象大小 > 32 KB。
- 流程:
-
- 计算所需页数:将对象大小转换为页数(1 页 = 8 KB)。
- 基数树查找空闲页:
-
-
mheap
使用基数树快速定位满足需求的连续空闲页。- 基数树的
max
字段帮助跳过无法满足需求的子树,start
/end
处理跨子树合并。
-
-
- 分配并更新元数据:
-
-
- 标记位图中对应页为已占用。
- 更新基数树节点的
start
/max
/end
摘要信息。
-
-
- 直接返回内存地址:无需切割 Span,直接将连续页映射给大对象。