Go 内存分配

405 阅读8分钟

1. 前导:TCMalloc 算法

Golang运行时的内存分配算法主要源自 Google 为 C 语言开发的TCMalloc算法,全称Thread-Caching Malloc。核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

结合上图,介绍TCMalloc的几个重要概念:

  • Page

操作系统对内存管理以页为单位,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。《TCMalloc解密》里称x64下Page大小是8KB。

  • Span

一组连续的Page被称为Span,Span比Page高一个层级,是为了方便管理一定大小的内存区域,Span是TCMalloc中内存管理的基本单位。

  • ThreadCache

每个线程各自的Cache,一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。

  • CentralCache

所有线程共享的缓存,也保存空闲内存块链表,链表的数量与ThreadCache中链表数量相同,当ThreadCache的内存块不足时,可以从CentralCache获取内存块;当ThreadCache内存块过多时,可以放回CentralCache。由于CentralCache是共享的,所以它的访问是要加锁的。

  • PageHeap

堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span。当CentralCache的内存不足时,会从PageHeap获取空闲的内存Span,然后把1个Span拆成若干内存块,添加到对应大小的链表中并分配内存;当CentralCache的内存过多时,会把空闲的内存块放回PageHeap中。

large span set 用来保存中大对象的。毫无疑问,PageHeap也是要加锁的。

  • 小对象大小:0~256KB
  • 中对象大小:257~1MB
  • 大对象大小:>1MB

小对象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分时候,ThreadCache缓存都是足够的,不需要去访问CentralCache和HeapPage,无系统调用配合无锁分配,分配效率是非常高的。

中对象分配流程:直接在PageHeap中选择适当的大小即可,128 Page的Span所保存的最大内存就是1MB。

大对象分配流程:从large span set选择合适数量的页面组成span,用来存储数据。

2. Go内存管理的基础概念

Go内存管理的许多概念在TCMalloc中已经有了,含义是相同的,只是名字有一些变化。

Go在程序启动时,先向OS申请一块内存(只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理。

申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小。 堆区总览 arena区域:堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页(与TCMalloc中的Page相同),一些页组合起来称为mspan(与TCMalloc中的Span相同)。

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

bitmap arena

bitmap arena

bitmap的高地址部分指向arena区域的低地址部分,即bitmap的地址是由高地址向低地址增长的。

spans区域存放mspan的指针,每个指针对应一页,spans区域的大小为512GB/8KB*8B=512MB创建mspan时,按页填充对应的spans区域,在回收object时,根据地址很容易就能找到它所属的mspan

3. 内存管理单元

建议截图配合后文一起观看 image.png mspan:Go内存管理的基本单元,由一片连续的8KB的页组成的大块内存。一句话概括:mspan是一个包含起始地址、mspan规格、页的数量等内容的双端链表。

每个mspan按照它自身的属性Size Class的大小分割成若干个object,每个object可存储一个对象。并且会使用一个位图来标记其尚未使用的object

image.png 属性Size Class决定object大小 ,它是 object size 的级别,相当于把 size 归类到一定大小的区间段,比如size(0,8]属于Size Class 1,size(8,16]属于Size Class 2。

mspanSize Class共有67种,每种mspan分割的object大小是8*2n的倍数:

// 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}

根据mspanSize Class可以得到它划分的object大小。 比如Size Class等于3,object大小就是32B。 32B大小的object可以存储对象大小范围在17B~32B的对象。而对于微小对象(小于16B),分配器会将其进行合并,将几个对象分配到同一个object中。

数组里最大的数是32768,也就是32KB,超过此大小就是大对象了,它会被特别对待,稍后再介绍。类型Size Class为0表示大对象,它实际上直接由堆内存分配,而小对象都要通过mspan来分配。

对于mspan来说,它的Size Class会决定它所能分到的页数:

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。

还有一个概念:Span Class,指 mspan 的级别。span class 主要用来和 size class 做对应,1个 size class 对应2个 span class ,2个 span classspan大小相同 ,只是功能不同,1个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的Span就无需GC扫描了。

mspan结构体定义:

// path: /usr/local/go/src/runtime/mheap.go

type mspan struct {
        //链表后向指针,用于将span链接起来
	next *mspan	
	
	//链表前向指针
	prev *mspan	
	
	// 起始地址,即所管理页的地址
	startAddr uintptr 
	
	// 管理的页数
	npages uintptr 
	
	// 块个数,表示有多少个块可供分配
	nelems uintptr 

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

        // 已分配块的个数
	allocCount uint16 
	
	// class表中的class ID,和Size Classs相关
	spanclass spanClass  

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

我们将mspan放到更大的视角来看: mspan更大视角 假设最左边第一个mspanSize Class等于10,根据class_to_size数组,得出这个msapn分割的object大小是144B,算出可分配的对象个数是8KB/144B=56.89个,取整56个,会有些浪费内存;再根据class_to_allocnpages数组,得到这个mspan只由1个page组成;假设这个mspan是分配给无指针对象的,那么spanClass等于20。(spanClass里无指针对象在后半段)

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

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

左起第一个mspan具体值

4. 内存管理组件

内存分配由内存分配器完成。分配器由3种组件构成:mcachemcentralmheap

4.1 mcache

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

不同点:TCMalloc 中是每个线程1个ThreadCache,Go中是每个 P 拥有1个mcache。因为在Go程序中,当前最多有 GOMAXPROCS 个线程在运行,所以最多需要 GOMAXPROCS 个 mcache 就可以保证各线程对 mcache 的无锁访问,线程的运行又是与P绑定的,把 mcache 交给 P 刚刚好。

mcache的结构体定义:

//path: /usr/local/go/src/runtime/mcache.go

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进行分配。

4.2 mcentral

mcentral 与 TCMalloc中的 CentralCache 类似,是所有线程共享的缓存,需要加锁访问。它按 Span级别 对 Span 分类,然后串联成链表,当 mcache 的某个级别 Span 的内存被分配光时,会向 mcentral 申请1个当前级别的 Span。

不同点:CentralCache 是每个级别的 Span 有1个链表,mcentral是每个级别的Span有2个链表,这和mcache申请内存有关,稍后我们再解释。

//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列表。

简单说下mcachemcentral获取和归还mspan的流程:

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

4.3 mheap

mheap 与 TCMalloc 中的 PageHeap 类似,它是堆内存的抽象,把从OS申请出的内存页组织成 Span ,并保存起来。当 mcentralSpan 不够用时会向 mheap 申请内存,而 mheapSpan 不够用时会向OS申请内存。

mheap向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。

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

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

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

mheap结构体定义:

//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 
        
        // 包含所有的mcentral
	central [67*2]struct {
		mcentral mcentral
		pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
	}
}

mheap

上图我们看到,bitmap和arena_start指向了同一个地址,这是因为bitmap的地址是从高到低增长的,所以他们指向的内存位置相同。

5. 内存分配流程

变量是在栈上分配还是在堆上分配,由逃逸分析的结果决定。通常情况下,编译器是倾向于将变量分配到栈上的,因为开销小。

Go的内存分配器在分配对象时,根据对象的大小分类 image.png 大体上的分配流程:

  • 大对象直接从mheap上分配
  • Tiny对象使用mcache的tiny分配器分配
  • 其他小对象,首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;
    • 如果mcache没有相应规格大小的mspan,则向mcentral申请
    • 如果mcentral没有相应规格大小的mspan,则向mheap申请
    • 如果mheap中也没有合适大小的mspan,则向操作系统申请

5.1 小对象的内存分配

  • 为对象寻找span
    1. 计算对象所需内存大小size
    2. 根据size到size class映射,计算出所需的size class
    3. 根据size class和对象是否包含指针计算出span class
    4. 获取该span class指向的span

以分配一个不包含指针的,大小为24Byte的对象为例,根据映射表:

// class  bytes/obj  bytes/span  objects  
//     1          8        8192     1024          
//     2         16        8192      512        
//     3         32        8192      256        
//     4         48        8192      170         

对应的size class为3,它的对象大小范围是(16,32]Byte,24Byte刚好在此区间,所以此对象的size class为3。

Size class到span class的计算如下:

// noscan为true代表对象不包含指针
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
    return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

对应的span class为7,该对象需要的是span class 7指向的span。

span class = 3 << 1 | 1 = 7
  • 从span分配对象空间

Span可以按对象大小切成很多份,这些都可以从映射表上计算出来,以size class 3对应的span为例,span大小是8KB,每个对象实际所占空间为32Byte,这个span就被分成了256块,可以根据span的起始地址计算出每个对象块的内存地址。

随着内存的分配,span中的对象内存块,有些被占用,有些未被占用,如上图,整体代表1个span,蓝色块代表已被占用内存,绿色块代表未被占用内存。当分配内存时,只要快速找到第一个可用的绿色块,并计算出内存地址即可,如果需要还可以对内存块数据清零。

当span内的所有内存块都被占用时,没有剩余空间继续分配对象,mcache会向mcentral申请1个span,mcache拿到span后继续分配对象。

  • mcache向mcentral申请span

mcentral和mcache一样,都是0~133这134个span class级别,但每个级别都保存了2个span list,即2个span链表:

  1. nonempty:这个链表里的span,所有span都至少有1个空闲的对象空间。这些span是mcache释放span时加入到该链表的。
  2. empty:这个链表里的span,所有的span都不确定里面是否有空闲的对象空间。当一个span交给mcache的时候,就会加入到empty链表。

这两个东西名称一直有点绕,建议直接把empty理解为没有对象空间就好了。

mcache向mcentral申请span时,mcentral会先从nonempty搜索满足条件的span,如果没有找到再从emtpy搜索满足条件的span,然后把找到的span交给mcache。

  • mheap的span管理

mheap里保存了两棵二叉排序树,按span的page数量进行排序:

  1. free:保存空闲并且非垃圾回收的span。
  2. scav:保存空闲并且已经垃圾回收的span。

如果是垃圾回收导致的span释放,span会被加入到scav,否则加入到free,比如刚从OS申请的的内存也组成的Span。

arenas由一组heapArena组成,每个heapArena都包含了连续的pagesPerArenaspan,这个主要是为mheap管理span和垃圾回收服务。

  • mcentral向mheap申请span

当mcentral向mcache提供span时,如果empty里也没有符合条件的span,mcentral会向mheap申请span。

此时,mcentral需要向mheap提供需要的内存页数和span class级别,然后它优先从free中搜索可用的span。如果没有找到,会从scav中搜索可用的span。还没有找到则向OS申请内存,再重新搜索2棵树。如果找到的span比需要的span大,则把span进行分割成2个span,其中1个刚好是需求大小,把剩下的span再加入到free中去,然后设置需要的span的基本信息,然后交给mcentral。

  • mheap向OS申请内存

当mheap没有足够的内存时,mheap会向OS申请内存,把申请的内存页保存为span,然后把span插入到free树。在32位系统中,mheap还会预留一部分空间,当mheap没有空间时,先从预留空间申请,如果预留空间内存也没有了,才向OS申请。

5.2 大对象的内存分配

99%的流程与mcentral向mheap申请内存的相同,不同的一点在于 mheap 会记录 大对象的统计信息。

6. Go的栈内存

内存管理不应当只有堆,也应当有栈。每个goroutine都有自己的栈,栈的初始大小是2KB,100万的goroutine会占用2G,但goroutine的栈会在2KB不够用时自动扩容,当扩容为4KB的时候,百万goroutine会占用4GB。

总结

Go内存分配的一个原则就是能复用的一定要复用。

  • Go在程序启动时,会向操作系统申请一大块内存,之后自行管理。
  • Go内存管理的基本单元是mspan,它由若干个页组成,每种mspan可以分配特定大小的object。
  • mcache, mcentral, mheap是Go内存管理的三大组件,层层递进。mcache管理线程在本地缓存的mspan;mcentral管理全局的mspan供所有线程使用;mheap管理Go的所有动态分配内存。
  • 极小对象会分配在一个object中,以节省资源,使用tiny分配器分配内存;一般小对象通过mspan分配内存;大对象则直接由mheap分配内存。

 本文为缝合怪文章,在原文基础上稍微修改的简单易懂一些
饶全成老师  <图解 Go 语言内存分配 | qcrao 的博客 >
零壹技术栈老师 《详解Go语言的内存模型及堆的分配管理 - 知乎 (zhihu.com)