抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

Golang内存分配机制

Go语言内置运行时(就是runtime), 抛弃了传统的内存分配方式, 改为自主管理。这样可以自主实现更好的内存使用模式, 比如内存池、预分配等等。这样, 不会每次内存分配都需要系统调用;

设计思想

  • 内存分配算法采用Google的TCMalloc算法, 每个线程都会自行维护一个独立的内存池, 进行内存分配时优先从该内存池中分配, 当内存池不足时才会向加锁的全局内存池申请, 减少系统调用并且避免不同线程对全局内存池的锁竞争;
  • 把内存切分的非常细小, 分为多级管理, 以降低锁的粒度;
  • 回收对象时, 并没有将其真正释放, 只是放回预先分配的大块内存中, 以便复用。只有内存闲置过多的时候, 才会尝试归还部分内存给操作系统, 降低整体开销;

分配组件

Go的内存管理组件主要有: mspanmcachemcentralmheap;

内存管理单元: mspan

mspan是内存管理的基本单元, 该结构体中包含nextprev两个字段, 它们分别指向前一个和后一个mspan, 每个mspan都管理npages个大小为8kb的页, 一个span是由多个page组成, 这里的页不是操作系统中的内存页, 它们是操作系统内存页的整数倍;

page是内存存储的基本单元, “对象”放到page

1
2
3
4
5
6
7
8
9
10
type mspan struct {
next *mspan // 前指针
prev *mspan // 后指针
startAddr uintptr // 管理页的起始地址, 指向page
npages uintptr // 页数
spanclass spanClass // 规格
...
}

type spanClass uint8

Go有68种不同大小的spanClass, 用于小对象分配

1
2
const _NumSizeClasses = 68
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}

如果按照序号为1的spanClass(对象规格为8B)分配, 每个span占用堆的字节数: 8k, mspan可以保存1024个对象;
如果按照序号为2的spanClass(对象规格为16B)分配, 每个span占用堆的字节数: 8k, mspan可以保存512个对象;

如果按照序号为67的spanClass(对象规格为32K)分配, 每个span占用堆的字节数: 32k, mspan可以保存1个对象;

class bytes/obj bytes/span objects tail waste max waste
1 8 8192 1024 0 87.50%
2 16 8192 512 0 43.75%
3 24 8192 341 0 29.24%
4 32 8192 256 0 46.88%
5 48 8192 170 32 31.52%
6 64 8192 128 0 23.44%
7 80 8192 102 32 19.07%
67 32768 8192 1 0 12.50%

字段含义:

  • class: class ID, 每个span结构种都有一个class ID, 表示该span可处理的对象类型;
  • bytes/obj: 该class代表对象的字节数;
  • bytes/span: 每个span占用堆的字节数, 也即页数*页大小;
  • objects: 每个span可分配的对象个数, 也即(bytes/spans) / (bytes/obj);
  • waste bytes: 每个span产生的内存碎片, 也即(bytes/spans) % (bytes/obj)

大于32k的对象出现时, 会直接从heap分配一个特殊的span, 这个特殊的span的类型(class)是0, 只包含了一个大对象;

线程缓存: mcache

mcache管理线程在本地缓存的mspan, 每个goroutine绑定的P都有一个mcache字段

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

_NumSizeClasses = 68
numSpanClasses = _NumSizeClasses << 1

mcacheSpan Classes作为索引管理多个用于分配的mspan, 它包含所有规格的mspan。它是_NumSizeClasses的2倍, 也就是68*2=136, 其中*2是将spanClass分成了有指针和没有指针两种, 方便于垃圾回收。对于每种规格, 有2个mspan, 一个不包含指针, 另一个则包含指针。对于无指针对象的mspan在进行垃圾回收时无需进一步扫描它是否引用了其他活跃的对象。

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

中心缓存: mcentral

mcentral管理全局的mspan供所有线程使用, 全局mheap变量包含central字段, 每个mcentral结构都维护在mheap结构内;

1
2
3
4
5
6
type mcentral struct {
spanClass spanClass // 指当前规格大小

partial [2]spanSet // 有空闲object的mspan列表
full [2]spanSet // 没有空闲object的mspan列表
}

每个mcentral管理一种spanClassspan, 并将有空闲空间和没有空闲空间的mspan分开管理。partailfull的数据类型为spanSet, 表示mspans集, 可用通过poppush来获得mspans;

1
2
3
4
5
6
7
8
type spanSet struct {
spineLock mutex
spine unsafe.Pointer // 指向[]span指针
spineLen uintptr // Spine array length, accessed atomically
spineCap uintptr // Spine array cap, accessed under lock

index headTailIndex // 前32位是头指针, 后32位是尾指针
}

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

  • 获取: 加锁, 从partail链表找到一个可用的mspan; 并将其从partail链表删除; 将取出的mspan加入到full链表中; 将mspan返回给工作流程, 解锁;
  • 归还: 加锁, 将mspanfull链表中删除, 将mspan加入到partail链表中, 解锁;

页堆: mheap

mheap管理Go所有动态分配内存, 可以认为是Go程序持有整个堆空间, 全局唯一;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var mheap_ mheap
type mheap struct {
lock mutex // 全局锁
pages pageAlloc // 页面分配的数据结构
allspans []*mspan // 所有通过mheap_申请的mspans
//堆
arenas [1 << arenas1Bits]*[1 << arenas2Bits]*heapArena

// 所有中心缓存mcentral
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{}) % cpu.CacheLinePadSize]byte
}
...
}

所有mcentral的集合则是存放于mheap中的。mheap里的arena区域是堆内存的抽象, 运行时会将8KB看做一页, 这些内存页存储了所有在堆上初始化的对象。运行时使用二维的runtime.heapArena数组管理所有的内存, 每个runtime.heapArena都会管理64MB的内存;

当申请内存时, 依次经过mcachemcentral都没有可用合适规格的大小内存, 这时候会向mheap申请一块内存。然后按指定规格划分为一些列表, 并将其添加到相同规格大小的mcentral非空闲列表后面;

分配对象

  • 微对象(0, 16B): 先使用线程缓存上的微型分配器, 再依次尝试线程缓存、中心缓存、堆 分配内存;
  • 小对象(16B, 32KB): 一次尝试线程缓存、中心缓存、堆 分配内存;
  • 大对象(32KB, +∞): 直接尝试堆分配内存;

分配流程

  • 首先通过计算使用的大小规格;
  • 然后使用mcache中对应大小规格的块分配;
  • 如果mcentral中没有可用的块, 则向mheap申请, 并根据算法找到最合适的mspan;
  • 如果申请到的mspan超出申请大小, 将会根据需求进行切分, 以返回用户所需的页数, 剩余的页构成一个新的mspan放回mheap的空闲列表中;
  • 如果mheap中没有可用的span, 则向操作系统申请一系列新的页(最小1MB);

Golang内存管理-其二

TCMalloc

go内存管理是借鉴了TCMalloc的设计思想, TCMalloc全称Thread-Caching Malloc, 是Google开发的内存分配器。

Page

操作系统对内存管理以页为单位, TCMalloc也是这样, 只不过TCMalloc里的Page大小与操作系统大小并不一定相等, 而是倍数关系;

Span

一组连续的Page被称为Span, 比如可以有4个页大小的Sapn, 也可以有8个页大小的Span, Span比Page高一个层级, 是为了方便管理一定大小的内存区域, Span是TCMalloc中内存管理的基本单位;

ThreadCache

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

CentalCache

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

PageHeap

PageHeap是堆内存的抽象, PageHeap存的也是若干链表, 链表保存的是Span, 当CentralCache没有内存时, 会从PageHeap中取, 把1个Span拆成若干内存块, 添加到对应大小的链表中, 当CentralCache内存多的时候, 会放回PageHeap中;

TCMalloc对象分配

小对象直接从ThreadCache中分配, 若ThreadCache不够则从CentralCache中获取内存, CentralCache内存不够时再从PageHeap中获取内存, 大对象在PageHeap中选择合适的页组成Span用于存储数据;

Go内存管理

经过上述对TCMalloc内存管理的描述, 接着看一下Go内存管理架构图

Page

和TCMalloc中的Page相同, 图中最下方浅蓝色长方形代表一个Page;

Span

与TCMalloc中的Span相同, Span是go内存管理的基本单元, 代码中为mspan, 一组连续的Page组成1个Span, 所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span, 另外, 1个淡紫色长方形为1个Span;

mcache

mcache与TCMalloc中的ThreadCache类似, mcache保存的是各种大小的Span, 并按Span class分类, 小对象直接从mcache分配内存, 它起到了缓存的作用, 并且可以无锁访问。但mcache与ThreadCache也有不同点, TCMalloc中是每个线程1个ThreadCache, Go中是每个P拥有1个mcache, 因为在Go程序中, 当前最多有GOMAXPROC个线程运行, 所以最多需要GOMAXPROCS个mcache就可以保证各线程对mache的无锁访问, 下图是G, P, M三者之间的关系:

mcentral

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

mheap

mheap与TCMalloc中的PageHeap类似, 它是堆内存的抽象, 把从OS(系统)申请出的内存页组织成Span, 并保存起来。当mcentral的Span不够用时会向mheap申请, mheap的Span不够用时会向OS申请, 向OS的内存申请是按页来的, 然后把申请来的内存页生成Span组织起来, 同样也是需要加锁访问的。但mheap与PageHeap也有不同点: mhep把Span组织成了树结构, 而不是链表, 并且还是两棵树, 然后把Span分配到heapArena进行管理, 它包含地址映射和Span是否包含指针等位图, 这样做的主要原因是为了更高效的利用内存: 分配、回收和再利用;

学习参考资料

评论