1 引文

内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新内存,而分配器会负责从堆中初始化相应的内存区域。
image.png
内存空间的两个重要区域

  • 栈区 (Stack)
    • 由编译器进行管理
  • 堆区 (Heap)
    • C++由开发者主动申请释放
    • Go 和 Java等编程语言会由开发者和编译器hu共同管理,堆中的对象由内存分配器分配并由垃圾收集器

      2 设计原理

      2.1 分配方法

      2.1.1 线性分配器

      分配的时候只需要维护指向特定内存位置的指针。这是一种高效的分配方法,但是有较大的局限性。

      2.1.1.1 分配过程

  1. 检查剩余的空闲内存
  2. 返回分配的内存区域
  3. 修改指针在内存的位置,即移动下图的中虚线箭头。

image.png

2.1.1.2 局限性

线性分配器无法在内存被释放时重用内存。
如下图所示,如果已经分配的内存被回收,线性分配器无法重新利用红色的内存。
image.png

2.1.2 空闲链表分配器

内存块会形成一个链表的结构

2.1.2.1 分配过程

  1. 遍历链表
  2. 找到足够大的空闲内存
  3. 修改链表

image.png

2.1.2.2 特点

  1. 优点
    1. 可以重新利用回收的资源
  2. 缺点

    1. 分配的时候需要遍历链表,时间复杂度为O(N). 所以需要一些策略来提升分配效率。

      2.1.2.3 常见的分配策略

  3. 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大于申请内存的内存块;

  4. 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大于申请内存的内存块
  5. 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块
  6. 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择空闲的内存块。

    2.1.2.4 隔离适应

    如下图把内存分割成4,8,16,32字节的内存快组成的链表。如申请8字节的内存,先找到8字节对应的链表,再遍历链表找到空闲的内存块。减少了遍历的内存块数量,提高内存分配的效率。
    image.png

    2.2 分级分配

    Go的内存分配器借鉴了线程缓存分配(Thread-Caching Malloc,TCMalloc)的设计实现的高速内存分配,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的策略

    2.2.1 对象分类

    程序中大部分的对象都小于32KB,而申请的内存大小影响Go运行时分配内存的过程和开销,所以分别处理小对象和大对象有利于提升内存分配器的性能。
类别 大小
微对象 (0, 16B)
小对象 [16B, 32KB]
大对象 (32KB, +∞)

2.2.2 分级缓存

内存分配器不仅会区别对待不同大小的对象,还会将内存分成不同的级别分别管理。Go运行时分配器引入了三个组件分级管理内存

  1. 线程缓存(Thread Cache)
    1. 属于每一个独立的线程,能满足绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁保护内存,减少锁竞争带来的性能消耗
  2. 中心缓存(Central Cache)
    1. 作为线程缓存的补充,当线程缓存不满足需求的,解决小对象的内存分配
  3. 页堆(Page Heap)
    1. 用于分配32KB以上的大对象

image.png

2.3 虚拟内存布局

2.3.1 线性内存

2.3.1.1 结构

Go 1.10以前的版本的布局方式,启动时会初始化整片虚拟内存区域。如下所示的三个区域 spans、bitmap 和 arena 分别预留了 512MB、16GB 以及 512GB 的内存空间,这些内存并不是真正存在的物理内存,而是虚拟内存
image.png

  • spans
    • 区域存储了指向内存管理单元 runtime.mspan 的指针,每个内存单元会管理几页的内存空间,每页大小为 8KB;
  • bitmap
    • 用于标识 arena 区域中的那些地址保存了对象,它的每个字节都会表示堆区中的 32 字节是否空闲;
  • arena
    • 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象;

对于任意一个地址,我们都可以根据 arena 的基地址计算该地址所在的页数并通过 spans 数组获得管理该片内存的管理单元 runtime.mspan。

2.3.1.1 局限性

Go在垃圾回收的时候根据指针的地址判断是否在堆区,并通过上面的过程找到对应的内存管理单元 runtime.mspan。这些都是建立在堆区的内存是连续的这一假设上。这种设计虽然简单,但是C和Go混用的时候会导致程序崩溃

  • 分配的内存地址会发生冲突,导致堆的初始化和扩容失败;
  • 没有被预留的大块内存可能会被分配给 C 语言的二进制,导致扩容后的堆不连续;

    2.3.2 稀疏内存

    稀疏内存是 Go 语言在 1.11 中提出的方案

    2.3.2.1 优缺点

  1. 优点
    1. 移除堆大小的上限
    2. 地址不再是连续的,解决C和Go混合使用是的地址冲突问题
  2. 缺点
    1. 失去了内存连续性,管理变复杂

      2.3.2.2 结构

      运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个单元都会管理 64MB 的内存空间。不同平台和架构的二维数组大小可能完全不同,如果我们的 Go 语言服务在 Linux 的 x86-64 架构上运行,二维数组的一维大小会是 1,而二维大小是 4,194,304,因为每一个指针占用 8 字节的内存空间,所以元信息的总大小为 32MB。由于每个 runtime.heapArena 都会管理 64MB 的内存,整个堆区最多可以管理 256TB 的内存,这比之前的 512GB 多好几个数量级
      image.png

      2.4 地址空间

      所有的内存都需要从操作系统中申请,所以Go的运行时构建了操作系统的内存管理抽象层,该抽象层把运行时管理的地址空间氛围以下四种状态。
状态 解释
None 内存没有被保留或者映射,是地址空间的默认状态
Reserved 运行时持有该地址空间,但是访问该内存会导致错误
Prepared 内存被保留,一般没有对应的物理内存,访问该片内存的行为是未定义的,可以快速转换到 Ready 状态
Ready 可以被安全访问

2.4.1 状态转换

  • runtime.sysAlloc 会从操作系统中获取一大块可用的内存空间,可能为几百 KB 或者几 MB
  • runtime.sysFree 会在程序发生内存不足(Out-of Memory,OOM)时调用并无条件地返回内存;
  • runtime.sysReserve 会保留操作系统中的一片内存区域,访问这片内存会触发异常;
  • runtime.sysMap 保证内存区域可以快速转换至就绪状态;
  • runtime.sysUsed 通知操作系统应用程序需要使用该内存区域,保证内存区域可以安全访问;
  • runtime.sysUnused 通知操作系统虚拟内存对应的物理内存已经不再需要,可以重用物理内存;
  • runtime.sysFault 将内存区域转换成保留状态,主要用于运行时的调试;

image.png

3 内存管理组件

  • 内存管理单元 runtime.mspan
  • 线程缓存 runtime.mcache
  • 中心缓存 runtime.mcentral
  • 页堆 runtime.mheap

    3.1 内存布局

    image.png
    Go会在启动的时候初始化如上图的内存布局,每一个处理器都会分配一个线程缓存(runtime.mcache) 用于处理微对象和小对象的分配,他们会持有内存管理单元runtime.mspan

每个类型的内存管理单元都会管理特定大小的对象,当内存管理单元中不存在空闲对象时,从页堆 runtime.mheap持有的134个中心缓存 runtime.mcentral中获取新的内存单元,中心缓存属于全局的堆结构,它会从操作系统中申请内存

3.2 内存管理单元

runtime.mspan 是 Go 语言内存管理的基本单元,该结构体中包含 next 和 prev 两个字段,它们分别指向了前一个和后一个 runtime.mspan。串联后的结构体形成如下的双向链表。运行时会使用 runtime.mSpanList 存储双向链表的头结点和尾节点并在线程缓存以及中心缓存中使用。
image.png

每个 runtime.mspan 都管理 npages 个大小为 8KB 的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍,该结构体会使用下面这些字段来管理内存页的分配和回收:

  1. type mspan struct {
  2. startAddr uintptr // 起始地址
  3. npages uintptr // 页数 每个页的大小都是 8KB
  4. freeindex uintptr // 扫描页中空闲对象的初始索引
  5. allocBits *gcBits // 标记内存的占用
  6. gcmarkBits *gcBits // 标记内存的回收
  7. allocCache uint64 // allocBits 的补码,可以用于快速查找内存中未被使用的内存;
  8. ...
  9. }

image.png

3.2.1 内存管理单元和页

当用户程序或者线程向 runtime.mspan 申请内存时,它会使用 allocCache 字段以对象为单位在管理的内存中快速查找待分配的空间:
image.png

3.2.1 内存管理单元和对象

如果我们能在内存中找到空闲的内存单元会直接返回,当内存中不包含空闲的内存时,上一级的组件 线程缓存 runtime.mcache 会为调用 runtime.mcache.refill 更新内存管理单元以满足为更多对象分配内存的需求。

3.2.1.1 状态

运行时会使用 runtime.mSpanStateBox 存储内存管理单元的状态 runtime.mSpanState

  1. type mspan struct {
  2. ...
  3. state mSpanStateBox
  4. ...
  5. }

该状态可能处于 mSpanDeadmSpanInUsemSpanManualmSpanFree 四种情况。当 runtime.mspan 在空闲堆中,它会处于 mSpanFree 状态;当 runtime.mspan 已经被分配时,它会处于 mSpanInUsemSpanManual 状态,运行时会遵循下面的规则转换该状态:

  • 在垃圾回收的任意阶段,可能从 mSpanFree 转换到 mSpanInUse 和 mSpanManual;
  • 在垃圾回收的清除阶段,可能从 mSpanInUse 和 mSpanManual 转换到 mSpanFree;
  • 在垃圾回收的标记阶段,不能从 mSpanInUse 和 mSpanManual 转换到 mSpanFree;

    3.2.1.1 跨度类

    runtime.spanClassruntime.mspan 的跨度类,它决定了内存管理单元中存储的对象大小和个数:
    1. type mspan struct {
    2. ...
    3. spanclass spanClass
    4. ...
    5. }
    Go 的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在 runtime.class_to_size 和 runtime.class_to_allocnpages 等变量中
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 24 8192 341 0 29.24%
4 32 8192 256 0 46.88%
5 48 8192 170 32 31.52%
6 64 8192 128 0 23.44%
7 80 8192 102 32 19.07%
67 32768 32768 1 0 12.50%

数据
上表展示了对象大小从 8B 到 32KB,总共 67 种跨度类的大小、存储的对象数以及浪费的内存空间,以表中的第四个跨度类为例,跨度类为 5 的 runtime.mspan 中对象的大小上限为 48 字节、管理 1 个页、最多可以存储 170 个对象。因为内存需要按照页进行管理,所以在尾部会浪费 32 字节的内存,当页中存储的对象都是 33 字节时,最多会浪费 31.52% 的资源:
内存分配器 - 图14
image.png
大于32KB?
除了上述 67 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象。

3.3 线程缓存 - runtime.mcache

与线程一一绑定,主要用来缓存程序申请的微小对象,每个线程缓存持有 68 * 2 个内存管理单元。这些内存管理单元都存储在结构体的 alloc 字段
image.png

3.3.1 初始化

线程缓存在刚刚被初始化时是不包含 runtime.mspan 的,只有当用户程序申请内存时才会从上一级组件获取新的 runtime.mspan 满足内存分配的需求。调用页堆中的线程缓存分配器初始化新的线程缓存

3.3.2 微分配器

线程缓存中还包含几个用于分配微对象的字段,专门管理 16 字节以下的对象:

  1. type mcache struct {
  2. tiny uintptr // 指向堆中的一片内存
  3. tinyoffset uintptr // 是下一个空闲内存所在的偏移量
  4. local_tinyallocs uintptr // 会记录内存分配器中分配的对象个数
  5. }

3.4 中心缓存 - runtime.mcentral

runtime.mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁.每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两runtime.spanSet,分别存储包含空闲对象不包含空闲对象的内存管理单元。

  1. type mcentral struct {
  2. spanclass spanClass
  3. partial [2]spanSet
  4. full [2]spanSet
  5. }

3.4.1 线程缓存如何从中心缓存获取内存管理单元

线程缓存会通过中心缓存的 runtime.mcentral.cacheSpan 方法获取新的内存管理单元,该方法的实现比较复杂,我们可以将其分成以下几个部分:

  1. 调用 runtime.mcentral.partialSwept 从清理过的、包含空闲空间的 runtime.spanSet 结构中查找可以使用的内存管理单元;
  2. 调用 runtime.mcentral.partialUnswept 从未被清理过的、有空闲对象的 runtime.spanSet 结构中查找可以使用的内存管理单元;
  3. 调用 runtime.mcentral.fullUnswept 获取未被清理的、不包含空闲空间的 runtime.spanSet 中获取内存管理单元并通过 runtime.mspan.sweep 清理它的内存空间;
  4. 调用 runtime.mcentral.grow 从堆中申请新的内存管理单元
  5. 更新内存管理单元的 allocCache 等字段帮助快速分配内存;

    3.5 页堆 - runtime.mheap

    runtime.mheap 是内存分配的核心结构体,Go 语言程序会将其作为全局变量存储,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central,另一个是管理堆区内存区域的 arenas 以及相关字段。
    页堆中包含一个长度为 136 的 runtime.mcentral 数组,其中 68 个为跨度类需要 scan 的中心缓存,另外的 68 个是 noscan 的中心缓存:
    image.png

    3.5.1 页堆与中心缓存列表

    已经介绍过 Go 语言所有的内存空间都由如下所示的二维矩阵 runtime.heapArena 管理,这个二维矩阵管理的内存可以是不连续的:
    image.png

    4 内存分配

    4.1 三种对象

  6. 微对象 (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;

  7. 小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存;
  8. 大对象 (32KB, +∞) — 直接在堆上分配内存;

image.png

4.2 微对象的分配

  • 优点
    • 使用线程缓存上的微对象分配器提高分配效率
  • 目标变量
    • 主要用来分配较小的字符串和逃逸的临时变量。
    • 不可以是指针类型
  • 特点
    • 微对象可以将多个较小的内存分配请求何如同一个内存块,只有内存块里的所有对象都需要被回收的时候,整片内存才可能被回收。

      4.2.1 分配过程

      4.2.1.1 有大小合适的内存块

      如下图微分配器已经在 16 字节的内存块中分配了 12 字节的对象,如果下一个待分配的对象小于 4 字节,它会直接使用上述内存块的剩余部分,减少内存碎片,不过该内存块只有所有对象都被标记为垃圾时才会回收
      image.png

      4.2.1.2 没有大小合适的内存块

  1. 先从线程缓存中找到跨度类对应的内存管理单元
  2. 获取空闲内存块
  3. 不存在空闲内存块的时候从中心缓存或者页堆中获取课分配的内存

    4.3 小对象

  • 目标变量
    • 小对象是指大小为 16 字节到 32,768 字节的对象以及所有小于 16 字节的指针类型的对象

      4.3.1 分配过程

  1. 确定分配对象的大小以及跨度类 runtime.spanClass
  2. 从线程缓存,中心缓存或者堆中获取内存管理单元并从内存管理单元中找到空闲的内存空间
  3. 调用 runtime.memclrNoHeapPointers 清空空闲内存中的所有数据

    4.4 大对象

  • 运行时对于大于 32KB 的大对象会单独处理,我们不会从线程缓存或者中心缓存中获取内存管理单元,而是直接调用 runtime.mcache.allocLarge 分配大片内存.
  • runtime.mcache.allocLarge会计算分配该对象所需要的页数,它按照8KB的倍数在堆上申请内存。
  • 申请内存时会创建一个跨度类为 0 的 runtime.spanClass 并调用 runtime.mheap.alloc 分配一个管理对应内存的管理单元。