Go语言内存管理

本章节,就来学习一下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内存模型设计的几个核心要点

  1. 以时间换空间,一次缓存,多次使用

首先我们要做到,每次向操作系统申请空间的操作都是很重的,那不妨一次性咱多要一点,以备后续使用。

Golang中的mheap正是基于这种思想,产生的数据结构。接下来我们从两个不同的角度去看这个堆:

  • 对于操作系统而言:这个堆就是用户进程中缓存的内存。
  • 对于Go进程内部:堆则是所有对象的起源。

  1. 多级缓存,以实现无/细锁化

为什么要设置多级缓存?

实际上,堆是Go运行时中最大的临界内存资源,这意味着每次存取都要加锁,在性能层面上看是一件非常可怕的事情.

正是为了解决这个问题,Golang在堆mheap上,做了更加细微的处理,建立了 mcentral、mcache。

通过设置不同规格的mcentral,从而实现对不同大小的对象进行管理,分开加锁,避免了直接对mheap直接加锁导致的性能问题。

  1. 多级规格,提高利用率

首先先来了解一下page和mspan这两个概念:

  • page:最小的存储单元

Golang 借鉴操作系统分页管理的思想,每个最小的存储单元也称之为页 page,但大小为 8 KB

  • mspan:最小的管理单元

mspan 大小为 page 的整数倍,且从 8B 到 80 KB 被划分为 67 种不同的规格,分配对象时,会根据大小映射到不同规格的 mspan,从中获取空间,实际上有一个更大的规格,所以说是68种,后续会在涉及

为什么要将mspan划分为多个规格呢?

  1. 有了规格化,便产生了等级制度,有了等级,才支持mcentral实现细琐化
  2. 消除了外部碎片,但是不能避免内部碎片,宏观上提高了整体空间的利用率

可以看一下整体的架构图,我们后续会不断对细节进行一个补充的。

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
    // 最多可以存放多少个 object
    nelems 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       uintptr
    tinyoffset uintptr
    tinyAllocs 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
    // 对应的 spanClass
    spanclass spanClass
    // 有空位的 mspan 集合,数组长度为 2 是用于抗一轮 GC
    partial [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
    // 通过这个来间接管理bitmap
    arenas [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 不包含指针(避免影响垃圾回收扫描)。
  • 流程
    1. 尝试合并:若当前 mcache.tiny 块剩余空间足够,直接在当前 tiny 块分配(无锁)。
    2. 申请新块:若空间不足,向 mheap 申请一个新的 16 B 内存块(可能触发垃圾回收)。
  • 优化:通过合并多个微小对象减少内存碎片。

(2)小对象分配(mcache -> mcentral)

  • 步骤
    1. 确定 Size Class:根据对象大小匹配预定义的 Size Class(Go 有 67 个预定义规格,如 8 B、16 B、32 B ... 32 KB)。
    2. 从 mcache 获取 Span
      • mcache 为每个 P(处理器)维护本地缓存 Span,包含对应 Size Class 的空闲对象链表。
      • 若当前 Span 有空闲对象,直接分配(无锁)。
    1. mcache 向 mcentral 申请新 Span
      • mcache 的 Span 耗尽,向对应的 mcentral 请求新的 Span。
      • mcentral 维护全局 Span 列表(nonemptyempty 链表),通过锁保证并发安全。
    1. mcentral 向 mheap 扩容
      • mcentral 无可用 Span,向 mheap 申请新的 Span(通常为 8 KB 的倍数)。
      • mheap 通过基数树查找连续空闲页,切割为所需大小的 Span 返回给 mcentral

(3)大对象分配(直接通过 mheap)

  • 触发条件:对象大小 > 32 KB。
  • 流程
    1. 计算所需页数:将对象大小转换为页数(1 页 = 8 KB)。
    2. 基数树查找空闲页
      • mheap 使用基数树快速定位满足需求的连续空闲页。
      • 基数树的 max 字段帮助跳过无法满足需求的子树,start/end 处理跨子树合并。
    1. 分配并更新元数据
      • 标记位图中对应页为已占用。
      • 更新基数树节点的 start/max/end 摘要信息。
    1. 直接返回内存地址:无需切割 Span,直接将连续页映射给大对象。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值