golang系列之-内存分配
golang的内存分配机制最初是基于TCMalloc,演化至今已经有了很大差异。其原理是:slab + tiling algorithm + 层级内存分配。本文仅介绍如何通过mallocgc分配内存,不涉及栈内存分配管理、手动内存管理等内容。当前go版本:1.24
前言
slab + tiling
内存分配基本单位-mspan(即slab),内部再划分更小的块(即tiling)
小对象(<=32KB)按预定义的sizeclass分成不同的mspan,每个mspan最低有8KB,切割成指定大小的块。而大对象(>32KB)的sizeclass=0,不限制大小,mspan不分块。如下
1 | // mspann -> sizeclass=0 -> nKB -> | <----- nKB -----> | |
如make([]int, 5)
,创建一块40B大小的内存区域,经过计算这个内存块会从sizeclass=5的mspan分配(该mspan的每个元素大小为48B)
层级内存分配
参考TCMalloc,内存分配时主要分为3级:mcache、mcentral、mheap
- mcache为线程缓存,每个p都有一个,所有的内存分配/回收都是先通过mcache,访问不需要加锁
- mcentral负责向mheap申请分配内存、管理mspan,按spanclass分组,嵌入到mheap结构体,全局唯一
- mheap负责从系统申请内存进行分配管理,一般情况下访问都要加锁,全局唯一
执行mallocgc分配内存时,mheap、mcentral、mcache的关系如下
1 | // mallocgc |
- 如果对象<=32KB
- 从p的mcache找一个空闲的mspan分配一段内存/地址空间
- 如果mspan没有空闲空间,从mcentral申请一个新的/重用一个已清扫的mspan
- 如果mcentral也没有可用mspan,通过mheap跟OS申请一段内存,初始化一个mspan返回
- 如果对象>32KB
- 通过mheap跟OS申请一段内存,初始化一个mspan返回
系统内存状态
这里需要了解系统内存的几个状态,以及go内部是如何从系统分配、释放内存的
状态 | 含义 |
---|---|
None | 默认状态,未映射,地址空间未被保留或使用 |
Reserved | 已保留,但未提交,即地址空间已经被申请,但尚未向操作系统请求实际物理内存 |
Prepared | 已提交但未使用,已经向操作系统申请了物理内存,但可能未完全初始化 |
Ready | 可用状态,内存已初始化,可用于分配 |
状态转换函数
函数 | 主要作用 | 是否分配物理内存 | 是否映射虚拟内存 | 内存状态转换 | 备注 |
---|---|---|---|---|---|
sysAlloc |
直接申请内存 | ✅ 是 | ✅ 是 | None -> Ready | 可能会触发 mmap |
sysReserve |
保留虚拟地址空间,但不映射 | ❌ 否 | ✅ 是 | None -> Reserved | 预留地址,后续 sysMap |
sysMap |
将预留的虚拟地址映射为实际物理内存 | ✅ 是 | ✅ 是 | Reserved -> Prepared | 只有 sysReserve 过的地址能 sysMap |
sysUsed |
标记某段地址正在使用 | ✅ 是 | ✅ 是 | Prepared -> Ready | 可能会触发 madvise 让物理页生效 |
sysUnused |
标记某段地址未使用,可以回收物理内存 | ⚠️ 可能 | ❌ 否 | Ready -> Prepared | MADV_DONTNEED ,内存仍属于进程 |
sysFault |
让一段地址变成不可访问 | ❌ 否 | ✅ 是 | Ready -> Reserved | mprotect(PROT_NONE) ,用于调试 |
sysFree |
释放虚拟内存,归还给 OS | ✅ 是 | ✅ 是 | -> None | munmap ,这段内存不能再用 |
我对Go内存分配的理解
- 内存管理的本质是地址空间的组织和维护
- Go的内存策略:尽量保留虚拟地址,按需释放物理页
- Go会一次性申请64MB内存(Reserved-虚拟地址空间),但并不是立即分配物理页,而是在需要分配时使用sysMap + sysUsed使一小部份内存可用(Reserved->Prepared->Ready)
GC助攻
为了防止内存分配速度过快,导致GC跟不上,分配时会判断是否需要协助GC标记/清扫。每次都要判断是否需要协助标记,而清扫只发生在获取新的mspan和大对象分配场景下
数据结构
mspan
mspan负责纪录一片连续内存区域的起始/终止地址、组织信息等,实际可用内存地址需要通过页分配器获得。一个mspan最少可管理8KB的内存
1 | type mspan struct { |
scan/noscan
noscan:如果对象是nil或者对象不包含指针(scalar)
scan:对象包含指针,如一个结构体有字段类型是指针类型
状态
mspan状态列表如下
name | value | description |
---|---|---|
mSpanDead | 0 | 默认状态,未使用/回收 |
mSpanInUse | 1 | 使用中,由go自己管理 |
mSpanManual | 2 | 使用中,手动管理,一般用于栈分配 |
类型
name | value | description |
---|---|---|
spanAllocHeap | 0 | 默认,heap类型,其他均为手动分配 |
spanAllocStack | 1 | stack类型 |
spanAllocPtrScalarBits | 2 | GC bitmap |
spanAllocWorkBuf | 3 | 写屏障缓冲 |
小对象mspan示例
1 | // 只有1个page的小对象(<=32KB)mspan示例如下 |
mcache
每个p都有一个mcache,负责当前线程的内存分配,超小对象(scalar)通过tiny分配器分配(优化,本质还是访问的是mspan),小对象则通过找到alloc数组里相应的mspan进行分配,不负责大对象的分配
1 | type mcache struct { |
alloc是一个固定长度的数组,包含136个mspan指针。如果其中的mspan空间分配完/不足以容纳新对象,则向mcentral申请一个新的/清扫过的mspan替换原mspan后重试
mcentral
新的mspan会立即替换mcache的纪录,而mcentral负责纪录空间已满/不足的mspan、清扫重用mspan/向mheap申请分配新的mspan。按spanclass进行分组,每个组都是一个二维数组,嵌入到mheap结构体,全局唯一
1 | type mcentral struct { |
mcentral示例
1 | // |
注意
由于spanclass = sizeclass << 1 + noscan
,所以,奇数索引纪录scalar类型,偶数纪录索引类型
mheap
负责向操作系统申请内存进行分配/管理、纪录内存分配信息等。全局唯一,访问需要加锁或者STW。其中
- 页通过pages-页分配器分配、管理
- mspan通过spanalloc分配器创建/回收,关联页后纪录到allspans,mspan空间用完/不足会放到mcentral管理
1 | type mheap struct { |
hint作用
hint-预规划地址,它的作用是通过sysMap直接向操作系统申请在指定地址处映射内存,因为使用sysAlloc让操作系统选择地址空间会导致地址不连续、碎片化等。如果失败最后会回退到sysAlloc
mheap示例
1 | // mheap.arenas[0] -> *heapArena[0](64MB) |
可用内存空间
- 从arenas结构看,在linux系统下,mheap可以管理1419430464MB=256TB内存,但实际上,go选择内存地址的中间-
0xffff800000000000
作为基地址,计算最大地址0x007ffffffff000
减去基地址,得出实际可管理内存为128TB - arena包用于手动管理内存,从预分配地址看,实际可管理内存也为128TB,当然,它选择的是另一个地址空间,不跟heap共用
分配器
fixalloc
mheap大多数分配器是fixalloc类型,比如mspan就使用了fixalloc,每次用完就从系统申请16KB内存。分配内存优先从空闲对象链表list获取,没有才从chunk分配内存
1 | type fixalloc struct { |
pageAlloc
页分配器,本身属于bitmap,用于快速查找连续n页的起始地址。实现比较复杂,是非常重要的数据结构。其中
- chunks为二维数组,总数据量与arena相同,一次性可管理512个页,即4MB内存
- summary是chunks的索引,分5层,最后一层数量与chunks总数相同,每上一层数量缩减8倍,管理的页数增长8倍,可快速寻找连续n页内存的起始地址
注意
pageAlloc对bitmap的处理与其他分配器正相反,1为已使用,0为未使用,刚开始看代码很容易混淆
1 | type pageAlloc struct { |
mallocgc
slice、map、string申请内存时是通过mallocgc来分配的,其他如newobject、newarray、reflect.unsafe_New底层实际也是在调用mallocgc
mallocg内部对申请的内存大小size、对象的类型type判断,分别调用不同的内存分配器分配内存,具体如下
条件1 | 条件2 | 条件3 | 内存分配器 |
---|---|---|---|
<=32760B | nil或对象不包含指针 | <16B | mallocgcTiny |
>=16B | mallocgcSmallNoscan | ||
对象包含指针 | <=512B | mallocgcSmallScanNoHeader | |
>512B | mallocgcSmallScanHeader | ||
>32760B | mallocgcLarge |
1 | // 根据请求的内存大小分配内存、返回地址 |
mallocgcTiny
前提条件:size<=32KB,对象为nil或对象不包含指针,size<16B。tiny区域只有16字节,用于合并多个微小对象的内存分配,大概逻辑如下
- 访问mcache
- 有p则获取p.mcache,没有p则获取mcache0
- tiny区域调整、计算
- tiny区域的offset偏移量跟内存分配量size对齐
- 空间足够(offset+size<=61)
- 更新偏移量、计数器,返回内存区域起始地址
- 空间不足
- 申请新的mspan
- 从mcache.alloc[tinySpanClass]获取mspan
- 快速分配
- 通过64位的allocCache快速判断并分配对象
- 慢速分配
- 从allocBits获取64位数据,重新填充allocCache并重新判断分配对象
- 如果mspan已满/空间不足,则从mcentral获取新的mspan替换原mspan后重试
- 申请新的mspan
- 收尾
- 分配的16字节内存区域清0
- 如果size比offse小或tiny区域未初始化,替换tiny区域、更新信息
- 如果size比offset大,如offset=8,size=12,直接返回整个16字节内存块,不更新tiny区域
- 写屏障、profiling、GC处理
1 | // 从tiny区域分配 |
mallocgcSmallNoscan
前提条件:size<=32KB,对象为nil或对象不包含指针,size>=16B。大概逻辑如下
- 访问mcache
- 有p则获取p.mcache,没有p则获取mcache0
- 获取mspan
- 通过组合sizeclass和noscan变量计算出spanclass
- 如果size<=1016(1KB-8),sizeclass范围是[0,32]
- 如果size>1016,sizeclass范围是[32,67]
从mcache.alloc[sizeclass]获取mspan
- 通过组合sizeclass和noscan变量计算出spanclass
- 分配内存
- 快速分配
- 通过64位的allocCache快速判断并分配对象
- 慢速分配
- 从allocBits获取64位数据,重新填充allocCache并重新判断分配对象
- 如果mspan已满/空间不足,则从mcentral获取新的mspan替换原mspan后重试
- 快速分配
- 收尾
- 内存区域清0
- 写屏障、profiling、GC处理
1 | // 计算spanclass获取span,分配size大小的内存返回 |
mallocgcSmallScanNoHeader
前提条件:size<=32KB,对象包含指针,size<=512B。大概逻辑如下
- 访问mcache
- 有p则获取p.mcache,没有p则获取mcache0
- 获取mspan
- 通过组合sizeclass和noscan变量计算出spanclass,最终sizeclass范围是[0,32]
- 从mcache.alloc[sizeclass]获取mspan
- 分配内存
- 快速分配
- 通过64位的allocCache快速判断并分配对象
- 慢速分配
- 从allocBits获取64位数据,重新填充allocCache并重新判断分配对象
- 如果mspan已满/空间不足,则从mcentral获取新的mspan替换原mspan后重试
- 快速分配
- 收尾
- 根据sizeclass调整scanAlloc、size
- 写屏障、profiling、GC处理
1 | func mallocgcSmallScanNoHeader(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) { |
mallocgcSmallScanHeader
前提条件:size<=32KB,对象包含指针,size>512B。大概逻辑如下
- 访问mcache
- 有p则获取p.mcache,没有p则获取mcache0
- 获取mspan
- size+=8(多分配8字节存储type)
- 通过组合sizeclass和noscan变量计算出spanclass
- 如果size<=1016(1KB-8),sizeclass范围是[0,32]
- 如果size>1016,sizeclass范围是[32,67]
- 从mcache.alloc[sizeclass]获取mspan
- 分配内存
- 快速分配
- 通过64位的allocCache快速判断并分配对象
- 慢速分配
- 从allocBits获取64位数据,重新填充allocCache并重新判断分配对象
- 如果mspan已满/空间不足,则从mcentral获取新的mspan替换原mspan后重试
- 快速分配
- 收尾
- 内存区域清0
- 存储type到内存区域头8个字节,调整内存区域指针、scanAlloc、size
- 写屏障、profiling、GC处理
1 | func mallocgcSmallScanHeader(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) { |
mallocgcLarge
前提条件:size>32KB。大概逻辑如下
- 访问mcache
- 有p则获取p.mcache,没有p则获取mcache0
- 创建mspan
- 通过mheap分配size大小的mspan(size会按一定的倍数向上取整),更新mspan信息
- 需要协助GC清扫
- 收尾
- 内存区域清0,如果是scan则纪录对象类型到largeType
- 写屏障、profiling、GC处理
1 | func mallocgcLarge(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) { |
相关依赖函数
malloc
1 | // malloc初始化,schedinit-调度器初始化时调用 |
mheap
1 | // mheap初始化 |
mcentral
1 | // mcentral初始化 |
mcache
1 | // 创建mcache |
mspan
1 | // 获取可用对象索引,如果allocCache用完则重新填充再计算索引(可能多次) |
pageCache
1 | // 从pcache找到连续n个页的起始地址,并判断这几个页是否被清理过(重用) |
pageAlloc
1 | func (p *pageAlloc) init(mheapLock *mutex, sysStat *sysMemStat, test bool) { |
pallocBits
1 | // 在一个chunk中寻找可用的连续n个页的起始索引(最多512个页) |
GC助攻
1 | // 降低g的Assist额度,如果额度用光了,则g需要协助GC标记(g会被挂起) |
写屏障
1 | // 新分配对象标记为黑色,类似greyobject |
OS内存申请
1 | // 向系统申请内存,返回对齐后的内存地址和大小,对齐后剩余的量全部释放回系统 |
profile相关
1 | // 返回堆分析的下一个采样点(随机数:[0,MemProfileRate)) |
其他
1 | // 内存区域批量清0 |
参考文档
An overview of memory management in Go
A visual guide to Go Memory Allocator from scratch (Golang)
Go: Memory Management and Allocation
Go’s Memory Allocator - Overview
Visualizing memory management in Golang
GopherCon 2018 - Allocator Wrestling
7.1 内存分配器
Contiguous stacks in Go
Golang Memory Management (based on 1.12.5)
Golang Memory Allocator