图解Go语言内存分配
Go语言内置运行时(就是runtime
),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。
Golang运行时的内存分配算法主要源自 Google 为 C 语言开发的TCMalloc
算法,全称Thread-Caching Malloc
。核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。
基础概念
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
的地址是由高地址向低地址增长的。
spans区域
存放mspan
(也就是一些arena
分割的页组合起来的内存管理基本单元,后文会再讲)的指针,每个指针对应一页,所以spans
区域的大小就是512GB/8KB*8B=512MB
。除以8KB
是计算arena
区域的页数,而最后乘以8
是计算spans
区域所有指针的大小。创建mspan
的时候,按页填充对应的spans
区域,在回收object
时,根据地址很容易就能找到它所属的mspan
。
内存管理单元
mspan
:Go中内存管理的基本单元,是由一片连续的8KB
的页组成的大块内存。注意,这里的页和操作系统本身的页并不是一回事,它一般是操作系统页大小的几倍。一句话概括:mspan
是一个包含起始地址、mspan
规格、页的数量等内容的双端链表。
每个mspan
按照它自身的属性Size Class
的大小分割成若干个object
,每个object
可存储一个对象。并且会使用一个位图来标记其尚未使用的object
。属性Size Class
决定object
大小,而mspan
只会分配给和object
尺寸大小接近的对象,当然,对象的大小要小于object
大小。还有一个概念:Span Class
,它和Size Class
的含义差不多,
1
Size_Class = Span_Class / 2
这是因为其实每个 Size Class
有两个mspan
,也就是有两个Span Class
。其中一个分配给含有指针的对象,另一个分配给不含有指针的对象。这会给垃圾回收机制带来利好,之后的文章再谈。
如下图,mspan
由一组连续的页组成,按照一定大小划分成object
。
Go1.9.2里mspan
的Size Class
共有67种,每种mspan
分割的object
大小是8*2n
的倍数,这个是写死在代码里的:
1
2
3
4
5
// 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}
根据mspan
的Size Class
可以得到它划分的object
大小。 比如Size Class
等于3
,object
大小就是32B
。 32B
大小的object
可以存储对象大小范围在17B~32B
的对象。而对于微小对象(小于16B
),分配器会将其进行合并,将几个对象分配到同一个object
中。
数组里最大的数是32768
,也就是32KB
,超过此大小就是大对象了,它会被特别对待,这个稍后会再介绍。顺便提一句,类型Size Class
为0
表示大对象,它实际上直接由堆内存分配,而小对象都要通过mspan
来分配。
对于mspan
来说,它的Size Class
会决定它所能分到的页数,这也是写死在代码里的:
1
2
3
4
5
// 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
大小为32B
的mspan
的时候,在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
29
30
// path: /usr/local/go/src/runtime/mheap.go
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
放到更大的视角来看:
上图可以看到有两个S指向了同一个mspan
,因为这两个S
指向的P
是同属一个mspan
的。所以,通过arena
上的地址可以快速找到指向它的S
,通过S
就能找到mspan
,回忆一下前面我们说的mspan
区域的每个指针对应一页。
假设最左边第一个mspan
的Size Class
等于10
,根据前面的class_to_size
数组,得出这个msapn
分割的object
大小是144B
,算出可分配的对象个数是8KB/144B=56.89
个,取整56
个,所以会有一些内存浪费掉了,Go的源码里有所有Size Class
的mspan
浪费的内存的大小;再根据class_to_allocnpages
数组,得到这个mspan
只由1
个page
组成;假设这个mspan
是分配给无指针对象的,那么spanClass
等于20
。
startAddr
直接指向arena
区域的某个位置,表示这个mspan
的起始地址,allocBits指向一个位图,每位代表一个块是否被分配了对象;allocCount
则表示总共已分配的对象个数。
这样,左起第一个mspan的各个字段参数就如下图所示:
内存管理组件
内存分配由内存分配器完成。分配器由3种组件构成:mcache, mcentral, mheap。
mcache
mcache
:每个工作线程都会绑定一个mcache
,本地缓存可用的mspan
资源,这样就可以直接给Goroutine
分配,因为不存在多个Goroutine
竞争的情况,所以不会消耗锁资源。
mcache
的结构体定义:
1
2
3
4
5
6
7
//path: /usr/local/go/src/runtime/mcache.go
type mcache struct {
alloc [numSpanClasses]*mspan
}
numSpanClasses = _NumSizeClasses << 1
mcache
用Span Classes
作为索引管理多个用于分配的mspan
,它包含所有规格的mspan
。它是_NumSizeClasses
的2
倍,也就是67*2=134
,为什么有一个两倍的关系,前面我们提到过:为了加速之后内存回收的速度,数组里一半的mspan
中分配的对象不包含指针,另一半则包含指针。
对于无指针对象的mspan
在进行垃圾回收的时候无需进一步扫描它是否引用了其他活跃的对象。 后面的垃圾回收文章会再讲到,这次先到这里。
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
18
//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
}
empty
表示这条链表里的mspan
都被分配了object
,或者是已经被cache
取走了的mspan
,这个mspan
就被那个工作线程独占了。而nonempty
则表示有空闲对象的mspan
列表。每个central
结构体都在mheap
中维护。
简单说下mcache
从mcentral
获取和归还mspan
的流程:
-
获取加锁;从
nonempty
链表找到一个可用的mspan
;并将其从nonempty
链表删除;将取出的mspan
加入到empty
链表;将mspan
返回给工作线程;解锁。 -
归还加锁;将
mspan
从empty
链表删除;将mspan
加入到nonempty
链表;解锁。
mheap
mheap
:代表Go程序持有的所有堆空间,Go程序使用一个mheap
的全局对象_mheap
来管理堆内存。
当mcentral
没有空闲的mspan
时,会向mheap
申请。而mheap
没有资源时,会向操作系统申请新内存。mheap
主要用于大对象的内存分配,以及管理未切割的mspan
,用于给mcentral
切割成小对象。
同时我们也看到,mheap
中含有所有规格的mcentral
,所以,当一个mcache
从mcentral
申请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
}
}
上图我们看到,bitmap
和arena_start
指向了同一个地址,这是因为bitmap
的地址是从高到低增长的,所以他们指向的内存位置相同。
分配流程
上一篇文章《Golang之变量去哪儿》中我们提到了,变量是在栈上分配还是在堆上分配,是由逃逸分析的结果决定的。通常情况下,编译器是倾向于将变量分配到栈上的,因为它的开销小,最极端的就是”zero garbage
“,所有的变量都会在栈上分配,这样就不会存在内存碎片,垃圾回收之类的东西。
Go的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于16B
)、一般对象(大于16B
,小于等于32KB
)、大对象(大于32KB
)。
大体上的分配流程:
> 32KB
的对象,直接从mheap
上分配;<=16B
的对象使用mcache
的tiny
分配器分配;(16B,32KB]
的对象,首先计算对象的规格大小,然后使用mcache
中相应规格大小的mspan
分配;- 如果mcache没有相应规格大小的mspan,则向mcentral申请
- 如果mcentral没有相应规格大小的mspan,则向mheap申请
- 如果mheap中也没有合适大小的mspan,则向操作系统申请
总结
Go语言的内存分配非常复杂,它的一个原则就是能复用的一定要复用。源码很难追,后面可能会再来一篇关于内存分配的源码阅读相关的文章。简单总结一下本文吧。
文章从一个比较粗的角度来看Go的内存分配,并没有深入细节。一般而言,了解它的原理,到这个程度也可以了。
- Go在程序启动时,会向操作系统申请一大块内存,之后自行管理。
- Go内存管理的基本单元是mspan,它由若干个页组成,每种mspan可以分配特定大小的object。
- mcache, mcentral, mheap是Go内存管理的三大组件,层层递进。mcache管理线程在本地缓存的mspan;mcentral管理全局的mspan供所有线程使用;mheap管理Go的所有动态分配内存。
- 极小对象会分配在一个object中,以节省资源,使用tiny分配器分配内存;一般小对象通过mspan分配内存;大对象则直接由mheap分配内存。