Go - 内存分配
一、介绍一般程序内存分配
在讲Golang的内存分配之前,让我们先来看看一般程序的内存分布情况:
| 内存区域 | 存储的内存 | 说明 |
|---|---|---|
| 全局区 | 存放全局变量 | 1. 编译的时候以及分配好了 2. 有操作系统管理 |
| 栈区 | 存放函数中的基础类类型变量 | 1. 由程序系统向系统申请、由操作系统管理 2. 每个线程由自己的栈区、速度快、适用方便 |
| 堆区 | 动态分配的内存, 比如 go 的切片 | 1. 程序运行时动态分配内存大小、频繁分配和释放 2. 内存可以由程序员控制、free或者delete |
| 常量区 | 存放常量数据 | 1. 存放常量字符串内存、程序结束后由系统释放 |
| 程序代码区 | 存放程序本身的代码 | 1. 存放程序的二进制代码 |
二、Go的内存分配思想
go 内置了运行时的编程语言(runtime), 所谓运行时, 就是在程序开始时就申请了一大块的虚拟内存, 由 go 自己进行分配和管理. 用来避免在运行的时候再向操作系统申请内存, 带来性能问题。
go 的内存分配核心思想是:
- 每次从操作系统申请一大块内存, 由 go 来对内存进行分配和管理, 减少系统调用
- 内存分配算法采用 google 的
TCMalloc算法, 把内存切分的很细, 再通过多级进行管理, 降低锁粒度 - 回收对象内存时,并不是真正的将内存返回给操作系统, 而是放回自己的大块内存中等待复用, 只有闲置过多时才会尝试返回部分内存给操作系统.
三、Go语言内存结构
为了方便自主管理内存,做法便是先向系统申请一块内存,然后将内存切割成小块,通过一定的内存分配算法管理内存。以64位系统为例,Golang程序启动时会向系统申请的内存如下图所示:
预申请的内存划分为spans、bitmap、arena三部分。其中arena即为所谓的堆区,应用中需要的内存从这里分配。 其中spans和bitmap是为了管理arena区而存在的。
-
arena区域
Go动态分配的内存都是在这个区域 , arena的大小为512G,为了方便管理把arena区域划分成一个个的page,每个page为8KB,一共有512GB/8KB个 页
-
bitmap区域
bitmap区域标识arena区域哪些地址保存了对象,不过主要用于GC。 其中一个byte包括8位,用4位标志表示对象是否包含指针、用4位标志
GC扫描标记信息。
-
spans区域
存放span的指针,每个指针对应一个page,所以span区域的大小为(512GB/8KB)*指针大小8byte = 512M
四、内存管理组件
go 内存管理组件有以下几个:
- mspan: 内存管理基本单元
- mcache: 缓存, 每个运行时的 goroutine 都会绑定一个 mcache, mcache 会分配这个 goroutine 运行时需要的内存空间(mspan)
- mcentral: 为所有 mcache 切分好后备的 mspan, 收集给定大小和登记的所有 span
- mheap:代表Go程序持有的所有堆空间。还会管理闲置的span,需要时向操作系统申请新内存
4.1 mspan
span是用于管理arena页的关键数据结构,每个span中包含1个或多个连续页,为了满足小对象分配,span中的一页 会划分更小的粒度,而对于大对象比如超过页大小,则通过多页实现。
go 为了解决内存碎片问题. 将内存分为67种, 每种有不同数量的 page, 这每一种就是 mspan.每次分配时, 根据数据的不同, 分配给不同的 mspan. 当某个 mspan 被清理后, 在语言内部将这个标记为已清理, 等待下一次重新使用。
class系列
跟据对象大小,划分了一系列class,每个class都代表一个固定大小的对象,以及每个span的大小。如下表所示:
// 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 32 8192 256 0 46.88%
// 4 48 8192 170 32 31.52%
// 5 64 8192 128 0 23.44%
// 6 80 8192 102 32 19.07%
// 7 96 8192 85 32 15.95%
// 8 112 8192 73 16 13.56%
// 9 128 8192 64 0 11.72%
// 10 144 8192 56 128 11.82%
// 11 160 8192 51 32 9.73%
// 12 176 8192 46 96 9.59%
// 13 192 8192 42 128 9.25%
// 14 208 8192 39 80 8.12%
// 15 224 8192 36 128 8.15%
// 16 240 8192 34 32 6.62%
// 17 256 8192 32 0 5.86%
// 18 288 8192 28 128 12.16%
// 19 320 8192 25 192 11.80%
// 20 352 8192 23 96 9.88%
// 21 384 8192 21 128 9.51%
// 22 416 8192 19 288 10.71%
// 23 448 8192 18 128 8.37%
// 24 480 8192 17 32 6.82%
// 25 512 8192 16 0 6.05%
// 26 576 8192 14 128 12.33%
// 27 640 8192 12 512 15.48%
// 28 704 8192 11 448 13.93%
// 29 768 8192 10 512 13.94%
// 30 896 8192 9 128 15.52%
// 31 1024 8192 8 0 12.40%
// 32 1152 8192 7 128 12.41%
// 33 1280 8192 6 512 15.55%
// 34 1408 16384 11 896 14.00%
// 35 1536 8192 5 512 14.00%
// 36 1792 16384 9 256 15.57%
// 37 2048 8192 4 0 12.45%
// 38 2304 16384 7 256 12.46%
// 39 2688 8192 3 128 15.59%
// 40 3072 24576 8 0 12.47%
// 41 3200 16384 5 384 6.22%
// 42 3456 24576 7 384 8.83%
// 43 4096 8192 2 0 15.60%
// 44 4864 24576 5 256 16.65%
// 45 5376 16384 3 256 10.92%
// 46 6144 24576 4 0 12.48%
// 47 6528 32768 5 128 6.23%
// 48 6784 40960 6 256 4.36%
// 49 6912 49152 7 768 3.37%
// 50 8192 8192 1 0 15.61%
// 51 9472 57344 6 512 14.28%
// 52 9728 49152 5 512 3.64%
// 53 10240 40960 4 0 4.99%
// 54 10880 32768 3 128 6.24%
// 55 12288 24576 2 0 11.45%
// 56 13568 40960 3 256 9.99%
// 57 14336 57344 4 0 5.35%
// 58 16384 16384 1 0 12.49%
// 59 18432 73728 4 0 11.11%
// 60 19072 57344 3 128 3.57%
// 61 20480 40960 2 0 6.87%
// 62 21760 65536 3 256 6.25%
// 63 24576 24576 1 0 11.45%
// 64 27264 81920 3 128 10.00%
// 65 28672 57344 2 0 4.91%
// 66 32768 32768 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大小,超过32K大小的由特殊的class表示,该class ID为0,每个class只包含一个对象。
mspan的数据结构
span是内存管理的基本单位,每个span用于管理特定的class对象, 跟据对象大小,span将一个或多个页拆分成多 个块进行管理。
src/runtime/mheap.go:mspan
type mspan struct {
next *mspan //链表前向指针,用于将span链接起来
prev *mspan //链表前向指针,用于将span链接起来
startAddr uintptr // 起始地址,也即所管理页的地址
npages uintptr // 管理的页数
nelems uintptr // 块个数,也即有多少个块可供分配
allocBits *gcBits //分配位图,每一位代表一个块是否已分配
allocCount uint16 // 已分配块的个数
spanclass spanClass // class表中的class ID
elemsize uintptr // class表中的对象大小,也即块大小
}
以class 10为例span和管理的内存
spanclass为10,参照class表可得出 npages=1 , nelems=56 , elemsize为144。
其中startAddr是在span初始 化时就指定了某个页的地址。
allocBits指向一个位图,每位代表一个块是否被分配,本例中有两个块已经被分配, 其allocCount也为2。
next和prev用于将多个span链接起来,这有利于管理多个span。
4.2 mcache
有了管理内存的基本单位span,还要有个数据结构来管理span,这个数据结构叫mcentral,各线程需要内存时从 mcentral管理的span中申请内存。
为了避免多线程申请内存时不断的加锁,Golang为每个线程分配了span的缓 存,这个缓存即是cache。
src/runtime/mcache.go:mcache
type mcache struct {
.....
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
.....
alloc [67*2]*mspan // 按class分组的mspan列表
.....
}
- alloc为mspan的指针数组,数组大小为class总数的2倍。
- 数组中每个元素代表了一种class类型的span列表,每 种class类型都有两组span列表,第一组列表中所表示的对象中包含了指针,第二组列表中所表示的对象不含有指 针 (这么做是为了提高GC扫描性能,对于不包含指针的span列表,没必要去扫描)
根据对象是否包含指针,将对象分为noscan和scan两类,其中noscan代表没有指针,而scan则代表有指针,需要 GC进行扫描。
mcache和span的关系
上图所示,class 0的span数比class1的要多,说明本线程中分配的小对象要多一些。 mchache在初始化时是没有任何span的,在使用过程中会动态的从central中获取并缓存下来,跟据使用情况每种 class的span个数也不相同。
4.3 mcentral
cache作为线程的私有资源为单个线程服务,而central则是全局资源,为多个线程服务,当某个线程内存不足时会 向central申请,当某个线程释放内存时又会回收进central 。
src/runtime/mcentral.go:mcentral
type mcentral struct {
lock mutex //互斥锁
spanclass spanClass // span class ID
nonempty mSpanList // non-empty 指还有空闲块的span列表
empty mSpanList // 指没有空闲块的span列表
nmalloc uint64 // 已累计分配的对象个数
}
- lock: 线程间互斥锁,防止多线程读写冲突
- spanclass: 每个mcentral管理着一组有相同class的span列表
- nonempty: 指还有内存可用的span列表
- empty: 指没有内存可用的span列表
- nmalloc: 指累计分配的对象个数
线程从central获取span步骤如下:
- 加锁
- 从nonempty列表获取一个可用span,并将其从链表中删除
- 将取出的span放入empty链表
- 将span返回给线程
- 解锁
- 线程将该span缓存进cache
线程将span归还步骤如下:
- 加锁
- 将span从empty列表删除
- 将span加入noneempty列表
- 解锁
mcentral被所有的工作线程共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源。
4.4 mheap
从mcentral数据结构可见,每个mcentral对象只管理特定的class规格的span。
事实上每种class都会对应一个 mcentral,这个mcentral的集合存放于mheap数据结构中。
src/runtime/mheap.go:mheap
type mheap struct {
lock mutex
spans []*mspan
bitmap uintptr //指向bitmap首地址,bitmap是从高地址向低地址增长的
arena_start uintptr //指示arena区首地址
arena_used uintptr //指示arena区已使用地址位置
central [67*2]struct {
mcentral mcentral
pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
}
}
- lock: 互斥锁
- spans: 指向spans区域,用于映射span和page的关系
- bitmap:bitmap的起始地址
- arena_start: arena区域首地址
- arena_used: 当前arena已使用区域的最大地址
- central: 每种class对应的两个mcentral
从数据结构可见,mheap管理着全部的内存,事实上Golang就是通过一个mheap类型的全局变量进行内存管理的。
系统预分配的内存分为spans、bitmap、arean三个区域通过mheap管理起来
五、内存分配的过程
针对待分配对象的大小不同有不同的分配逻辑:
- (0 , 16B) :且不包含指针的对象 ,mcache上的Tiny分配
- (0 , 16B) : 包含指针的对象、正常分配
- [16B , 32KB] : 正常分配
- (32KB , -) : 大对象直接从mheap上分配
以申请size为n的内存为例,分配步骤如下:
- 获取当前线程的私有缓存mcache
- 跟据size计算出适合的class的ID
- 从mcache的alloc[class]链表中查询可用的span
- 如果mcache没有可用的span则从mcentral申请一个新的span加入mcache中
- 如果mcentral中也没有可用的span则从mheap中申请一个新的span加入mcentral
- 从该span中获取到空闲对象地址并返回
Go语言的内存分配非常复杂,它的一个原则就是能复用的一定要复用。文章从一个比较粗的角度来看Go的内存分配,并没有深入细节。一般而言,了解它的原理到这个程度也可以了。