内存分配

内存分配器

编程语言的内存分配器一般包含两种分配方法:

  • 线性分配器(Sequential Allocator,Bump Allocator)
  • 空闲链表分配器(Free-List Allocator)

线性分配器

线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,用户程序向分配器申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置

bump-allocator

虽然线性分配器实现为它带来了较快的执行速度以及较低的实现复杂度,但是线性分配器无法在内存被释放时重用内存

如下图,红色部分是已经被回收的内存,但是无法重新利用:

bump-allocator-reclaim-memory

所以线性分配器需要与合适的垃圾回收算法配合使用,例如:标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法。它们可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。

因为线性分配器需要与具有拷贝特性的垃圾回收算法配合,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用线性分配器

空闲链表分配器

空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表。

不同的内存块通过指针构成了链表,所以使用这种方式的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度是 O(n)

空闲链表分配器选择内存块的策略:

  • 首次适应(First-Fit):从链表头开始遍历,选择第一个大小大于申请内存的内存块;
  • 循环首次适应(Next-Fit):从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
  • 最优适应(Best-Fit):从链表头遍历整个链表,选择最合适的内存块;
  • 隔离适应(Segregated-Fit):将内存分割成多个链表,每个链表中的内存块大小相同,根据申请的内存大小选择不同的链表

隔离适应策略

segregated-fit

该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当向内存分配器申请 8 字节的内存时,它找到满足条件的空闲内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。

分级分配

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

Go 运行时根据对象的大小将对象分成微对象、小对象和大对象三种:

类别大小
微对象(0, 16B)
小对象[16B, 32KB]
大对象(32KB, +∞)

程序中的绝大多数对象的大小都在 32KB 以下,所以分别处理大对象和小对象有利于提高内存分配器的性能

多级缓存

内存分配器还会将内存分成不同的级别分别管理,TCMalloc 和 Go 运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存:

multi-level-cache
  • 线程缓存mcache):每个线程都有一个线程缓存,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。
  • 中心缓存mcentral):当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配
  • 页堆mheap):遇到 32KB 以上的对象时,内存分配器会选择页堆直接分配大内存。

虚拟内存布局

Go 1.10 以前的版本,堆区的内存空间都是连续的;但是在 1.11 版本,Go 使用稀疏的堆内存空间替代了连续的内存,解决了连续内存带来的限制以及在特殊场景下可能出现的问题。

Go 1.10 的线性内存

Go 在程序启动的时候,会先向操作系统申请一块内存(这只是一段虚拟的地址空间,并不会真正地分配内存),包括三个区域 spansbitmaparena 分别预留了 512MB、16GB 以及 512GB 的内存空间:

go-heap-1.10

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

找到任意一个地址对应的 runtime.mspan

  1. 根据 arena 的基地址计算该地址所在的页号。
  2. 通过 mheap.spans 数组获得管理该片内存的管理单元 runtime.mspanmheap_.spans[page] 页号就是数组的索引)。

Go 在垃圾回收时会根据指针的地址判断对象是否在堆中,并通过上面的方式到管理该对象的 runtime.mspan。但是这种方式又一个前提,就是堆区的内存必须是连续的

在 C 和 Go 混合使用时,线性堆内存的问题:

  1. 分配的内存地址会发生冲突,导致堆的初始化和扩容失败;
  2. 没有被预留的大块内存可能会被分配给 C 语言的二进制,导致扩容后的堆不连续;
ℹ️
  • 线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用太浪费了。
  • 不预留内存空间的话在特殊场景下造成程序崩溃。

Go 1.11 的稀疏内存方案

使用稀疏的内存布局不仅能移除堆大小的上限,还能解决 C 和 Go 混合使用时的地址空间冲突问题。但是基于稀疏内存的内存管理失去了内存的连续性这一特征,使内存管理变得更加复杂:

go-heap-1.11

使用一个 runtime.heapArena 数组管理所有内存。每个 runtime.heapArena 管理 64MB 的内存。

type heapArena struct {
	bitmap       [heapArenaBitmapBytes]byte
	spans        [pagesPerArena]*mspan
	pageInUse    [pagesPerArena / 8]uint8
	pageMarks    [pagesPerArena / 8]uint8
	pageSpecials [pagesPerArena / 8]uint8
	checkmarks   *checkmarksMap
	zeroedBase   uintptr
}
  • heapArena 中的 bitmapspans 和线性内存中的意思一样。
  • zeroedBase 字段指向了该结构体管理的内存的基地址。

上述设计将原有的连续大内存切分成稀疏的小内存,而用于管理这些内存的元信息也被切成了小块

内存管理组件

Go 语言的内存分配器包含内存管理单元(runtime.mspan)、线程缓存(runtime.mcache)、中心缓存(runtime.mcentral)和页堆(runtime.mheap)几个重要组件。

go-mem-component

Go 程序会在启动时初始化如上图所示的内存布局。

  1. 每一个处理器都会分配一个线程缓存 runtime.mcache 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan
  2. mspan 不存在空闲 object 时,从 runtime.mheap 持有的 134 个中心缓存 runtime.mcentral 中获取新的内存单元。中心缓存属于全局的堆结构体 runtime.mheap,它会从操作系统中申请内存。

在 amd64 的 Linux 操作系统上,runtime.mheap 会持有 4,194,304 runtime.heapArena,每个 runtime.heapArena 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。

内存管理单元 msapn

type mspan struct {
	next *mspan
	prev *mspan
    list *mSpanList // For debugging.
    // ...

    // mspan 内存的开始位置,N 个连续 page 内存的开始位置
	startAddr uintptr
    // 该 span 管理的页数
	npages uintptr
    // 空闲 object 链表的开始位置
	freeindex uintptr
    // 一共有多少个 object
	nelems uintptr
    // 决定 object 的大小、以及当前 mspan 是否需要垃圾回收扫描
	spanclass spanClass

    allocBits  *gcBits
	gcmarkBits *gcBits
	allocCache uint64

    state      mSpanStateBox
}
  • npages 就代表了这个 mspan 是由几个连续的 page 组成。mspan 是由 N 个且连续的 page 组成,可以是一个 page,也可以是 2 个、3 个或者更多。
  • 相邻的 mspan 互相引用组成一个双向链表
  • startAddrnpages 就可以确定该结构体管理的多个页所在的内存,每个页的大小都是 8KB。
  • allocBits 是一个 bitmap,记录 mspan 中每个对象(object)的分配状态,每个 bit 对应 mspan 中的一个对象。:
    • 1:表示对象已被分配(正在使用或未被回收)。
    • 0:表示对象未被分配(空闲,可能在 freelist 中)。
  • gcmarkBits: 垃圾回收标记位图。1:对象被标记为存活(可达),0:对象未被标记(待回收)。标记过程:
    • 初始状态:所有 bit 为 0(白色)。
    • 标记阶段:从根对象出发,递归标记存活对象,将对应 bit 置 1(灰色→黑色)。
    • 清扫阶段:对比 allocBitsgcmarkBits,回收未被标记的对象。
  • allocCacheallocBits 的补码,可以用于快速查找内存中未被使用的内存。

Go 是按页 page 8KB 为最小单位分配内存的吗?

不是,如果这样的话会导致内存使用率不高。Go 内存管理器会把 mspan 再拆解为更小粒度的单位 object

所有的空闲 object 构成一个链表,但并不是 LinkedList 结构而是 FreeList 结构。

go-heap-mspan

FreeList

FreeList 采用 隐式链表(Embedded Linked List)设计。

  • 没有 Next 属性,而是通过 object 内存的前 8 字节来存储下一个空闲对象的地址。
  • 分配出去的节点,先将 freeindex 指向下一个空闲对象再返回,(节点整块内存空间可以被覆盖,包括下一个节点的指针)。

分配内存

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

go-heap-mspan-alloc

如果能在内存中找到空闲的内存单元会直接返回,当内存中不包含空闲的内存时,运行时会以页为单位向堆申请内存。

状态

mspan.state 可能有 4 个状态:

  • mSpanFree:表示该 mspan 在空闲堆中。
  • mSpanManualmSpanInUse:表示该 mspan 正在被使用,有部分 object 被分配出去了。
  • mSpanDead

设置 runtime.mspan 状态的操作必须是原子性的以避免垃圾回收造成的线程竞争问题。

跨度类 spanclass

runtime.spanClass 它决定了内存管理单元中存储的对象大小和个数。Go 的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象。

classbytes/objbytes/spanobjectstail wastemax waste
1881921024087.50%
2168192512043.75%
3248192341029.24%
4328192256046.88%
54881921703231.52%
6648192128023.44%
78081921023219.07%
6732768327681012.50%

上表展示了对象大小从 8B 到 32KB,总共 67 种跨度类的大小、存储的对象数以及浪费的内存空间。

以跨度类为 5 为例,它的 runtime.mspan 中对象的大小上限为 48 字节、管理 1 个页(8 KB)、最多可以存储 170 个对象。因为内存需要按照页进行管理,所以在尾部会浪费 32 (8192 - 170*48 = 32)字节的内存。当页中存储的对象都是 33 字节时,最多会浪费 31.52% 的资源(这是比较极端的情况,小于 33 字节的会使用跨度类 4)。

运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象

noscan

跨度类中除了存储类别的 ID 之外,它还会存储一个 noscan 标记位。该标记位表示是否需要垃圾回收

go-spanclass

线程缓存 mcache

runtime.mcache 是 Go 语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。

  • mcachetiny 结构主要负责分配微对象
  • mcachealloc 结构主要负责分配小对象alloc 结构持有 68*2runtime.mspan

mcache 初始化时是不包含 runtime.mspan 的,只有当用户程序申请内存时才会去获取新的 mspan

微分配器 tiny

type mcache struct {
	tiny             uintptr
	tinyoffset       uintptr
	local_tinyallocs uintptr
    // ...
}

微分配器只会用于分配非指针类型的内存

tiny 会指向堆中的一片内存,tinyOffset 是下一个空闲内存所在的偏移量,最后的 local_tinyallocs 会记录内存分配器中分配的对象个数。

中心缓存 mcentral

runtime.mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁

每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的内存管理单元。

type mcentral struct {
	spanclass spanClass // 跨度类
	partial  [2]spanSet
	full     [2]spanSet
}

页堆

runtime.mheap 是内存分配的核心结构体,作为一个全局变量存储。堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central,另一个是管理堆区内存区域的 arenas 以及相关字段

堆内存分配过程

微对象分配

micro-object-malloc

  1. mcachetiny 内存充足,则直接分配微对象所需内存。
  2. mcachetiny 内存不足,先去 mcachealloc 申请 16B 给 tiny,再分配微对象所需内存。

小对象分配

small-object-malloc

  1. mcachealloc 充足,则直接分配小对象所需内存。
  2. mcachealloc 不足,则去中央缓存 mcentral 获取一个 mspan,再分配小对象所需内存。
  3. mcachealloc 不足,且中央缓存 mcentral 不足,则去逻辑处理器结构的 p.pagecache 分配。
  4. 如果 pagecache 也不足,直接去堆上 mheap 获取一个 mspan,再分配小对象所需内存。

大对象分配

对于大于 32KB 的大对象会单独处理,运行时不会从线程缓存或者中心缓存中获取内存管理单元,而是直接调用 runtime.mcache.allocLarge 分配大片内存。

申请内存时会创建一个跨度类为 0runtime.spanClass 并调用 runtime.mheap.alloc 分配一个管理对应内存的管理单元。

最后更新于