本文章仅供个人学习使用。
参考
- 小徐先生的编程世界:mp.weixin.qq.com/s/2TBwpQT5-...
- go语言设计与实现:draven.co/golang/docs...
笼统的概念
深入一件事情之前,先笼统了解其运用的思想。后续深入时,时刻想着这些思想,可以避免因为过于深入细节而忽略了整体导致的晕头转向。
空闲链表分配 + 按大小分类
空闲链表分配方式是区别于线性分配方式的。通过把空闲的内存空间串联成一个链表,实现内存分配。
这就涉及到一个问题:我要申请一块大小为x字节的内存,如何找到一块儿大于等于x的内存空间用来分配呢?
常见的算法有4种:
- 首次适应(First-Fit)--- 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应(Next-Fit)--- 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应(Best-Fit)--- 从链表头遍历整个链表,选择最合适的内存块;
隔离适应(Segregated-Fit)
--- 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
这四种,重点了解最后一种,因为go语言采用了与之类似的思想。

多级缓存
计算机世界很多地方都有多级缓存的体现:从 计算机硬件 到 大型web后端。Go的内存管理也用到了这种思想。
Go的内存管理,管理的是:虚拟内存。
Go的内存分配器,从用户侧到操作系统虚拟内存,依次是:
- 内存管理单元:mspan
- 线程缓存:mcache
- 中心缓存:mcentral
- 页堆:mheap

详细介绍内存管理组件
mspan
- mspan是内存管理的基本单元,管理的页大小为8KB。
- mspan管理的内存是连续的,所以知道了startAddress,又知道每个页大小固定=8KB,那再知道有多少页npages,就能确定mspan管理的内存范围了。
- Go划分了67种跨度,表示不同对象的大小,mspan的spanclass字段,标识了本span管理的是哪一个大小等级的对象。
- 管理相同大小等级的mspan串联在一起,组成双向链表。
- mspan中使用bitmap来标识哪些「对象」是空闲的。(注意不是页)
- 同等级的mspan从属于同一个mcentral,由同一把互斥锁管理。
管理相同大小等级的mspan串联在一起,组成双向链表:

mspan管理的内存是连续的,所以知道了startAddress,又知道每个页大小固定=8KB,那再知道有多少页npages,就能确定mspan管理的内存范围了:

mspan中使用bitmap来标识哪些「对象」是空闲的。(注意不是页)

最后我们来完整的看一眼go代码中的mspan:
go
type mspan struct {
// 标识前后节点的指针
next *mspan
prev *mspan
// 起始地址
startAddr uintptr
// 包含几页,页是连续的
npages uintptr
// 标识此前的位置都已被占用
freeindex uintptr
// 最多可以存放多少个 object
nelems uintptr // number of object in the span.
// bitmap 每个 bit 对应一个 object 块,标识该块是否已被占用
allocCache uint64
// 标识 mspan 等级,包含 class 和 noscan 两部分信息
spanclass spanClass
// ...
}
spanClass
跨度类。跨度类是一个8位无符号整数。高7位表示(67种跨度id),最低位表示noscan。
noscan
方法返回true --> 不扫描;否则扫描。- 垃圾回收会对包含指针的
runtime.mspan
结构体进行扫描。 noscan
位=1,方法返回true,所以noscan
= 1,表示对象不包含指针。
源码如下:
go
type spanClass uint8
// uint8 左 7 位为 mspan 等级,最右一位标识是否为 noscan
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
func (sc spanClass) sizeclass() int8 {
return int8(sc >> 1)
}
func (sc spanClass) noscan() bool {
return sc&1 != 0
}
对象等级一览表:(其实一共68个等级,还有一种id=0表示超过32KB的对象)
- waste 挺好理解的:我想申请一个大小为9B的对象,class=1就不够,只能申请class=2的,那每申请一个对象就浪费16-9=7B。
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 | 32768 | 1 | 0 | 12.50% |
mcache
- mcache 是每个 P 独有的缓存,因此交互无锁
- mcache 缓存了 2(noscan维度) * 68(spanClass维度) = 136 个mspan。
- mcache 中还有 微对象分配器,用于处理小于16B对象的内存分配。
源码:
go
const numSpanClasses = 136
type mcache struct {
// 微对象分配器相关
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
// mcache 中缓存的 mspan,每种 spanClass 各一个
alloc [numSpanClasses]*mspan
// ...
}
mcentral
- 每个mcentral一把锁
- 每个mcentral对应一种spanClass,包含2个mspan链表,分别是有空间的和满的。

go
type mcentral struct {
// 对应的 spanClass
spanclass spanClass
// 有空位的 mspan 集合,数组长度为 2 是用于抗一轮 GC
partial [2]spanSet
// 无空位的 mspan 集合
full [2]spanSet
}
mheap
- 是OS虚拟内存的抽象,向虚拟内存申请页面。
- mheap中页大小为8KB,负责把连续的页组装成mspan。
- heapArena记录了page到mspan的映射关系,一个heapArena包含8192个页,总大小是8192 * 8KB = 64M,这也是mheap向虚拟内存申请内存的基本单位。
- mheap通过类似bitmap的方式,寻找空闲的页,组装成mspan,bit=1表示已组装,bit=0表示空闲。但实际上是通过「基数树」快速定位空闲页面的。
- mheap是mcentral的持有者,缓存了每种等级的mcentral。
源码:
mheap
go
type mheap struct {
// 堆的全局锁
lock mutex
// 空闲页分配器,底层是多棵基数树组成的索引,每棵树对应 16 GB 内存空间
pages pageAlloc
// 记录了所有的 mspan. 需要知道,所有 mspan 都是经由 mheap,使用连续空闲页组装生成的
allspans []*mspan
// heapAreana 数组,64 位系统下,二维数组容量为 [1][2^22]
// 每个 heapArena 大小 64M,因此理论上,Golang 堆上限为 2^22*64M = 256T
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
// ...
// 多个 mcentral,总个数为 spanClass 的个数
central [numSpanClasses]struct {
mcentral mcentral
// 用于内存地址对齐
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
// ...
}
heapArena
go
const pagesPerArena = 8192
type heapArena struct {
// ...
// 实现 page 到 mspan 的映射
spans [pagesPerArena]*mspan
// ...
}