Go 内存管理概述
2023-06-13 Golang 并发编程 Go 源码分析 读代码
TCMalloc
TCMalloc 是由 Google 开发的一种高效的、线程安全的内存分配器,采用多级分配算法和线程缓存来提升内存分配效率。 它具有现代化内存分配器的基本特征:减少内存碎片、在多核处理器能够规模扩展。
具体来说,TCMalloc
将内存分配视为一个分层并且递进的过程:
- 首先,它会将大块的内存分成若干小块,每个小块的空间大小都是 2 的幂次
- 然后,对于不同空间大小的内存请求,TCMalloc 会使用不同的分配策略:
- 对于较小的内存请求,TCMalloc 会将内存分配到线程缓存中,以避免从全局内存池中分配产生的竞争
- 对于较大的内存请求,TCMalloc 会直接从全局内存池中分配
本文主要来一下 Go 内存分配
的内部实现,相关文件目录为 $GOROOT/src/runtime
,笔者的 Go 版本为 go1.19 linux/amd64
。
GMP 回顾
在分析具体的代码实现之前,先来回顾下 GMP
调度的概览图,这有助于我们更好地理解内存管理中多级缓存机制。
Go 语言的内存管理
Go 语言的底层内存分配实现方案参考了
TCMalloc
, 通过多级缓存机制、 内存对象大小分类来完成不同的分配策略来提升性能。
多级缓存
- 线程缓存
- 中心缓存
- 页堆
Go 的内存对象大小分类
- 微对象: < 16B
- 小对象: >= 16B && <= 32 KB
- 大对象: > 32KB
内存分配时,会优先从 线程缓存
获取,当 线程缓存
没有足够的内存时,会尝试从 中心缓存
获取,如果申请内存超过 32KB
时,会直接从 页堆
获取。
数据结构
常量
const (
// 基础内存页大小 8KB
pageSize = 8192
// 不同的平台对应不同的值
// 64 位: 64MB
heapArenaBytes = 1 << logHeapArenaBytes
// 64 位: goarch.PtrSize * 8 / 2 = 32
// 64 位: heapArenaBytes / 32 = 64 * 1024 * 1024 / 32 = 2097152
heapArenaBitmapBytes = heapArenaBytes / (goarch.PtrSize * 8 / 2)
// 64 位: 8192
pagesPerArena = heapArenaBytes / pageSize
// 硬编码跨度类型数量
numSpanClasses = 136
// OS | FixedStack | NumStackOrders
// -----------------+------------+---------------
// linux/darwin/bsd | 2KB | 4
// windows/32 | 4KB | 3
// windows/64 | 8KB | 2
// plan9 | 4KB | 3
_NumStackOrders = 4 - goarch.PtrSize/4*goos.IsWindows - 1*goos.IsPlan9
)
内存大小跨度类
定义在文件 sizeclasses.go
中,一共有 67
个跨度类。
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
...
// 67 32768 32768 1 0 12.50% 8192
span 内存浪费计算公式
maxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
以 class = 7
(上述表格第 7 行) 为例:
maxWaste := ((80-64-1)*102+32) / 8192 = 19.07%
对上面这个公式做一个简单说明:申请范围在 65B - 80B
之间的内存,都会分配 80B
,这里假设出现的是极端情况,刚好申请了 65B
,
那么每个分配对象会浪费 15B
内存,再加上尾部浪费的内存 (不够分配一个对象的内存),那么一个 页面 (Page)
浪费掉的内存等于 15 * 102 + 32 = 1562
,
最后除以页面大小等于 1562 / 8192 = 19.07%
。
mspan 对象
mspan
对象表示 内存管理的基本单元,每个 mspan
表示大小为 8 KB
, 数量为 npages
的一组连续内存页。
type mspan struct {
next *mspan // 下一个元素, 双向链表结构
prev *mspan // 上一个元素, 双向链表结构
list *mSpanList // Debug 时使用
startAddr uintptr // 起始地址
npages uintptr // 页面数量
// 开始扫描时的空闲对象索引
// 每次分配从 freeindex 开始扫描 allocBits, 直到遇到表示空闲对象的 0, 然后调整 freeindex 的值
// 如果 freeindex == nelem, 当前 span 已经没有空闲对象了
freeindex uintptr
nelems uintptr // span 内存储的对象数量
allocCache uint64 // allocBits 的补码,可以用于快速查找未使用的内存
allocBits *gcBits // 是一个 bitmap, 用于标识当前 span 中已分配的对象
gcmarkBits *gcBits // 是一个 bitmap, 用于标识当前 span 中已标记的对象
spanclass spanClass // 跨度大小
state mSpanStateBox // 存储内存管理单元的状态
}
下面是一个跨度为 1, 单个对象大小为 8B, 对象数量为 1024,总空间大小为 8KB 的 mspan 对象。
mSpanList 对象
mSpanList
对象表示 mspan
对象的双向链表结构。
type mSpanList struct {
first *mspan // 链表头节点
last *mspan // 链表尾节点
}
heapArena 对象
heapArena
对象表示内存管理的元数据,存储在 Go 堆内存之外,通过 mheap_.arenas
字段访问。
type heapArena struct {
// bitmap 存储了区域中哪些地址保存了对象
// 主要用于垃圾回收,两种标记方式:
// 1. 标记地址中是否有对象
// 2. 标记对象是否被 GC 标记
bitmap [heapArenaBitmapBytes]byte
// spans 存储了指向内存管理单元的指针
// 多个数组元素可能指向同一个 mspan 对象指针
spans [pagesPerArena]*mspan
// pageInUse 是一个 bitmap, 用于标识哪些 span 状态为 mSpanInUse
// 该 bitmap 根据页面编号进行索引,但只使用每个 span 中与第一页对应的位
pageInUse [pagesPerArena / 8]uint8
// pageMarks 是一个 bitmap, 用于标识哪些 span 有标记对象
pageMarks [pagesPerArena / 8]uint8
// pageSpecials 是一个 bitmap, 用于标识哪些 span 有特殊标记
pageSpecials [pagesPerArena / 8]uint8
checkmarks *checkmarksMap
// 指向该对象管理的内存起始地址
zeroedBase uintptr
}
bitmap
字段数据类型为数组,一共2097152
个元素,每个元素可以表示32 bit
内存,一共可以表示64MB
内存spans
字段数据类型为数组,一共8192
个元素,每个元素表示8KB
内存,一共可以表示64MB
内存
mcache 对象
mcache
对象表示 线程缓存
,每个处理器 P
都会分配一个 mcache
对象,用于处理 微对象
和 小对象
的分配,因为是每个处理器独有的,不存在并发访问,所以访问时不需要加锁。
type mcache struct {
// tiny 指向当前对象表示的内存块的起始地址,如果当前未分配内存块,则为 nil
// tiny 指向堆内存,由于 mcache 是在非 GC 内存中,所以标记终止时调用 releaseAll 方法进行清除
// tinyoffset 表示下一个空闲内存块的偏移量
// tinyAllocs 表示持有当前对象的处理器 P 分配的微对象数量
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
alloc [numSpanClasses]*mspan // mspan 指针数组,元素数量: 136
}
spanClass 数据类型
spanClass
类型表示 mspan
对象的跨度类的大小以及是否包含指针。
type spanClass uint8
// spanClass 最后 1 位表示是否包含指针
func (sc spanClass) noscan() bool {
return sc&1 != 0
}
mcentral 对象
mcentral
对象表示指定跨度大小的 中心缓存
,每个 mcentral
会管理某个跨度大小 (根据 spanclass
字段决定) 的内存。
type mcentral struct {
spanclass spanClass
partial [2]spanSet // 空闲对象的 span 列表
full [2]spanSet // 非空闲对象的 span 列表
}
spanSet 对象
spanSet
对象表示一个 mspan
对象的指针集合。
type spanSet struct {
spineLock mutex // 访问中心缓存时需要用到互斥锁
spine unsafe.Pointer // *[N]*spanSetBlock, accessed atomically
spineLen uintptr // Spine array length, accessed atomically
spineCap uintptr // Spine array cap, accessed under lock
}
mheap 对象
mheap
对象表示 全局内存分配概况,堆上初始化的所有对象都由该对象统一管理。
type mheap struct {
// 锁
lock mutex
// allspans 切片保存已经创建的 mspan 对象指针, 每个 mspan 只会出现一次
allspans []*mspan
// 指向 heapArena 对象的二维数组 (表示整个虚拟内存的 heapArena 元数据)
// 64位: 1 << arenaL1Bits = 1
// 1 << arenaL2Bits = 4194304
// 4194304 个 heapArena 对象, 每个 heapArena 对象可以表示 64 MB 内存,一共可以表示 256 TB 内存 (虚拟内存)
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
// 全局中心缓存空闲列表
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
spanalloc fixalloc // span* 分配器
cachealloc fixalloc // amcache* 分配器
specialfinalizeralloc fixalloc // specialfinalizer* 分配器
specialprofilealloc fixalloc // specialprofile* 分配器
specialReachableAlloc fixalloc // specialReachable 分配器
arenaHintAlloc fixalloc // arenaHints 分配器
}
操作系统接口
操作系统提供的内存管理接口定义在文件: $GOROOT/src/runtime/mem.go
,接口方法列表如下:
// 从操作系统中获取一大块可用的内存空间
func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {}
// 通知操作系统虚拟内存对应的物理内存已经不再需要,可以重用物理内存
func sysUnused(v unsafe.Pointer, n uintptr) {}
// 通知操作系统应用程序需要使用该内存区域,保证内存区域可以安全访问
func sysUsed(v unsafe.Pointer, n, prepared uintptr) {
// 向操作系统给出一个提示,该内存区域使用 huge page 性能会更高
func sysHugePage(v unsafe.Pointer, n uintptr) {}
// 如果在分配过程中检测到内存不足错误 (OOM),无条件返回内存
func sysFree(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {}
// 将内存区域转换成保留状态,主要用于运行时的调试
func sysFault(v unsafe.Pointer, n uintptr) {}
// 保留操作系统中的一片内存区域,访问这片内存会触发异常
func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {}
// 保证内存区域可以快速转换至就绪状态
func sysMap(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {}
内存管理接口具体的实现方是与平台相关的,实现在 $GOROOT/src/runtime/mem_*
文件中,以笔者使用的 Linux
为例,具体实现在 $GOROOT/src/runtime/mem_linux.go
文件中,其他平台以此类推。
内存分配
newobject 方法
所有分配到堆上的对象都会使用 newobject
方法分配内存。
func newobject(typ *_type) unsafe.Pointer {
// 调用 mallocgc 方法
return mallocgc(typ.size, typ, true)
}
mallocgc 方法
mallocgc
方法会分配指定大小的内存空间对象,内部会依次尝试从 线程缓存
中心缓存
页堆
上分配。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
// 判断申请的内存是否有指针
noscan := typ == nil || typ.ptrdata == 0
// maxTinySize : 16 B
// maxSmallSize: 32 KB
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 微对象分配
// 依次尝试从线程缓存、中心缓存、堆上分配
// 微对象分配器将几个小的分配请求合并到一个内存块中
// 当所有子对象都不可访问时,释放产生的内存,子对象必须不包含指针,这确保了内存浪费是有限的
// 微对象分配器分配的内存不能被显式释放
// 微对象分配器的主要目标是小的字符串和逃逸变量
// 在一个 json 基准测试中,性能提升了大约 12%, 堆大小减少了大约 20%
...
// 存在空闲的内存
if off+size <= maxTinySize && c.tiny != 0 {
// 将对象分配到现有的小块中,然后返回
...
}
// 没有空闲的内存
// 分配一个新的块
...
// 根据剩余的空间,决定是否需要将已有的小块替换为一个新的块
...
} else {
// 小对象分配
// 确定分配对象的大小以及跨度大小
// 依次尝试从线程缓存、中心缓存上分配
...
}
} else {
// 大对象分配,直接在堆上分配
...
}
// GC 期间分配的黑色对象
// 所有对象都是 nil, 无需扫描
...
// 统计内存碎片
...
}
nextFreeFast
方法利用 mspan.allocCache
字段,快速查找参数 mspan
中一个空闲的对象,没有找到时返回 0。
func nextFreeFast(s *mspan) gclinkptr {
...
}
线程缓存
创建线程缓存
初始化一个处理器 P
时,调用 allocmcache
方法初始化 线程缓存
,并返回一个初始化后的 mcache
对象指针。
// 空 mspan 类型
var emptymspan mspan
func allocmcache() *mcache {
var c *mcache
...
// 初始化后的 mspan 都是空的 emptymspan 类型
for i := range c.alloc {
c.alloc[i] = &emptymspan
}
c.nextSample = nextSample()
return c
}
nextFree
方法尝试从 线程缓存
查找一个空闲的对象,如果没有找到,调用 refill
方法将一个可用的 mspan
填充进 线程缓存
。
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
...
}
allocLarge
方法计算指定参数大小的 大对象
需要的内存页数,然后分配一个拥有指定页数的 mspan
对象指针。
func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {
...
}
获取指定跨度大小的内存
refill
方法从 中心缓存
里面为 线程缓存
分配一个指定跨度大小的 mspan
对象进行填充,这个 mspan
至少有一个空闲对象。
func (c *mcache) refill(spc spanClass) {
// 将当前 span 放入中心缓存
s := c.alloc[spc]
if s != &emptymspan {
mheap_.central[spc].mcentral.uncacheSpan(s)
...
}
...
// 从中心缓存获取一个新的 span
s = mheap_.central[spc].mcentral.cacheSpan()
...
c.alloc[spc] = s
}
中心缓存
获取新的 mspan 对象
当 线程缓存
没有足够的内存时,会调用 mcentral.cacheSpan
方法获取一个 mspan
对象指针。
func (c *mcentral) cacheSpan() *mspan {
...
}
该方法会依次尝试从下列空间获取 mspan
对象指针:
- 从清理过的空闲对象中查找
- 从未清理过的空闲对象中查找
- 从未清理过的非空闲对象中查找可用的,同时调用
sweep
方法进行清理操作
如果上述 3 个方法都没有获取到 mspan
, 说明 线程缓存
中暂时没有可用的缓存,接着会调用 mcentral.grow
方法向 中心缓存
申请一个 mspan
。
grow
方法根据 中心缓存
的跨度大小,计算需要的页数和内存大小,然后从堆上分配一个空的 mspan
对象并返回对象的指针。
func (c *mcentral) grow() *mspan {
...
}
全局页堆
init
方法负责全局页堆的初始化。
func (h *mheap) init() {
...
}
alloc
方法从 GC
完成后的的堆中分配 mspan
对象, 参数 spanclass
指定 span
对象的跨度大小以及是否需要扫描。
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
var s *mspan
systemstack(func() {
// 为了防止堆过度增长,在分配 N 个页面之前,需要至少清理和回收 N 个页面
if !isSweepDone() {
h.reclaim(npages)
}
s = h.allocSpan(npages, spanAllocHeap, spanclass)
})
return s
}
allocSpan
方法分配一个数量为 npages
的一组连续内存所对应的 mspan
对象指针。
func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
...
}
grow
方法尝试申请数量至少为 npage
个内存空间对象,放入堆中。
源代码中,卡在代码行中间的注释很犀利 :-)
func (h *mheap) grow(npage uintptr) (uintptr, bool) {
// 计算需要的内存大小和内存起始地址
...
// arena 没有足够的内存了,调用 sysAlloc 方法申请
// 这个卡在中间的注释很犀利
if nBase > h.curArena.end || /* overflow */ end < h.curArena.base {
av, asize := h.sysAlloc(ask)
...
if uintptr(av) == h.curArena.end {
// 新申请的的内存和当前内存区域是连续的,直接拼接
...
} else {
// 新申请的的内存和当前内存区域不连续
// 将当前内存剩余空间切换到新内存空间
...
}
}
...
// 将要使用的内存空间状态从 Reserved 切换为 Prepared
sysMap(unsafe.Pointer(v), nBase-v, &gcController.heapReleased)
// 更新状态
// 更新页面分配器对象数据,这部分空间可以再次分配
...
}
小结
本文主要介绍了内存分配的设计思路和核心数据结构,同时简单描述了对象的内存分配逻辑流程,感兴趣的读者可以在此基础上深入探索具体方法的内部实现细节。
Reference
- goroutine 泄漏与检测
- Go 内存模型
- TCMalloc : Thread-Caching Malloc
- 图解 TCMalloc
- Go 设计与实现
- Go 语言原本
- Golang 堆内存管理详细图文版