Golang 堆内存管理和分配原理

基本概念

Golang 的 runtime 将堆地址空间划分为 arena。在 Linux 环境下每个 arena 是 64 MB,每个 arena 中又有 8192 个 page,每个 page 为 8KB。

为了防止出现出现大量内存碎片,Golang 采用了类似于 Google 的 tcmalloc 的内存分配算法:将堆内存按一组预设大小规格划分成内存块,再把这些不同规格的内存块以链表的形式组织。

在申请内存时,会先找到最匹配的规格,去对应的空闲链表中获取一个内存块。这样可以降低外部内存碎片的概率。

Golang 中有 67 种预设规格,从 8B 到 32KB。所以 arena 中会按需划分出不同的 span,每个 span 包含一组连续的 page,并且按照特定规格划分成了等大的内存块。

涉及堆内存管理的结构体

arena、span、page 和内存块组成了 Golang 堆内存的堆内存 heap,它们自然也有各自对应的结构体来管理堆内存。

heapArena

heapArena 结构体对应 arena:

1
2
3
4
5
6
7
8
9
10

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
}

其中存储着 arena 的元数据,有很多位图标记:

  • bitmap:用一位标记这个 arena 中,一个指针大小的内存单元是指针还是标量,再用一位标记这块内存空间的后续单元是否包含指针。用于 GC 扫描阶段。
  • pageInUse:用于标记处于使用状态的 span 的第一个 page。
  • pageMarks:用于标记每个 span 的第一个 page,在 GC 标记阶段会修改这个位图,标记哪些 span 中存在被标记的对象,在 GC 清扫阶段根据这个位图来释放不含标记对象的 span。
  • spans:用来标记某个 page 对应哪一个 span。

mspan

mspan 中管理 span 中一组连续的 page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

type mspan struct {
next *mspan
prev *mspan
list *mSpanList
startAddr uintptr
npages uintptr
manualFreeList gclinkptr
freeindex uintptr
nelems uintptr
allocCache uint64
allocBits *gcBits
gcmarkBits *gcBits
sweepgen uint32
divMul uint16
baseMask uint16
allocCount uint16
spanclass spanClass
state mSpanStateBox
needzero uint8
divShift uint8
divShift2 uint8
elemsize uintptr
limit uintptr
speciallock mutex
specials *special
}

该 span 具体被划分成了什么规格大小跟 spanclass 有关,spanclass 的高 7 位用于标记内存规格的编号,对应 1-67,编号 0 被留出来表示大于 32KB 的内存,一共 68 中。

而 spanclass 的最低位用于标记这个 span 是否需要进行 GC 扫描,所以 span 又可以分为 scannable 和 non-scannable 两种,总共就是 136 种。

span 中的 nelems 记录该 span 被划分成多少内存块,而 freeIndex 记录这下一个空闲内存块的索引。

span 中也有一些位图:

  • allocBits:标记哪些内存块被分配了;
  • gcmarkBits:用于在 GC 扫描阶段标记内存块。

在 GC 清扫阶段会释放掉旧的 allocBits,把标记好的 gcmarkBits 用作 allocBits,因为没有在 gcmarkBits 标记下的内存块都被回收了,接着再重新分配一段清零的内存给 gcmarkBits。

mcentral

mcentral 是一个全局的 mspan 管理中心。每一种 span 对应一个 mcentral,所以 mcentral 也有 136 种。mcentral 将 mspan 按照用尽与未用尽的分别管理,而用尽的或未用尽的又会分成已清扫和未清扫的进行管理。

mcache

由于使用全局的 mspan 管理中心会导致多个 P 之间对锁产生竞争,所以每个 P 也设置了自己的本地缓存,即 mcache。

当 P 想要获取特定规格的 mspan 时,先从本地的 mcache 中尝试获取,如果没有或用完了就去 mcentral 中找一个放到本地,把已经用尽的归还到 mcentral 中对应的 full set 中。

堆内存分配过程

堆内存分配的主要逻辑可以分为四个部分:

  1. 辅助 GC;
  2. 空间分配;
  3. 位图标记;

辅助 GC

考虑一种情况:当内存申请的速度超过了 GC 标记的速度,内存就分配不过来了。所以需要申请内存的协程参与到 GC 中来,即辅助 GC。要帮助扫描或清扫一部分内存之后,才可以得到内存。

每次参与辅助 GC 最少需要扫描 64KB,所有如果要申请的内存并没有到达 64KB,则多干的活可以留到下次再使用。

同时 mark worker 在进行清扫的时候,也会在全局 gcController 中的 bgScanCredit 中累积信用,这个信用是可以交由辅助 GC 的协程使用的。

空间分配

在辅助 GC 完成后就可以进行空间分配了。要根据需要分配的空间大小以及是否为 non-scannable 型的空间来使用不同的分配策略:

  • 当需要的空间小于 16B,并且为 non-scannable 空间的申请,就是用 tiny-allocator;
  • 如果大于 32KB 则会直接分配到堆上;
  • 大于等于 16B 且小于等于 32KB 的 non-scannable 空间和小于等于 32KB 的 scannable 空间的申请则按照预设大小规格来进行合适的分配。

由于最小的预设规格为 8B,所以即使只需要 1B 的空间,按预设规格来分配也会分配出去 8B,所以每个 P 都有一个 16B 大小的 tiny 内存空间,可以将小的内存分配请求统一分到 tiny 内存空间来提高内存利用率。

位图标记

将 heapArena、mspan 中对应的位图进行标记。