Go-06-内存管理

带着问题看世界

  1. 内存是如何管理的
  2. 如何根据指定的大小分配内存的

基础概念

Go在程序启动的时候,会先向操作系统申请一块内存(注意这时还只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理。申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小。

堆区总览
  • arena区域就是所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan

  • bitmap区标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B)的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB

bitmap arena 堆区总览

从上图其实还可以看到bitmap的高地址部分指向arena区域的低地址部分,也就是说bitmap的地址是由高地址向低地址增长的。

  • spans区域存放mspan(也就是一些arena分割的页组合起来的内存管理基本单元)的指针,每个指针对应一页,所以spans区域的大小就是512GB/8KB*8B=512MB。除以8KB是计算arena区域的页数,而最后乘以8是计算spans区域所有指针的大小。创建mspan的时候,按页填充对应的spans区域,在回收object时,根据地址很容易就能找到它所属的mspan

内存管理单元

mspan:Go中内存管理的基本单元,是由一片连续的8KB的页组成的大块内存。它是一个包含起始地址、mspan规格、页的数量等内容的双端链表

mspan-and-linked-list

每个mspan按照它自身的属性Size Class的大小分割成若干个object,每个object可存储一个对象。并且会使用一个位图来标记其尚未使用的object,属性Size Class决定object大小,而mspan只会分配给和object尺寸大小接近的对象,当然,对象的大小要小于object大小。还有一个概念:Span Class,它和Size Class的含义差不多,Size_Class = Span_Class / 2

为什么乘以2,因为每个 Size Class有两个mspan,也就是有两个Span Class。其中一个分配给含有指针的对象,另一个分配给不含有指针的对象。如何寻找为对象寻span,寻找span的流程如下:

  • 计算对象所需内存大小size。
  • 根据size到size class映射,计算出所需的size class。
  • 根据size class和对象是否包含指针计算出span class。
  • 获取该span class指向的span。

如下图,mspan由一组连续的页组成,按照一定大小划分成object

page mspan

mspanSize Class共有67种,每种mspan分割的object大小是8*2n的倍数,这个是写死在代码里的:

1
2
3
4
// path: /usr/local/go/src/runtime/sizeclasses.go
const _NumSizeClasses = 67

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536,1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

数组里最大的数是32768,也就是32KB,超过此大小就是大对象;类型Size Class为0表示大对象,它实际上直接由堆内存分配,而小对象都要通过mspan来分配。

对于mspan来说,它的Size Class会决定它所能分到的页数,这也是写死在代码里的:

1
2
3
4
// path: /usr/local/go/src/runtime/sizeclasses.go
const _NumSizeClasses = 67

var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}

比如要申请一个object大小为32Bmspan的时候,在class_to_size里对应的索引是3,而索引3在class_to_allocnpages数组里对应的页数就是1。

mspan结构体定义

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
28
type mspan struct {
//链表前向指针,用于将span链接起来
next *mspan

//链表前向指针,用于将span链接起来
prev *mspan

// 起始地址,也即所管理页的地址
startAddr uintptr

// 管理的页数
npages uintptr

// 块个数,表示有多少个块可供分配
nelems uintptr

//分配位图,每一位代表一个块是否已分配
allocBits *gcBits

// 已分配块的个数
allocCount uint16

// class表中的class ID,和Size Classs相关
spanclass spanClass

// class表中的对象大小,也即块大小
elemsize uintptr
}

mspan放到更大的视角来看:

mspan更大视角

上图可以看到有两个S指向了同一个mspan,因为这两个S指向的P是同属一个mspan的。所以,通过arena上的地址可以快速找到指向它的S,通过S就能找到mspan,回忆一下前面我们说的mspan区域的每个指针对应一页。

假设最左边第一个mspanSize Class等于10,根据前面的class_to_size数组,得出这个msapn分割的object大小是144B,算出可分配的对象个数是8KB/144B=56.89个,取整56个,所以会有一些内存浪费掉了,Go的源码里有所有Size Classmspan浪费的内存的大小;再根据class_to_allocnpages数组,得到这个mspan只由1个page组成;假设这个mspan是分配给无指针对象的,那么spanClass等于20。

startAddr直接指向arena区域的某个位置,表示这个mspan的起始地址,allocBits指向一个位图,每位代表一个块是否被分配了对象;allocCount则表示总共已分配的对象个数。

这样,左起第一个mspan的各个字段参数就如下图所示:

左起第一个mspan具体值

内存管理元件

内存分配由内存分配器完成。分配器由3种组件构成:mcache, mcentral, mheap

img
  • Span 是 Go 内存管理的基本单位,代码中为 mspan,由一组连续的 page 组成

  • mcache 与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。

    Go中是每个P拥有1个mcache,最多需要 GOMAXPROCS 个mcache就可以保证各线程对mcache的无锁访问

  • mcentral 与TCMalloc中的CentralCache不同的是CentralCache 是每个级别的Span有1个链表,mcache是每个级别的Span有2个链表。为什么有两个?

  • mheap 与TCMalloc中的PageHeap不同点的是 mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用

  • Go的内存管理基本单位是span,每个span通过spanclass标识属于哪种规格的span,golang的span规格一共有67种,详细可查看 src/runtime/sizeclasses.go

go-memory-layout

mcache

mcache:每个工作线程都会绑定一个mcache,本地缓存可用的mspan资源,这样就可以直接给Goroutine分配,因为不存在多个Goroutine竞争的情况,所以不会消耗锁资源。

mcache的结构体定义:

1
2
3
4
5
type mcache struct {
alloc [numSpanClasses]*mspan
}

numSpanClasses = _NumSizeClasses << 1

mcacheSpan Classes作为索引管理多个用于分配的mspan,它包含所有规格的mspan。它是_NumSizeClasses的2倍,也就是67*2=134

为什么有一个两倍的关系:为了加速之后内存回收的速度,数组里一半的mspan中分配的对象不包含指针,另一半则包含指针。而无指针的mspan在进行垃圾回收的时候是不需要扫描它是否引用了其他活跃对象的。

mcache

mcache在初始化的时候是没有任何mspan资源的,在使用过程中会动态地从mcentral申请,之后会缓存下来。当对象小于等于32KB大小时,使用mcache的相应规格的mspan进行分配

mcentral

mcentral:为所有mcache提供切分好的mspan资源。每个central保存一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。 每个mcentral对应一种mspan,而mspan的种类导致它分割的object大小不同。当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral获取。

mcentral被所有的工作线程共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源。结构体定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//path: /usr/local/go/src/runtime/mcentral.go
type mcentral struct {
// 互斥锁
lock mutex

// 规格
sizeclass int32

// 尚有空闲object的mspan链表
nonempty mSpanList

// 没有空闲object的mspan链表,或者是已被mcache取走的msapn链表
empty mSpanList

// 已累计分配的对象个数
nmalloc uint64
}
mcentral

empty表示这条链表里的mspan都被分配了object,或者是已经被cache取走了的mspan,这个mspan就被那个工作线程独占了。而nonempty则表示有空闲对象的mspan列表。每个central结构体都在mheap中维护。

mcachemcentral获取和归还mspan的流程:

  • 获取

    • 加锁;
    • nonempty链表找到一个可用的mspan
    • 并将其从nonempty链表删除;
    • 将取出的mspan加入到empty链表;
    • mspan返回给工作线程;
    • 解锁。
  • 归还

    • 加锁;
    • mspanempty链表删除;
    • mspan加入到nonempty链表;
    • 解锁。

mheap

mheap:代表Go程序持有的所有堆空间,Go程序使用一个mheap的全局对象_mheap来管理堆内存。

  • mcentral没有空闲的mspan时,会向mheap申请。

  • mheap没有资源时,会向操作系统申请新内存。

mheap主要用于大对象的内存分配,以及管理未切割的mspan,用于给mcentral切割成小对象

mheap中含有所有规格的mcentral,所以,当一个mcachemcentral申请mspan时,只需要在独立的mcentral中使用锁,并不会影响申请其他规格的mspan

mheap结构体定义:

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
//path: /usr/local/go/src/runtime/mheap.go

type mheap struct {
lock mutex

// spans: 指向mspans区域,用于映射mspan和page的关系
spans []*mspan

// 指向bitmap首地址,bitmap是从高地址向低地址增长的
bitmap uintptr

// 指示arena区首地址
arena_start uintptr

// 指示arena区已使用地址位置
arena_used uintptr

// 指示arena区末地址
arena_end uintptr

central [67*2]struct {
mcentral mcentral
pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
}
}
mheap

内存分配流程

Go的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于16B)、一般对象(大于16B,小于等于32KB)、大对象(大于32KB)。

大体上的分配流程:

  • 大于 32KB 的对象,直接从mheap上分配;

  • 小于等于 16B 的对象使用mcache的tiny分配器分配;

  • (16B,32KB] 的对象,首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;

    • 如果mcache没有相应规格大小的mspan,则向mcentral申请
    • 如果mcentral没有相应规格大小的mspan,则向mheap申请
    • 如果mheap中也没有合适大小的mspan,则向操作系统申请

地址空间

Go 语言的运行时构建了操作系统的内存管理抽象层,将运行时管理的地址空间分成以下四种状态

  • None: 内存没有被保留或者映射,是地址空间的默认状态
  • Reserved: 运行时持有该地址空间,但是访问该内存会导致错误
  • Prepared: 内存被保留,一般没有对应的物理内存访问该片内存的行为是未定义的可以快速转换到 Ready 状态
  • Ready: 可以被安全访问

不同状态之间的转换过程:

memory-regions-states-and-transitions

运行时中包含多个操作系统实现的状态转换方法,所有的实现都包含在以 mem_ 开头的文件中,本节将介绍 Linux 操作系统对上图中方法的实现:

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

运行时使用 Linux 提供的 mmapmunmapmadvise 等系统调用实现了操作系统的内存管理抽象层

参考链接

  1. https://qcrao.com/2019/03/13/graphic-go-memory-allocation/
  2. https://www.infoq.cn/article/IEhRLwmmIM7-11RYaLHR
  3. https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/