内存模型

** 参考解析

题目来源:字节、米哈游

答案1:

  1. Go语言运行时依靠细微的对象切割、极致的多级缓存、精准的位图管理实现了对内存的精细化管理。

​ 将对象分为微小对象、小对象、大对象,使用三级管理结构mcache、mcentral、mheap用于管理、缓存加速span对象的访问和分配,使用精准的位图管理已分配的和未分配的对象及对象的大小。
​ Go语言运行时依靠细微的对象切割、极致的多级缓存、精准的位图管理实现了对内存的精细化管理以及快速的内存访问,同时减少了内存的碎片。

Span

  1. Go 将内存分成了67个级别的scan,特殊的0级特殊大对象大小是不固定的。

当具体的对象需要分配内存时,并不是直接分配span,而是分配不同级别的span中的元素。因此span的级别不是以每个span的大小为依据,而是以span中元素的大小为依据的。

Span等级 元素大小(字节) Span大小(字节) 元素个数
1 8 8192 1024
2 16 8192 512
3 32 8192 256
4 48 8192 170
65 64 8192 128
65 28672 57344 2
66 32768 32768 1

第1级span拥有的元素个数为8192/8=1024。每个span的大小和span中元素的个数都不是固定的,例如第65级span的大小为57344字节,每个元素的大小为28672字节,元素个数为2。span的大小虽然不固定,但其是8KB或更大的连续内存区域。
每个具体的对象在分配时都需要对齐到指定的大小,假如我们分配17字节的对象,会对应分配到比17字节大并最接近它的元素级别,即第3级,这导致最终分配了32字节。因此,这种分配方式会不可避免地带来内存的浪费。

三级对象管理

为了方便对Span进行管理,加速Span对象访问、分配。分别为mcache、mcentral、mheap。
TCMalloc内存分配算法的思想:
每个逻辑处理器P都存储了一个本地span缓存,称作mcache。如果协程需要内存可以直接从mcache中获取,由于在同一时间只有一个协程运行在逻辑处理器P上,所以中间不需要加锁。mcache包含所有大小规格的mspan,但是每种规格大小只包含一个。除class0外,mcache的span都来自mcentral。

mcentral 所有逻辑处理器P共享的。

  • 对象收集所有给定规格大小的span。每个mcentral都包含两个mspan的链表:empty mspanList表示没有空闲对象或span已经被mcache缓存的span链表,nonempty mspanList表示有空闲对象的span链表。 (为了的分配Mspan到Mcache中)

    mheap 每个级别的span都会有一个mcentral用于管理span链表(0级除外),其实 都是一个数组,由Mheap管理
    作用:
    不只是管理central,大对象也会直接通过mheap进行分配。

  • mheap实现了对虚拟内存线性地址空间的精准管理,建立了span与具体线性地址空间的联系,保存了分配的位图信息,是管理内存的最核心单元。堆区的内存被分成了HeapArea大小进行管理。对Heap进行的操作必须全局加锁,而mcache、mcentral可以被看作某种形式的缓存。

(三级缓存对象图)

56.内存模型 - 图1

(mheap管理虚拟内存线性地址空间)

56.内存模型 - 图2

四级内存块管理

Go 根据对象大小,将堆内存分成了 HeapArea->chunk->span->page 4种内存块进行管理。不同的内存块用于不同的场景,便于高效地对内存进行管理。

  • HeapArea 内存块最大,其大小与平台相关,在UNIX 64位操作系统中占据64MB。
  • chunk占据了512KB
  • span根据级别大小的不同而不同,但必须是page的倍数
  • 而1个page占据8KB

(内存块管理结构)

56.内存模型 - 图3

对象分配

  1. 在运行时分配对象的逻辑主要位于mallocgc函数中,这个名字很有意思,malloc代表分配,gc代表垃圾回收(GC),此函数除了分配内存还会为垃圾回收做一些位图标记工作。
  2. 内存分配时,将对象按照大小不同划分为微小(tiny)对象、小对象、大对象。微小对象的分配流程最长,逻辑链路最复杂

微小对象

  1. 小于16字节的都被划分成了微小对象,微小对象主要处理极小的字符串和独立的转义变量。

微小对象会被放入class为2的span中,首先对微小对象按照2、4、8的规则进行字节对齐。例如,字节为1的元素会被分配2字节,字节为7的元素会被分配8字节。

微小对象分配时,查看之前分配的元素中是否有空余的空间,如果当前对象要分配8字节,并且正在分配的元素可以容纳8字节,则返回tiny+offset的地址,分配完成后offset的位置也需要相应增加,为下一次分配做准备。

如果当前要分配的元素空间不够,将尝试从mcache中查找span中下一个可用的元素。因此,tiny分配的第一步是尝试利用分配过的前一个元素的空间,达到节约内存的目的。

(微小对象分配)

56.内存模型 - 图4

tiny offset代表当前已分配内存的偏移量)

56.内存模型 - 图5

Mcache 缓存位图

  1. 在查找空闲元素空间时,首先需要从mcache中找到对应级别的mspanmspan中拥有allocCache字段,其作为一个位图,用于标记span中的元素是否被分配。由于allocCache元素为uint64,因此其最多一次缓存64字节。

有时候,span中元素的个数大于64,因此需要专门有一个字段freeindex标识当前span中的元素被分配到了哪里。span中小于freeindex序号的元素都已经被分配了,将从freeindex开始继续分配。
因此,只要从allocCache开始找到哪一位为0即可。假如X位为0,那么X+freeindex为当前span中可用的元素序号。当allocCache中的bit位全部被标记为1后,需要移动freeindex,并更新allocCache,一直到span中元素的末尾为止。

(allocCache位图标记span中的元素是否被分配)

56.内存模型 - 图6

(freeindex之前的元素都已被分配)

56.内存模型 - 图7

mcentral 遍历 span

  1. 如果当前的span中没有可以使用的元素,这时就需要从mcentral中加锁查找。mcentral中有两种类型的span链表,分别是有空闲元素的nonempty链表和没有空闲元素的empty链表。在mcentral查找时,会分别遍历这两个链表,查找是否有可用的span

既然是没有空闲元素的empty链表,为什么还需要遍历呢?

  1. 这是由于可能有些span虽然被垃圾回收器标记为空闲了,但是还没有来得及清

Mheap 缓存查找

  1. 如果在mcentral中找不到可以使用的span,就需要在mheap中查找。Go 1.12 采用treap结构进行内存管理,treap是一种引入了随机数的二叉搜索树,其实现简单,引入的随机数及必要时的旋转保证了比较好的平衡性。这种方式有扩展性的问题,由于这棵树是mheap管理的,所以在操作它时需要维持一个lock。这在密集的对象分配及逻辑处理器P过多时,会导致更长的等待时间。使用bitmap来管理内存页,我们会看到每个逻辑处理器P中都维护了一份page cache,这就是现在Go实现的方式。
  2. mheap会首先查找每个逻辑处理器PpageCache字段的cachecache也是一个位图,每一位都代表了一个page8 KB)。由于cacheuint64,因此一共可以提供64×8=512KB的连续虚拟内存。在cache中,1代表未分配的内存,0代表已分配的内存。base代表该虚拟内存的基地址。当需要分配的内存小于512/4=128KB时,需要首先从cache中分配。在分配 n page 需要查找 cache中是否有连续的n1的单位,如果存在,在缓存中找到了合适的内存,用于构建Span

mheap基数树查找

  1. 如果要分配的page过大或者在逻辑处理器Pcache中没有找到可用的page,就需要对mheap加锁,并在整个mheap管理的虚拟地址空间的位图中查找是否有可用的page,这涉及Go语言对线性地址空间的位图管理。

管理线性地址空间的位图结构叫作基数树(radix tree),结构和一般的基数树结构不太一样,会有这个名字很大一部分是由于父节点包含了子节点的若干信息。

(内存管理基数树结构)

56.内存模型 - 图8

该树中的每个节点都对应一个pallocSum,最底层的叶子节点对应的pallocSum包含一个chunk的信息(512×8KB),除叶子节点外的节点都包含连续8个子节点的内存信息。例如,倒数第2层的节点包含连续8个叶子节点(即8×chunk)的内存信息。因此,越上层的节点对应的内存越多。
pallocSum是一个简单的uint64,分为开头(start)、中间(max)、末尾(end)3部分,pallocSum的开头与末尾部分各占21bit,中间部分占22bit,它们分别包含了这个区域中连续空闲内存页的信息,包括开头有多少连续内存页,最多有多少连续内存页,末尾有多少连续内存页。对于最顶层的节点,由于其max位为22bit,因此一棵完整的基数树最多代表2的21次方 pages=16GB内存。
不需要每一次查找都从根节点开始。在Go中,存储了一个特别的字段searchAddr,用于搜索可用内存的。利用searchAddr可以加速内存查找。searchAddr有一个重要的设定是它前面的地址一定是已经分配过的,因此在查找时,只需要向searchAddr地址的后方查找即可跳过已经查找的节点,减少查找的时间。

(利用searchAddr加速内存查找)

56.内存模型 - 图9

在第1次查找时,会从当前searchAddr的chunk块中查找是否有对应大小的连续空间,这种优化主要针对比较小的内存(至少小于512KB)分配。Go对内存有非常精细的管理,chunk块的每个page(8 KB)都有位图表明其是否已经被分配。
每个chunk都有一个pallocData结构,其中pallocBits管理其分配的位图。pallocBits是uint64,有8字节,由于其每一位对应一个page,因此pallocBits一共对应64×8=512KB,恰好是一个chunk块的大小。位图的对应方式和之前是一样的。
而所有的chunk pallocData都在pageAlloc结构中进行管理。
当内存分配过大或者当前chunk块没有连续的npages空间时,需要到基数树中从上到下进行查找。基数树有一个特性——要分配的内存越大,它能够越快地查找到当前的基数树中是否有连续的满足需求的空间。

在查找基数树的过程中,需要从上到下、从左到右地查找每个节点是否符合要求。先计算pallocSum的开头有多少连续的内存空间,如果大于或等于npages,则说明找到了可用的空间和地址。如果小于npages,则会计算pallocSum字段的max,即中间有多少连续的内存空间。如果max大于或等于npages,那么需要继续向基数树当前节点对应的下一级查找,原因在于,max大于npages,表明当前一定有连续的空间大于或等于npages,但是并不知道具体在哪一个位置,必须查找下一级才能找到可用的地址。如果max也不满足,那么是不是就不满足了呢?不一定,如图18-13所示,有可能两个节点可以合并起来组成一个更大的连续空间。因此还需要将当前pallocSum计算的end与后一个节点的start加起来查看是否能够组合成大于npages的连续空间。

(更大的可用内存可能跨越了多个pallocSum)

56.内存模型 - 图10

每一次从基数树中查找到内存,或者事后从操作系统分配到内存时,都需要更新基数树中每个节点的pallocSum。

操作系统内存申请

当在基数树中查找不到可用的连续内存时,需要从操作系统中获取内存。从操作系统获取内存的代码是平台独立的,(例如在UNIX操作系统中,最终使用了mmap系统调用向操作系统申请内存。)

Go语言规定,每一次向操作系统申请的内存大小必须为heapArena的倍数。heapArena是和平台有关的内存大小,在64位UNIX操作系统中,其大小为64MB。这意味着即便需要的内存很小,最终也至少要向操作系统申请64MB内存。多申请的内存可以用于下次分配。
Go语言中对于heapArena有精准的管理,精准到每个指针大小的内存信息,每个page对应的mspan信息都有记录。

小对象分配

当对象不属于微小对象时,在内存分配时会继续判断其是否为小对象,小对象指小于32KB的对象。Go会计算小对象对应哪一个等级的span,并在指定等级的span中查找。
此后和微小对象的分配一样,小对象分配经历mcache→mcentral→mheap位图查找→mheap基数树查找→操作系统分配的过程。

大对象分配

大对象指大于32KB的对象,内存分配时不与mcache和mcentral沟通,直接通过mheap进行分配。大对象分配经历mheap基数树查找→操作系统分配的过程。每个大对象都是一个特殊的span,其class为0。