前言
在进入本章内容学习之前,最好能够对虚拟内存以及一些基本的物理内存知识有一定了解,这样能帮助更好的深入理解一些概念。为了缩减内容篇幅,聚焦重点内容,就不一一去讲解了,这边提供几个相关内容的参考地址,如下:
虚拟内存知识可以参考该地址: https://blog.csdn.net/qqxjx/article/details/133852071
物理内存知识可以参考该地址:https://blog.csdn.net/qqxjx/article/details/134119042
虚拟内存与物理内存映射参考地址:https://mp.weixin.qq.com/s/FzTBx32ABR0Vtpq50pwNSA
另外本文后续讲解的内容和概念都基于golang 1.20
源码以及后续版本来分析,早期版本不再兼顾。
设计原则
我们前面文章已经分析了 Go
程序如何启动、初始化需要进行的关键步骤、初始化结束后, 主 goroutine
如何被调度器进行调度,即GMP
调度。现在我们来看 Go
中另一重要的关键组件:内存分配器。
我们知道,Go
内存由**栈内存(Stack)**和 **堆内存(Heap)**两大部分组成:
- 传统意义上栈内存被
Go
的运行时(runtime
)控制,一般不开放给用户态代码; - 而堆内存被分为了两个部分:
Go
运行时(runtime
)自身所需的堆内存,即堆外内存;Go
用户态代码所使用的堆内存,也叫做Go
堆,Go
堆负责了用户态对象的存放以及goroutine
的执行栈。
作为内存分配器,最主要负责的是堆内存,特别是Go
堆部分的内存申请和管理。
Go
的内存分配器基于谷歌的 Thread-Cache Malloc
, tcmalloc
的具体细节在此就不普及了,因为 Go
的内存分配器与 tcmalloc
存在一定差异。
Go
内存分配器的核心设计思想是:多级内存分配模块,减少内存分配时锁的使用与系统调用;多尺度内存单元,减少内存分配产生碎片。大白话总结无外乎就是:时间换空间、空间换时间的处理方案罢了。
内存管理组件
Go
语言内置运行时(就是runtime
),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。
Go
的内存分配器主要包含以下几个核心管理组件:
heapArena
: 操作系统在虚拟内存中分配给Go
语言堆的一段连续内存区域;mheap
:分配的堆,负责管理堆内存的分配、回收;mspan
:运行时堆管理机制中管理内存的基本单元,表示一个内存区域,是mheap
上管理的一连串的页;mcentral
:中心缓存,每个中心缓存都会管理某个跨度类的内存管理单元;mcache
:线程缓存,它会与线程上的P
一一绑定,主要用来缓存用户程序申请的微小对象。
下面进行一一着重分析,分析每个管理组件的作用以及和其他组件的关系。
堆区域 — heapArena
runtime.heapArena
Go
语言的runtime
将Go
堆地址空间划分成了一个一个的arena
(堆区域),而arena
就是用于存储堆对象的一块连续内存区域。arena
区域的起始地址被定义为常量arenaBaseOffset,且与 arena
的大小对齐。
不同平台下虚拟地址大小以及arena
大小等都不一样,如下表:
平台 地址有效位数 单个Arena大小 L1 entries L2 entries
-------------- --------- ---------- ---------- -----------
*/64-bit 48 64MB 1 4M (32MB)
windows/64-bit 48 4MB 64 1M (8MB)
ios/arm64 33 4MB 1 2048 (8KB)
*/32-bit 32 4MB 1 1024 (4KB)
*/mips(le) 31 4MB 1 512 (2KB)
在amd64
架构的Linux
环境下,每个arena
的大小是64MB
,起始地址也对齐到64MB
,而arena
由 page
组成,每个page
大小为8KB
,可以得出每个arena
下有 8192
(64M/8K=8192
)个page
。相关定义如下:
//go 1.20.3 path: /src/runtime/sizeclasses.go
const (
_PageShift = 13
)
//go 1.20.3 path: /src/runtime/goarch.go
const PtrSize = 4 << (^uintptr(0) >> 63) // 单个指针大小,32系统下值为4, 64位系统下值为8
//go 1.20.3 path: /src/runtime/malloc.go
const{
_PageSize = 1 << _PageShift // 2的13次方,即8192字节 = 8K
pageSize = _PageSize
arenaBaseOffset = 0xffff800000000000*goarch.IsAmd64 + 0x0a00000000000000*goos.IsAix //系统不同值不同
heapArenaBytes = 1 << logHeapArenaBytes // 单个arena大小,64位下为 67108864字节 = 64M
pagesPerArena = heapArenaBytes / pageSize // 单个arena含page数量,即64m/8K = 8192 个 page
}
amd64
架构的Linux
环境下arena
示意图如下:
作为单个arena
,则有runtime.heapArena
结构体负责存储元数据和管理,runtime.heapArena
结构对象自身存储在Go
堆之外,其结构定义如下:
//go 1.20.3 path: /src/runtime/mheap.go
type heapArena struct {
_ sys.NotInHeap // 不用于堆分配的字段标记
bitmap [heapArenaBitmapWords]uintptr // 位图数组,用于表示哪些内存块已经被分配给对象,哪些内存块是可用的。
noMorePtrs [heapArenaBitmapWords / 8]uint8 // 位图数组,标记堆区域中每个字对应的内存页是否不再包含指针
spans [pagesPerArena]*mspan // 保存了指向 mspan 结构体的指针数组,表示堆区域的每个内存页的元信息
pageInUse [pagesPerArena / 8]uint8 // 位图数组,标记堆区域中每个内存页是否在使用中
pageMarks [pagesPerArena / 8]uint8 // 位图数组,标记堆区域中每个内存页是否被标记
pageSpecials [pagesPerArena / 8]uint8 // 位图数组,标记堆区域中每个内存页是否被特殊标记
checkmarks *checkmarksMap // 检查标记,用于跟踪堆区域中每个内存页的标记情况
zeroedBase uintptr // 用于存储堆区域的基址,表示已清零的内存页的起始地址
}
根据结构体定义,我们可以得出如下示意图:
接下来,分析分析runtime.heapArena
结构体的一些重要字段。
-
heapArena.spans
分析这个字段前,我们先初步了解下
span
以及对应的mspan
结构体(后续会单独分析)。我们都知道,
linux
下page
作为基本的内存分配单元,但是在Go
堆里面,span
被作为内存分配的基本单元,span
表示一组连续的page
,基于不同需求,span
将划分为很多不同规格类型,包含着不同连续数量的page
(比如2
个Page
组成的span
,还可以有4
个Page
组成的span
等等)。 而mspan
结构体正是负责管理span
的结构体。了解完
span
,回到heapArena.spans, 它是个*mspan
类型的数组,大小为8192
,正好对应arena
中8192
个page
,所以用于定位一个page
对应的mspan
在哪儿。 -
heapArena.bitmap & heapArena.noMorePtrs
heapArena.bitmap
是一个位图数组,用于记录堆内存中的内存块分配情况。在
Go1.18
以前,它用2
个bit
位标记一个指针大小的内存单元:- 用
1
位bit
(低4
位bit
位)标记这个arena
中一个指针大小的内存单元到底是指针还是标量; - 用
1
位bit
(高4
位bit
位)标记这块内存空间的后续单元是否包含指针,如果存在指针则需要继续扫描,否则终止扫描。
Go1.18
以及之前的heapArena.bitmap
如下图:但是
Go1.20
之后,heapArena.bitmap
就完全不是这么一回事了。我们先看看新版本heapArena.bitmap
数组的长度heapArenaBitmapWords
的定义的一些定义://go 1.20.3 path: /src/runtime/malloc.go const{ heapArenaBytes = 1 << logHeapArenaBytes // 单个arena大小,64位下为 67108864字节 = 64M heapArenaWords = heapArenaBytes / goarch.PtrSize // 单个arena字长数量; 64M/8=8M; 64位的Linux系统,一个heapArena有8M个word,一个word占8个字节 heapArenaBitmapWords = heapArenaWords / (8 * goarch.PtrSize) // 单个heapArena的bitmap占用,即8M/64=131072 }
在
Go1.20
以及之后的版本中,用一个bit
位指代一个字长(word size
),而非之前的一个byte
,在Linux 64
系统中,一个字长(word size
)为8Bytes
,所以一个heapArena
管理的内存大小是64MB
,则需要64MB/(8*64)=128K
个Bit
位。除此之外,
Go1.20
以及之后的版本也取消了用2
个bit
标记内存单元状态的方式,用heapArena.bitmap
来表示堆内存中的内存块分配情况,如果某bit
位为0
,表示对应的内存块是未分配的;如果bit
位为1
,表示对应的内存块已经被分配给对象。而新启用了
heapArena.noMorePtrs
字段来表标记内存块中不再有指针的数组。它的作用是辅助垃圾回收(GC
)过程,特别是在标记和清理阶段。heapArena.noMorePtrs
数组的每个元素表示一组8
个指针的状态。这是因为在Go
语言的内存管理中,一个指针的大小通常为8
字节(具体取决于特定的架构和编译器),因此一个内存块的长度通常也是8
的倍数。将内存块的大小除以8
可以得到一组指针的数量,这样每个元素可以表示这些指针的状态。通过这种方式,heapArena.noMorePtrs
数组可以更高效地管理内存块的指针状态。 - 用
-
heapArena.pageInUse
heapArena.pageInUse
同样也是一个位图数组,uint8
类型,用于标记哪些页面被使用了。实际上,这个位图只标记处于使用状态(mSpanInUse
)的span
的第一个page
。例如
arena
中连续三个span
分别包含1
,2
,3
个page
,pageInUse
位图标记情况如下图所示: -
heapArena.pageMarks
heapArena.pageMarks
是一个用于标记内存页(page
)是否被垃圾回收的数组。它的作用是在垃圾回收阶段,标记哪些内存页包含被标记的对象,以便在垃圾回收过程中进行特殊处理。heapArena.pageMarks
数组与heapArena.bitmap
数组一起使用。heapArena.bitmap
用于记录每个内存页的分配情况,而heapArena.pageMarks
用于记录每个内存页中对象的状态。当垃圾回收扫描到一个内存页时,它会检查heapArena.pageMarks
数组中相应的元素,以确定该内存页中的对象是否已经被标记为可达。如果
heapArena.pageMarks
数组中相应的元素为true
,表示该内存页中的对象已经被标记为可达,那么垃圾回收器会特殊处理该内存页中的对象。例如,在垃圾回收的清理阶段,只有被标记为可达的对象才会被保留,而未被标记的对象则会被释放。它的标记方法和
heapArena.pageInUse
一样,只标记每个span
的第一个page
。
了解完arena
的结构体runtime.heapArena
,再来看看 arena
们在堆中的管理。
arena
在堆中,有一个二维数组 mheap.arenas
管理,每个数组单元存储一个 arena
结构,并且通过mheap.arenas[index]
来访问。定义如下:
//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
......
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
arenaHints *arenaHint
......
}
那为什么要把mheap.arenas
设计成二维数组呢?这个后面会细说。
arenaHint
在堆中,还有一个跟arena
很有关系的字段:arenaHints
,它是 arenaHint
链表的节点结构,保存了arena
的起始地址、是否为最后一个 arena
,以及下一个 arenaHint
指针。其定义如下:
//go 1.20.3 path: /src/runtime/mheap.go
type arenaHint struct {
_ sys.NotInHeap // 标记该字段不会被放入堆中
addr uintptr // 指向内存地址的指针
down bool // 是否向下寻找内存
next *arenaHint // 指向下一个arenaHint的指针
}
Go
的堆是动态按需增长的,初始化的时候并不会向操作系统预先申请一些内存备用,而是等到实际用到的时候才去分配。为避免随机地申请内存造成进程的虚拟地址空间混乱不堪,我们要让堆区从一个起始地址连续地增长,而arenaHint
结构就是用来做这件事情的,它提示分配器从哪里分配内存来扩展堆,尽量使堆按照预期的方式增长。我们用一张图来展示arenaHint
结构:
arenaIdx
arenaIdx
类型底层是个uint
,它的主要作用是用来寻址对应的heapArena
。
给你一个地址p
,你怎么定位到相关的heapArena
呢?
这个要分两步走:
- 将地址
p
通过计算获取arenaIdx
; - 将
arenaIdx
通过计算转化为mheap.arenas
数组的一维和二维索引:l1
和l2
,从而通过索引定位到相关heapArena
存储位置。
地址与arenaIdx 相互转换
在amd64
架构的Linux
环境下,arena
的大小和对齐边界都是64MB
,所以整个虚拟地址空间都可以看作由一系列arena
组成的。arena
区域的起始地址被定义为常量arenaBaseOffset,且与 arena
的大小对齐。
用一个给定的地址p
减去arenaBaseOffset
,然后除以arena
的大小heapArenaBytes
,就可以得到p
所在arena
的编号。反之,给定arena
的编号,也能由此计算出arena
的地址。
相关代码定义如下:
//go 1.20.3 path: /src/runtime/mheap.go
//根据地址计算获取arenaIdx
func arenaIndex(p uintptr) arenaIdx {
return arenaIdx((p - arenaBaseOffset) / heapArenaBytes)
}
//根据arenaIdx计算获取地址
func arenaBase(i arenaIdx) uintptr {
return uintptr(i)*heapArenaBytes + arenaBaseOffset
}
地址与arenaIdx
编号之间的换算如下图:
arenaIdx寻址对应的heapArena
在使用arenaIdx
寻址对应的heapArena
前,我们要先了解为什么要将管理堆中arena
的mheap.arenas
数组设计成二维数组?
mheap.arenas
定义代码如下:
//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
......
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
......
}
我们回忆下,amd64
架构上虚拟地址的有效位数是48
位,而arena
的大小是2^26 = 64MB
,两者相差22
位,也就是说整个地址空间对应4M
个arena
。
我们已经知道每个arena
都有一个对应的heapArena
结构, 如果用arena
的编号作为下标,把所有heapArena
的地址放到一个数组中,则这个数组将占用32MB
空间。32MB
还可以接受,但是在某些系统上就不止32MB
了, 在amd64
架构的Windows
上,受系统原因影响, arena
的大小是4MB
, 缩小了16
倍, 用来寻址heapArena
的数组就会相应地变大16
倍,那就无法接受了,所以Go
的开发者把arenaIdx
分成了两段, 把用来寻址heapArena
的数组也做成了两级, 有点类似于两级页表, 这就是为什么要把 mheap.arenas
数组设计成二维数组的原因。
知道了 mheap.arenas
数组的设计原因后,借助下列函数可以将arenaIdx
转为 mheap.arenas
数组索引的,代码如下:
//go 1.20.3 path: /src/runtime/mheap.go
func (i arenaIdx) l1() uint {
if arenaL1Bits == 0 {
return 0
} else {
return uint(i) >> arenaL1Shift
}
}
func (i arenaIdx) l2() uint {
if arenaL1Bits == 0 {
return uint(i)
} else {
return uint(i) & (1<<arenaL2Bits - 1)
}
}
amd64
架构下,Linux
系统中将arenaL1Bits
被定义为0
,Windows
系统上被定义为6
, 第二级arenaL2Bits
的位数等于虚拟地址有效位数48
减去单个arena
大小对应的位数和第一级(arenaL1Bits
)的位数, amd64
架构下,例如:
Linux
系统,arena
大小为64M
(2的26次方),arenaL1Bits
为0
,则arenaL2Bits = 48 - 26 - 0 = 22
;Windows
系统,arena
大小为4M
(2的22次方),arenaL1Bits
为6
,则arenaL2Bits = 48 - 22 - 6 = 20
。
在Linux
系统上,第一维数组的大小为1
, 相当于没有用到,只用到了第二维这个大小为4M
的数组,arenaIdx
全部的22
位都用作第二维下标来寻址,如图:
在Windows
系统上,第一维数组的大小为64
,第二维大小为1M
,因为两级都存储了指针,利用稀疏数组按需分配的特性,可以大幅节省内存。arenaIdx
被分成两段,高6
位用作第一维下标,低20
位用作第二维下标,如图:
spanOf
在前面我们知道了如何用地址p
定位到arena
结构体heapArena
所在mheap.arenas
的位置,那我们怎么去找到p
它所在的mspan
呢?
答案就是:再用p
对arena
的大小取模,得到p
在arena
中的偏移量, 然后除以页面(page
)大小, 就可以得到对应页面的序号, 将该序号用作heapArena.spans
数组的下标,就可以得到mspan
的地址了。
在runtime
中提供了一些函数,专门用来根据给定的地址查找对应的mspan
,其中最常用的就是spanOf
函数。
该函数在进行映射的同时,还会校验给定的地址是不是一个有效的堆地址,如果有效就会返回对应的mspan
指针,如果无效则返回nil
,函数的代码如下:
//go 1.20.3 path: /src/runtime/mheap.go
// spanOf 函数根据给定的地址 p,返回包含该地址的内存页的 mspan 结构体指针。
func spanOf(p uintptr) *mspan {
// 计算地址 p 对应的 arena 索引
ri := arenaIndex(p)
// 检查 arenaL1Bits 是否为 0,如果为 0,则表示没有第二级索引
if arenaL1Bits == 0 {
// 检查 l2 的值是否超出了第二级 arena 数组的长度
if ri.l2() >= uint(len(mheap_.arenas[0])) {
return nil
}
} else {
// 检查 l1 的值是否超出了第一级 arena 数组的长度
if ri.l1() >= uint(len(mheap_.arenas)) {
return nil
}
}
// 获取第二级 arena 数组中的 l2 对应的指针
l2 := mheap_.arenas[ri.l1()]
// 如果 arenaL1Bits 不为 0 并且 l2 对应的指针为 nil,则返回 nil
if arenaL1Bits != 0 && l2 == nil {
return nil
}
// 获取 l2 对应的 heapArena 指针
ha := l2[ri.l2()]
// 如果 heapArena 指针为 nil,则返回 nil
if ha == nil {
return nil
}
// 根据地址 p 计算在 heapArena 中 spans 数组的索引,并返回相应的 mspan 结构体指针
return ha.spans[(p / pageSize) % pagesPerArena]
}
runtime
中还有一个spanOfUnchecked
函数,与spanOf
函数功能类似,只不过移除了与安全校验相关的代码,需要调用者来保证提供的是一个有效的堆地址,函数的代码如下:
func spanOfUnchecked(p uintptr) *mspan {
ai := arenaIndex(p)
return mheap_.arenas[ai.l1()][ai.l2()].spans[(p/pageSize)%pagesPerArena]
}
关于arena
相关的分析就到这里,期间我们多次提到了mspan
,下面我们就来讲讲它。
内存管理单元 — mspan
前面说了,heapArena
的内存大小是64M
,直接管理这么粗粒度的内存明显不符合实践。
golang
使用span
机制来减少碎片, 每个span
至少分配1
个page
(8KB
), 其大小是page
(Go
中的 page
大小为 8KB
)的倍数,是Go
中内存管理的基本单位。每个span
管理着 arena
中的一块内存。其结构体为runtime.mspan
,后续文中,我们所说的span
与mspan
其实是一个概念,不用过多纠结。
runtime.mspan
是相同大小等级的 span
的双向链表的一个节点,每个节点还记录了自己的起始地址、指向的 span
中页的数量, 一句话概括: mspan
是一个包含起始地址、 mspan
规格、页的数量等内容的双端链表。其结构如下:
//go 1.20.3 path: /src/runtime/mheap.go
type mspan struct {
_ sys.NotInHeap // 这个字段是一个占位符,表示这个字段不需要在堆内存中分配空间
next *mspan // 指向下一个 mspan 结构体的指针
prev *mspan // 指向前一个 mspan 结构体的指针
list *mSpanList // 指向包含此 mspan 的信息的指针
startAddr uintptr // 这是内存块的起始地址
npages uintptr // 这个字段表示内存块包含的页面数
manualFreeList gclinkptr // 这个字段可能是用于手动管理内存的链表,其中的元素由 gclinkptr 类型表示。
freeindex uintptr // 当前内存块中的空闲索引
nelems uintptr // 当前内存块中的元素数量
allocCache uint64 // 分配缓存
allocBits *gcBits // 分配位图
gcmarkBits *gcBits // GC 标记位图
sweepgen uint32 // 垃圾回收的分代状态
divMul uint32 // 用于内存管理的乘数系数
allocCount uint16 // 表示已经分配的内存块的数量
spanclass spanClass // span 类别
state mSpanStateBox // mspan 状态
needzero uint8 // 需要清零标志
isUserArenaChunk bool // 是否是用户 arena chunk
allocCountBeforeCache uint16 // 缓存前的分配计数
elemsize uintptr // 表示每个元素的大小
limit uintptr // 内存块的界限
speciallock mutex // 一个互斥锁,可能用于保护对特定资源的访问
specials *special // 指向 special 类型的指针,可能用于存储与内存块相关的特殊信息
userArenaChunkFree addrRange // 用户 arena chunk 空闲范围
freeIndexForScan uintptr // 用于追踪内存块的空闲索引以进行扫描操作
}
下面对重点字段进行分析:
-
next,prev,list
next
:指向后一个mspan
结构体的指针;prev
:指向前一个mspan
结构体的指针;list
:存储着包含该mspan
的双向链表的头结点和尾节点结构体的指针。next
,prev
串联起当前规格大小(spanclass
) 的mspan
节点会构成如下双向链表, 运行时会使用list
存储双向链表的头结点和尾节点并在线程缓存以及中心缓存中使用。list
字段的类型为runtime.mSpanList
,定义如下://go 1.20.3 path: /src/runtime/mheap.go type mSpanList struct { _ sys.NotInHeap first *mspan //头结点 last *mspan //尾节点 }
next
,prev
双向链表以及list
示意图如下: -
**spanclass,nelems, elemsize **
nelems
: 某mspan
规格类型中含有对象的数量;elemsize
:某mspan
规格类型中单个对象的大小;spanclass
:mspan
的规格类型, 其数据类型为spanClass
,在了解这个结构前,我们先了解mspan
的规格跨度内容。前面我们说过
go
使用span
机制来减少碎片, 在Go
的内存管理模块中,将内存分配67
种跨度类,就是会有67
种不同大小的mspan
,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象。所有的数据都会被预选计算好并存储在runtime.class_to_size
和runtime.class_to_allocnpages
等变量中。来看下源码中的定义://go 1.20.3 path: /src/runtime/sizeclasses.go // 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 // 8 96 8192 85 32 15.95% 32 // 9 112 8192 73 16 13.56% 16 // 10 128 8192 64 0 11.72% 128 // 11 144 8192 56 128 11.82% 16 // 12 160 8192 51 32 9.73% 32 // 13 176 8192 46 96 9.59% 16 // 14 192 8192 42 128 9.25% 64 // 15 208 8192 39 80 8.12% 16 // 16 224 8192 36 128 8.15% 32 // 17 240 8192 34 32 6.62% 16 // 18 256 8192 32 0 5.86% 256 // 19 288 8192 28 128 12.16% 32 // 20 320 8192 25 192 11.80% 64 // 21 352 8192 23 96 9.88% 32 // 22 384 8192 21 128 9.51% 128 // 23 416 8192 19 288 10.71% 32 // 24 448 8192 18 128 8.37% 64 // 25 480 8192 17 32 6.82% 32 // 26 512 8192 16 0 6.05% 512 // 27 576 8192 14 128 12.33% 64 // 28 640 8192 12 512 15.48% 128 // 29 704 8192 11 448 13.93% 64 // 30 768 8192 10 512 13.94% 256 // 31 896 8192 9 128 15.52% 128 // 32 1024 8192 8 0 12.40% 1024 // 33 1152 8192 7 128 12.41% 128 // 34 1280 8192 6 512 15.55% 256 // 35 1408 16384 11 896 14.00% 128 // 36 1536 8192 5 512 14.00% 512 // 37 1792 16384 9 256 15.57% 256 // 38 2048 8192 4 0 12.45% 2048 // 39 2304 16384 7 256 12.46% 256 // 40 2688 8192 3 128 15.59% 128 // 41 3072 24576 8 0 12.47% 1024 // 42 3200 16384 5 384 6.22% 128 // 43 3456 24576 7 384 8.83% 128 // 44 4096 8192 2 0 15.60% 4096 // 45 4864 24576 5 256 16.65% 256 // 46 5376 16384 3 256 10.92% 256 // 47 6144 24576 4 0 12.48% 2048 // 48 6528 32768 5 128 6.23% 128 // 49 6784 40960 6 256 4.36% 128 // 50 6912 49152 7 768 3.37% 256 // 51 8192 8192 1 0 15.61% 8192 // 52 9472 57344 6 512 14.28% 256 // 53 9728 49152 5 512 3.64% 512 // 54 10240 40960 4 0 4.99% 2048 // 55 10880 32768 3 128 6.24% 128 // 56 12288 24576 2 0 11.45% 4096 // 57 13568 40960 3 256 9.99% 256 // 58 14336 57344 4 0 5.35% 2048 // 59 16384 16384 1 0 12.49% 8192 // 60 18432 73728 4 0 11.11% 2048 // 61 19072 57344 3 128 3.57% 128 // 62 20480 40960 2 0 6.87% 4096 // 63 21760 65536 3 256 6.25% 256 // 64 24576 24576 1 0 11.45% 8192 // 65 27264 81920 3 128 10.00% 128 // 66 28672 57344 2 0 4.91% 4096 // 67 32768 32768 1 0 12.50% 8192 const ( _MaxSmallSize = 32768 smallSizeDiv = 8 smallSizeMax = 1024 largeSizeDiv = 128 _NumSizeClasses = 68 _PageShift = 13 ) var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 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} 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, 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}
从上面的规格来看,没有规格
0
,实际上规格0
用来指示所有大对象(大于32768
字节)span
,这种规格的span
直接从堆中分配(其他小的span
会从这67
种跨度的通过一系列方式获取)。这边以规格4
解释每一个指标的由来:class bytes/obj bytes/span objects tail waste max waste min align 4 32 8192 256 0 21.88% 32
class
:规格编号,此处为规格4
;bytes/obj
:规格4
中,每个object
为32
字节(byte
),即mspan
中的elemsize
属性值;bytes/span
:规格4
中,每个span
的内存大小为为8192
字节(8K
),即一个page
大小,因为一个内存页足够分配32
字节的内存空间;objects
:规格4
中,一个span
下objects
个数为256 = 8192/32
,即mspan
中的nelems
属性值;tail waste
:span
连续分配object
后,不足分配1
个object
的剩余空间大小。因为8192
字节的内存能完成分配256
个32
字节的object
,不存在剩余空间,所以tail waste
为0
;max waste
: 申请规格4
的对象最小内存长度为25
字节(小于25
字节则申请规格3
及以下规格mspan
),如果每个object
都被25
字节的对象申请,此时内存浪费最大,对应浪费率为(32-25)/32=21.88%
。
class_to_size
变量存储的则是提前计算好的每个规格span
中单个object
的大小,即存储的是每种mspan
中的elemsize
属性值;class_to_allocnpages
变量存储的是提前计算好的每个规格span
需要分配的page
(8K
)数量。mspan
跨度示意图如下:了解完
mspan
基本跨度类型后,在回过头来看看 其数据类型为spanClass
以及相关方法函数,定义如下://go 1.20.3 path: /src/runtime/mheap.go type spanClass uint8 // makeSpanClass 创建 spanClass 类别。 func makeSpanClass(sizeclass uint8, noscan bool) spanClass { // 将 sizeclass 左移 1 位,然后使用按位或操作将 noscan 的布尔值编码到最低位。 return spanClass(sizeclass<<1) | spanClass(bool2int(noscan)) } // sizeclass 返回 spanClass 对应的 sizeclass。 func (sc spanClass) sizeclass() int8 { // 右移 1 位得到原始 sizeclass。 return int8(sc >> 1) } // noscan 返回 spanClass 是否为 noscan。 func (sc spanClass) noscan() bool { // 通过与 1 进行按位与操作,判断最低位是否为 1。 return sc&1 != 0 }
在
go
中,会将spanclass
+nocan
两部分信息组装成一个uint8
,形成完整的spanClass
标识。8
个bit
中,高7
位表示了上表的span
等级(总共67 + 1
个等级ID
,8
个bit
足够用了),最低位表示nocan
信息,该标记位表示对象是否包含指针,垃圾回收会对包含指针的runtime.mspan
结构体进行扫描。 -
startAddr,npages
startAddr
: 该mspan
起始地址;npages
: 该mspan
含有多少个page
,注意的这里的page
不是linux
的page
概念,是go
里面用的8k
大小的page
。示意如图:
-
freeindex,allocCache,allocCount, allocBits
freeindex
:当前mspan
中,下一个空对象的索引,即0 ~ nelemes-1
,表示分配到第几个块;也就是说freeindex
之前的元素(存储对象的空间)均是已经被使用的,freeindex
之后的元素可能被使用也可能没被使用;allocBits
:当前mspan
中内存的使用情况的位图标记,1
表示该对象已经被申请,0
则表示该对象空闲, 注意的是它标记的对象是elem, 并非page 。与heapArena.bitmap
不同,mspan
这里的位图标记,面向的是划分好的内存块单元,allocBits
位图用于标记哪些内存块已经被分配了;allocCache
:allocBits
的补码,缓存allocBits
中一段未使用的位区(64
个elem
)。主要是为了提升性能,如果不做缓存,就需要遍历allocBits
中所有的位区,才能找到未使用的位区。通过freeindex
与allocCache
的联合,可以不用遍历,就能找到未使用的位区。allocCache
是一连串的bit
位,1
代表未使用,0
代表已使用;allocCount
:当前mspan
中已分配的内存对象数量。我们用一张图形象的表示下这几个字段的关系:
-
gcmarkBits
gcmarkBits
是当前span
的标记位图,在GC
标记阶段会对这个位图进行标记,一个二进制位对应span
中的一个内存块。到
GC
清扫阶段会释放掉旧的allocBits
,然后把标记好的gcmarkBits
用作allocBits
,这样未被GC
标记的内存块就能回收利用了。当然会重新分配一段清零的内存给gcmarkBits
位图。 -
sweepgen
sweepgen
: 垃圾回收的分代状态,主要拥有同mheap
中的当前mSpan
的sweepgen
进行比较,每次GC
,h->sweepgen
都会+2
; 比较分以下几种情况:sweepgen == h->sweepgen - 2
, 这个span
需要清除;sweepgen == h->sweepgen - 1
, 这个span
正在被清除;if sweepgen == h->sweepgen
, 这个span
已经被清除过并且就绪可用;if sweepgen == h->sweepgen + 1
, 这个span
在清除之前就被缓存且仍在缓存中,需要被清除(很明显这里mSpan
的sweepgen>h.sweepgen
,证明已经活过了上一次清除,即被缓存下来);if sweepgen == h->sweepgen + 3
, 这个span
被清除后缓存,且在缓存中。
这字段跟
GC
相关,后续GC
章节内容细说,这边不多展开了。 -
state
state
:该字段是来描绘当前mspan
的运行状态的。状态值有mSpanDead
、mSpanInUse
、mSpanManual
和mSpanFree
四种情况。当runtime.mspan
在空闲堆中,它会处于mSpanFree
状态;当runtime.mspan
已经被分配时,它会处于mSpanInUse
、mSpanManual
状态,运行时会遵循下面的规则转换该状态:- 在垃圾回收的任意阶段,可能从
mSpanFree
转换到mSpanInUse
和mSpanManual
; - 在垃圾回收的清除阶段,可能从
mSpanInUse
和mSpanManual
转换到mSpanFree
; - 在垃圾回收的标记阶段,不能从
mSpanInUse
和mSpanManual
转换到mSpanFree
;
设置
runtime.mspan
状态的操作必须是原子性的以避免垃圾回收造成的线程竞争问题。 - 在垃圾回收的任意阶段,可能从
线程缓存 — mcache
runtime.mcache
是 Go
语言中的线程缓存,它会与线程上的处理器(P
)一一绑定,主要用来缓存用户程序申请的微小对象(0-31Kb
),它是一个包含不同大小等级的 mspan
链表的数组,它将每种 spanClass
等级的 mspan
各缓存了一个,mspan
缓存总数为 2
(nocan
维度) * 68
(大小维度)= 136
。
runtime.mcache
是分配给M
运行中的goroutine
,是协程级所以无需加锁。因为在M
上运行的goroutine
只有一个,不会存在抢占资源的情况,所以是无需加锁的。
runtime.mcache
源码定义如下:
//go 1.20.3 path: /src/runtime/runtime2.go
type p struct {
......
mcache *mcache
......
}
//go 1.20.3 path: /src/runtime/mcache.go
const (
numSpanClasses = _NumSizeClasses << 1
tinySpanClass = spanClass(tinySizeClass<<1 | 1)
)
type mcache struct {
_ sys.NotInHeap // 不在堆上分配的标记
nextSample uintptr // 下一个采样点的地址
scanAlloc uintptr // 扫描时分配的内存大小
tiny uintptr // 用于小对象的内存分配
tinyoffset uintptr // tiny 内存的偏移量
tinyAllocs uintptr // tiny 内存的分配次数
alloc [numSpanClasses]*mspan // 用于不同大小类别的 mspan 链表,存储分配的内存块
stackcache [_NumStackOrders]stackfreelist // 多个大小类别的栈缓存,用于快速分配
flushGen atomic.Uint32 // 刷新代数,用于刷新栈缓存
}
下面对主要字段进行解析:
-
alloc
alloc
:该字段主要用于缓存存储不同类别的mspan
指针信息,其类型为:[numSpanClasses]*mspan
,是一个存储*mspan
且大小为numSpanClasses
的数组。Mcache
将同一级别的span
是分成2
类,一类是可以被GC
扫描的span
,里面是包含指针的对象;另一类似不可以被GC
扫描的span
,里面不包含指针的对象。所以alloc
数组大小为span
规格数量(_NumSizeClasses
)的两倍(numSpanClasses
),即_NumSizeClasses << 1 = 68 << 1 = 136
。runtime.mcache.alloc
示意图如下: -
tiny , tinyoffset, tinyAllocs
mcache
中还有一个为对象分配器tiny allocator
,用于处理小于16B
对象的内存分配,tiny , tinyoffset, tinyAllocs
字段与该对象分配器有关,后续我们会详细分析,此处知道即可。
当小对象申请内存在mache
不够时,会继续向mcentral
进行申请,下面我们了解下mcentral
。
中心缓存 — mcentral
mcentral
是所有线程共享的的缓存,需要加锁访问;它的主要作用是为mcache
提供切分好的mspan
资源。我们先来看看其结构体源码:
//go 1.20.3 path: /src/runtime/mecetral.go
type spanClass uint8
type mcentral struct {
_ sys.NotInHeap
spanclass spanClass // mcentral对应的spanClass
partial [2]spanSet // 储存空闲的Span的列表
full [2]spanSet // 储存非空闲Span的列表
}
每个mcentral
管理一种spanClass
的mspan
, 分别存储包含空闲对象(mcentral.partial
)和不包含空闲对象(mcentral.full
)的内存管理单元:
mcentral.partial
用于存储那些部分分配了内存的span
。所谓“部分分配”,意味着这些span
中的一些内存块已经被分配给对象,但还有其他内存块是空闲的。mcentral.full
用于存储那些完全分配了内存的span
。在这些span
中,所有内存块都已经被分配,没有剩余的空闲空间。
每种内存管理单元同时持有两个 runtime.spanSet
,表示 *mspans
集, 一个用在扫描 spans
,另一个用在未扫描spans
。在每轮GC
期间都扮演着不同的角色。这也是mheap_.sweepgen
在每轮GC
期间都会递增2
的原因。我们可以根据当前的垃圾回收周期(sweep generation
)选择相应mcentral.partial
和mcentral.full
持有的不同扫描状态的 spanSet
:
//go 1.20.3 path: /src/runtime/mcentral.go
func (c *mcentral) partialUnswept(sweepgen uint32) *spanSet {
// 获取当前垃圾回收周期未清扫的部分分配的 spanSet。
return &c.partial[1-sweepgen/2%2]
}
func (c *mcentral) partialSwept(sweepgen uint32) *spanSet {
// 获取当前垃圾回收周期已清扫的部分分配的 spanSet。
return &c.partial[sweepgen/2%2]
}
func (c *mcentral) fullUnswept(sweepgen uint32) *spanSet {
// 获取当前垃圾回收周期未清扫的完全分配的 spanSet。
return &c.full[1-sweepgen/2%2]
}
func (c *mcentral) fullSwept(sweepgen uint32) *spanSet {
// 获取当前垃圾回收周期已清扫的完全分配的 spanSet。
return &c.full[sweepgen/2%2]
}
来看看runtime.spanSet
定义:
//go 1.20.3 path: /src/runtime/mspanset.go
type spanSet struct {
spineLock mutex // spine 的互斥锁
spine atomicSpanSetSpinePointer // 指向 spanSetSpine 结构的原子指针
spineLen atomic.Uintptr // spine 的长度
spineCap uintptr // spine 的容量
index atomicHeadTailIndex // 原子的头尾指针,前32位是头指针,后32位是尾指针
}
type atomicHeadTailIndex struct {
u atomic.Uint64
}
spanSet
这个数据结构里面有一个由index
组成的头尾指针,pop
数据的时候会从head
获取,push
数据的时候从tail
放入,spine
相当于数据块的指针,通过head
和tail
的位置可以算出每个数据块的具体位置,数据块由spanSetBlock
表示:
//go 1.20.3 path: /src/runtime/mspanset.go
const (
spanSetBlockEntries = 512
)
type spanSetBlock struct {
lfnode // lock-free 节点
popped atomic.Uint32 // 弹出的标记位
spans [spanSetBlockEntries]atomicMSpanPointer // 存储 mspan 的数组
}
spanSetBlock
是一个存放mspan
的数据块,里面会包含一个存放512
个mspan
的数据指针。所以mcentral
的总体数据结构如下:
值得注意的是mcentral
链表都在mheap.central
中进行维护, mcentral
存储67
级别大小span
,其中class=0
是不使用的, 每一级别的span
分为2
种,分别为有空间 mspan
链表 partial
和满空间 mspan
链表 full
,代码如下:
//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
central [numSpanClasses]struct {
mcentral mcentral
pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
}
}
示意图如下:
当 mcentral
列表中也没有可分配的 span
时,则会向 mheap
提出请求,从而获得 新的 span
,并进而交给 mcache
。下面来讲解页堆。
页堆 — mheap
mheap
代表Go
中所持有的堆空间,mcentral
管理的span
也是从这里拿到的。当mcentral
没有空闲span
时,会向mheap
申请,如果mheap
中也没有资源了,会向操作系统来申请内存。向操作系统申请是按照页为单位来的(4kb
),然后把申请来的内存页按照page
(8kb
)、span
(page
的倍数)、chunk
(512kb
)、heapArena
(64m
)这种级别来组织起来。
mheap
的结构体是runtime.mheap
,是内存分配的核心结构体,Go
语言程序会将其作为全局变量存储,而堆上初始化的所有对象都由该结构体统一管理,runtime.mheap
定义如下:
//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
_ sys.NotInHeap
lock mutex // 锁,用于保护堆操作
pages pageAlloc // 用于分配和释放操作系统内存页
sweepgen uint32 // 垃圾回收的代数
allspans []*mspan // 所有的 mspan 列表
pagesInUse atomic.Uintptr // 正在使用的页数
pagesSwept atomic.Uint64 // 已经清扫的页数
pagesSweptBasis atomic.Uint64 // 基础的已清扫页数
sweepHeapLiveBasis uint64 // 基础的堆存活字节数
sweepPagesPerByte float64 // 每字节的清扫页数
reclaimIndex atomic.Uint64 // 待回收的页的索引
reclaimCredit atomic.Uintptr // 回收信用
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena // 堆的 arena 结构
heapArenaAlloc linearAlloc // arena 的线性分配器
arenaHints *arenaHint // arena 提示
arena linearAlloc // arena 的线性分配器
allArenas []arenaIdx // 所有 arena 的索引
sweepArenas []arenaIdx // 清扫的 arena 列表
markArenas []arenaIdx // 标记的 arena 列表
curArena struct { // 当前 arena 的信息
base, end uintptr
}
central [numSpanClasses]struct { // 各个 spanClass 的中心缓存
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
spanalloc fixalloc // mspan 结构的分配器
cachealloc fixalloc // mcache 结构的分配器
specialfinalizeralloc fixalloc // 特殊 finalizer 结构的分配器
specialprofilealloc fixalloc // 特殊 profile 结构的分配器
specialReachableAlloc fixalloc // 特殊可达结构的分配器
speciallock mutex // 特殊分配器的锁
arenaHintAlloc fixalloc // arena 提示的分配器
userArena struct { // 用户 arena 的信息
arenaHints *arenaHint // 用户 arena 提示
quarantineList mSpanList // 用户 arena 的 quarantine 列表
readyList mSpanList // 用户 arena 的 ready 列表
}
unused *specialfinalizer // 未使用的特殊 finalizer
}
该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central
,另一个是管理堆区内存区域的 arenas
以及相关字段,这两个字段以及相关信息都在各自内容部分已经做了分析,这里就不再重复,下面着重看看runtime.mheap
一些其他重要概念和字段。
pageAlloc分配器
结构 — pageAlloc
runtime.mheap.pages
字段是个类型为pageAlloc
的分配器,该分配器是堆分配器的一部分,用于管理和分配内存页面。Go
使用page
作为内存管理的基本单位,每页通常为 8KB
大小。pageAlloc
分配器负责在堆中直接高效地分配、释放和合并这些页面,其设计旨在处理大块内存的分配和回收,与处理小块内存分配的分配器(如 fixalloc
和 mcache
)形成互补。
pageAlloc
结构体定义为:
//go 1.20.3 path: /src/runtime/mpagealloc.go
type pageAlloc struct {
// summary 是一个多级数组,用于快速总结内存页的分配状态。
// 它是一个层级化的数据结构,每一级都以压缩的方式提供了对其所覆盖的内存页的分配情况的概述。
summary [summaryLevels][]pallocSum
// chunks 是一个两级的直接映射表,用于表示内存块(chunk)的分配数据。
// 每个 chunk 表示一组页面,chunks 使得可以快速访问和管理这些页面。
chunks [1 << pallocChunksL1Bits]*[1 << pallocChunksL2Bits]pallocData
// searchAddr 代表下一次内存分配搜索的起始地址。
// 它帮助 pageAlloc 记住上一次搜索结束的位置,以实现高效的下一次搜索。
searchAddr offAddr
// start 和 end 表示目前堆内存中 chunk 的起始索引和结束索引。
// 它们定义了当前堆空间的边界。
start, end chunkIdx
// inUse 表示当前正在使用的内存地址范围。
// 这是一个地址范围的集合,标记了哪些内存正在被使用。
inUse addrRanges
// scav 结构体包含关于已释放内存页(被清扫的页)的信息。
scav struct {
// index 是用于记录和查询被清扫页的索引。
index scavengeIndex
// released 表示已经被释放回操作系统的内存总量。
released uintptr
}
// mheapLock 是用来同步访问堆内存的互斥锁。
mheapLock *mutex
// sysStat 是用于记录内存统计信息的指针。
sysStat *sysMemStat
// summaryMappedReady 是一个标记,表示 summary 数组是否已经准备好被使用。
// 如果为 0,则表示 summary 还没有完全映射到内存中。
summaryMappedReady uintptr
// test 是一个用于测试的布尔标志。
test bool
}
这个结构体定义了 Go
运行时内存分配器如何跟踪内存页的分配状态,以及如何高效地进行内存分配和回收。不同字段和子结构体共同协作,以优化内存操作和垃圾收集的性能。下面对重点字段进行分析:
-
chunks
chunk
是堆内存中的一块连续区域,通常包含多个内存页,被用来跟踪和管理它包含的内存页的分配状态。这包括哪些页被分配,哪些是空闲的,以及它们是否可以被回收。chunks
数组表示的是表示了地址空间内所有chunk
里page
分配和scavenge
情况,是个2
级的直接映射表,其第一维的大小为:1 << pallocChunksL1Bits = 1 << 13 = 8192
, 其二维的大小为:1 << pallocChunksL2Bits = 1 << 13 = 8192
, 所以chunks
数组最终就是一个[8192]*[8192]pallocData
数组。我们知道
L4
层最大有2^26^
个4M
的块,如果将chunks
定义成一维数组来存储是一个相当庞大的数据,性能会大大降低。因此将其设计为二维数组[8192]*[8192]
这个维度,能容纳元素数量正好是2^26^
,完美的将数组维度降低了,方便了存储。我们再来仔细看看
chunks [1 << pallocChunksL1Bits]*[1 << pallocChunksL2Bits]pallocData
,这个数组结构是不是有点眼熟,能不能想起它来:arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
,对,就是它arena
!arena
与chunks
在存储设计上如出一辙,arena
可以通过arenaIdx
来互相转换成第一维度L1
和第二维度L2
的值,而chunks
也可以通过chunkIdx
完成同样的转换,来看下chunkIdx
的定义和转换方法://go 1.20.3 path: /src/runtime/mpagealloc.go type chunkIdx uint //计算给定地址相对于块索引(chunk index) func chunkIndex(p uintptr) chunkIdx { return chunkIdx((p - arenaBaseOffset) / pallocChunkBytes) } //计算给定chunk index相对于地址 func chunkBase(ci chunkIdx) uintptr { return uintptr(ci)*pallocChunkBytes + arenaBaseOffset } //通过chunkIdx计算该索引在pageAlloc.chunks数组中的一维index func (i chunkIdx) l1() uint { if pallocChunksL1Bits == 0 { return 0 } else { return uint(i) >> pallocChunksL1Shift } } //通过chunkIdx计算该索引在pageAlloc.chunks数组中的二维index func (i chunkIdx) l2() uint { if pallocChunksL1Bits == 0 { return uint(i) } else { return uint(i) & (1<<pallocChunksL2Bits - 1) } }
当然
pageAlloc
分配器还提供了更加方便的函数runtime.pageAlloc.chunkOf
,通过chunkIdx
直接获取数组存储的对象指针://go 1.20.3 path: /src/runtime/mpagealloc.go func (p *pageAlloc) chunkOf(ci chunkIdx) *pallocData { return &p.chunks[ci.l1()][ci.l2()] }
了解完存储和查找的知识点,再来看看其存储的
pallocData
是个何方神圣,其定义如下://go 1.20.3 path: /src/runtime/mpallocbits.go type pallocBits pageBits type pageBits [pallocChunkPages / 64]uint64 //pallocChunkPages值为512,所以pageBits可以看做[8]uint64 type pallocData struct { pallocBits scavenged pageBits //GC相关 }
每一个
chunk
都有一个pallocData
结构,其中pallocBits
管理其分配的位图。pallocBits
是一个uint64
的大小为8
的数组。由于每一位bit
对应着一个page
,因此pallocBits
总共对应着64*8 = 512page
,恰好是一个chunk
块的大小。位图的对应方式和之前是一样的。结构关系图如下:关于
pallocBits
相关位图操作,源码中提供了下了方法以方便我们操作://go 1.20.3 path: /src/runtime/mpallocbits.go //返回位图中第 i 个位的值 func (b *pageBits) get(i uint) uint { return uint((b[i/64] >> (i % 64)) & 1) } // 返回包含第 i 个位的 64 位对齐的位块 func (b *pageBits) block64(i uint) uint64 { return b[i/64] }
-
searchAddr
searchAddr
表示搜索起始地址,存储了一个地址值,这个值指示了pageAlloc
在搜索可用内存页面时的起始位置。这是一个优化,帮助快速定位到可能的空闲内存区域。 -
start, end
start
:表示pageAlloc
管理的内存区域的起始地址,它标记了可用于分配的内存页面区域的开始。end
: 表示pageAlloc
管理的内存区域的结束地址,这个字段定义了可用于页面分配的堆内存区域的上界,其值通常是动态变化的,它会随着堆的增长而增长。 -
summay
summary
是一个多级数据结构,该结构提供了一种层级化的方式来索引和查找chunks
。它类似于一本书的目录,其中高层级的summary
快速指导到包含空闲页的chunks
。也就是说summary
分为多层,每层存储着不同数量的chunk
。通过summary
,pageAlloc
可以迅速定位到有空闲页的chunks
,而无需逐个检查每个chunk
。这大大提升了内存分配的效率。summary
维度以及层级包含chunk
数量由summaryLevels
、levelBits
、levelLogPages
等和其他相关常量定义,我们给出其在64
位系统下相关常量代码定义://go 1.20.3 path: /src/runtime/mpagealloc.go const ( // summaryLevels 定义了概要信息的层数,是层级化概要的深度。 summaryLevels = 5 // pageAlloc32Bit 和 pageAlloc64Bit 是用于标识系统是32位还是64位的常量, // 这对于地址空间的大小和页面大小有影响。 pageAlloc32Bit = 0 pageAlloc64Bit = 1 // pallocChunksL1Bits 定义了第一级 chunks 数组的大小。 // 这个值影响了 chunks 数组的分段,这是内存分配表的一部分。 pallocChunksL1Bits = 13 // summaryLevelBits 定义了除了顶层外,每个概要层级的位数。 // 在层级化的概要信息中,每一层(除了顶层)使用这个值作为位数来表示更大区域的页面分配状态。 summaryLevelBits = 3 // logPallocChunkPages 是 pallocChunkPages 的对数表示, // 其中 pallocChunkPages 表示每个 chunk 包含的页数。 // 这个值用于在位图和其他数据结构中定位和管理页面。 logPallocChunkPages = 9 // _PageShift 是系统页面大小的对数表示,通常是系统分配内存的最小单位(通常为 4KiB)。 // 这个值用于将页面大小从字节转换为页数。 _PageShift = 13 // pageShift 是_PageShift的别名,用于表示一个页面的大小的位移量。 pageShift = _PageShift // logPallocChunkBytes 是每个 chunk 的字节大小的对数表示。 // 它是由每个 chunk 的页面数的对数(logPallocChunkPages)加上每页的字节大小的对数(pageShift)计算得出的。 logPallocChunkBytes = logPallocChunkPages + pageShift // summaryL0Bits 是顶层概要层级的位数。 // 它根据堆地址空间的位数(heapAddrBits)减去一个 chunk 的字节大小的对数(logPallocChunkBytes) // 和其他概要层级占用的位数来计算。 // 这是最顶层概要的位数,它覆盖了堆中最大的区域。 summaryL0Bits = heapAddrBits - logPallocChunkBytes - (summaryLevels-1)*summaryLevelBits ) // levelBits 是一个数组,定义了每个层级逐层的chunk的数量对象,即radix tree的逐层基数位 // 这些位代表了内存中的页面,用于快速检查页面的分配状态。 var levelBits = [summaryLevels]uint{ summaryL0Bits, // 第 0 层的位数,值为14,可以计算获取chunk数量为:2的14方 summaryLevelBits, // 第 1 层的位数,值为14+3=17,可以计算获取chunk数量为:2的17方 summaryLevelBits, // 第 2 层的位数,值为17+3=20,可以计算获取chunk数量为:2的20方 summaryLevelBits, // 第 3 层的位数,值为20+3=23,可以计算获取chunk数量为:2的23方 summaryLevelBits, // 第 4 层的位数,值为23+3=26,可以计算获取chunk数量为:2的26方 } // levelShift 是一个数组,定义了每个层级chunk的容量的对数值。 // 这是计算某个内存地址在概要信息中位置时使用的位移量。 var levelShift = [summaryLevels]uint{ heapAddrBits - summaryL0Bits, // 第 0 层的位移量,即48-14=34,容量大小为:2的34方=16G heapAddrBits - summaryL0Bits - 1*summaryLevelBits, // 第 1 层的位移量,即34-3=31,容量大小为:2的31方=2G heapAddrBits - summaryL0Bits - 2*summaryLevelBits, // 第 2 层的位移量,即31-3=28,容量大小为:2的28方=256M heapAddrBits - summaryL0Bits - 3*summaryLevelBits, // 第 3 层的位移量,即28-3=25,容量大小为:2的25方=32M heapAddrBits - summaryL0Bits - 4*summaryLevelBits, // 第 4 层的位移量,即25-3=22,容量大小为:2的22方=4M } // levelLogPages 是一个数组,定义了每个层级对应的页面数量的对数值。 // 这有助于确定一个层级概要所代表的页面数量。 var levelLogPages = [summaryLevels]uint{ logPallocChunkPages + 4*summaryLevelBits, // 第 0 层的页面对数,即9+12=21,page数量为:2的21方=2097152 logPallocChunkPages + 3*summaryLevelBits, // 第 1 层的页面对数,即9+9=18,page数量为:2的18方=262144 logPallocChunkPages + 2*summaryLevelBits, // 第 2 层的页面对数,即9+6=15,page数量为:2的15方=32768 logPallocChunkPages + 1*summaryLevelBits, // 第 3 层的页面对数,即9+3=12,page数量为:2的15方=4096 logPallocChunkPages, // 第 4 层的页面对数,即9,page数量为:2的9方=512 }
根据上述常量定义,我们将
summary
捋一捋:summary
数组第一维度层级由summaryLevels
常量决定,该值为5
,则说明summary
数组第一维度分为5
层,我们分别称这五层为L0
-L4
;summary
数组每层包含的chunk
数量、chunk
大小容量、chunk
包含page
数量分别由常量levelBits
、levelShift
、levelLogPages
定义,这些常量定义的为对数值,求其对数平方就可以求的值,这些在代码注释上解释的非常清楚了。
根据上面值定义,我们可以得出
pageAlloc.summary
最终示意图如下:说完
summary
数组的划分,我们再来看看summary
数组存储的数据类型:pallocSum
,其定义如下://go 1.20.3 path: /src/runtime/mpagealloc.go type pallocSum uint64
pallocSum
其数据类型就是一个uint64
数字,其占了8
字节内存,含有64bit
,将pallocSum
划分成3
部分:start
、max
、end
,如下图:start
、max
、end
每一个都是位图的摘要,每部分能分到21
个bit
,我们知道L0
层每个chunk
为16G
,每个page
为8KB
, 所以每个chunk
最多支持2^34/2^13=2^21
个page
,而21bit
能表示的数最大恰好为2^21-1
,因此是满足使用条件的,在极限条件下,可以通过设置第64
位来表示。相关代码定义如下:
//go 1.20.3 path: /src/runtime/mpagealloc.go //从pallocSum中提取起始值。 //实现方式:首先检查pallocSum是否包含特定的位标记(即最高位是否为1,表示为 1<<63)。如果是,返回一个最大值(maxPackedValue)。否则,返回pallocSum的低位部分,这部分代表起始值。 func (p pallocSum) start() uint { if uint64(p)&uint64(1<<63) != 0 { return maxPackedValue } return uint(uint64(p) & (maxPackedValue - 1)) } //从pallocSum中提取最大值 //实现方式:类似于 start(),先检查是否有特定位标记。如果有,同样返回最大值。如果没有,它会将pallocSum向右移动一定位数(logMaxPackedValue),然后与 maxPackedValue - 1 进行按位与操作,以提取中间部分的最大值。 func (p pallocSum) max() uint { if uint64(p)&uint64(1<<63) != 0 { return maxPackedValue } return uint((uint64(p) >> logMaxPackedValue) & (maxPackedValue - 1)) } //从pallocSum中提取结束值 //这个函数也首先检查是否有特定的位标记。如果有,返回最大值。否则,它将pallocSum向右移动更多的位数(两倍的 logMaxPackedValue),然后执行按位与操作,以提取打包总和中的结束值部分 func (p pallocSum) end() uint { if uint64(p)&uint64(1<<63) != 0 { return maxPackedValue } return uint((uint64(p) >> (2 * logMaxPackedValue)) & (maxPackedValue - 1)) }
讲到这里,那么
pallocSum
中start
、max
和end
的含义是啥呢?其实这些都是表示的是块内的page
的分配情况。我们用
L4
层举个例子,每个块chunk
大小为4M
,也就是有512
个page
,编号(index
)从0
到511
。那么:start
:表示这512
个page
中从第几个page
是空闲的,小于start
编号的page
是都已分配出去了;end
: 表示512
个page
中分配出去page
的结束索引编号;max
:表示这512
个page
中连续为0
(即未分配出去) 一片区域最大有多少个page
。
上图中最大连续的空闲块数即
max
的值为3
,这时候假设要分配一个npages
的值为3
的空间,因为max>=npages
,所以肯定可以从当前的chuck
中分配到内存。假如npages
的值为4
,这时候max<npages
,显然是从这个chuck
中找不到一个块有npages
页面的区域的,所以不用一个一个遍历当前chuck
中的每个page
(pageAlloc.chuck
中的pallocData.pallocBits
)查找是否满足分配要求了。下面来分析解决两个问题:
- 对于给定的地址
addr
,怎么知道这个addr
在哪个chunk
中?
因为对整个虚拟内存空间采用的是平坦划分,所以对于任意给定的地址
addr
除以单个chunk
的大小,就可以定位到这个地址属于哪个块(chunkIdx
),对于L4
来说,每个chunk
为4M
,直接看源码:type chunkIdx uint pallocChunkBytes = pallocChunkPages * pageSize // 就是L4的块大小4M func chunkIndex(p uintptr) chunkIdx { return chunkIdx((p - arenaBaseOffset) / pallocChunkBytes) }
转为公式就是:
c h u n k I d x = ( a d d r − a r e n a B a s e O f f s e t ) / p a l l o c C h u n k B y t e s chunkIdx = (addr - arenaBaseOffset) / pallocChunkBytes chunkIdx=(addr−arenaBaseOffset)/pallocChunkBytes
除了定位到chunkIdx
,也可以根据地址直接定位到page
:func chunkPageIndex(p uintptr) uint { return uint(p % pallocChunkBytes / pageSize) }
转为公式就是:
c h u n k p a g e I n d e x = a d d r % p a l l o c C h u n k B y t e s / p a g e S i z e chunkpageIndex = addr\%pallocChunkBytes / pageSize chunkpageIndex=addr%pallocChunkBytes/pageSize-
从某个块
i
中找到了空闲的page
,该page
索引为n
,怎么得到空闲page
对应的内存地址addr
?我们可以先计算出
i
块的base
地址,base
地址计算如下:func chunkBase(ci chunkIdx) uintptr { return uintptr(ci)*pallocChunkBytes + arenaBaseOffset }
计算出块的
base
地址后,再加上n
个page
的 地址大小即可,转为公式即:
a d d r = ( i ∗ p a l l o c C h u n k B y t e s + a r e n a B a s e O f f s e t ) + n ∗ p a g e S i z e addr = (i * pallocChunkBytes + arenaBaseOffset) + n * pageSize addr=(i∗pallocChunkBytes+arenaBaseOffset)+n∗pageSize
初始化 — pageAlloc.init
pageAlloc
分配器初始化是在mehp
初始化中被调用的,主要负责初始化 pageAlloc
结构体,这是内存管理系统的一部分。其代码如下:
//go 1.20.3 path: /src/runtime/mpagealloc.go
//初始化 pageAlloc 结构体。该方法负责设置初始状态和参数。
func (p *pageAlloc) init(mheapLock *mutex, sysStat *sysMemStat) {
// 检查是否满足一些基本条件,保证内存分配的逻辑和限制正确。
// levelLogPages[0] 表示的是最顶层页表的日志大小。
// 如果这个值大于 logMaxPackedValue,说明分页大小超出了预期,这是一个异常情况。
if levelLogPages[0] > logMaxPackedValue {
print("runtime: root level max pages = ", 1<<levelLogPages[0], "\n")
print("runtime: summary max pages = ", maxPackedValue, "\n")
// 如果条件不满足,则抛出异常。
throw("root level max pages doesn't fit in summary")
}
// 设置内存统计结构体。
p.sysStat = sysStat
// 初始化 inUse 字段,它追踪哪些页面正在使用中。
p.inUse.init(sysStat)
// 执行系统级别的初始化操作,涉及到操作系统层面的内存分配和管理。
p.sysInit()
// 设置搜索地址,这是内存分配的起始地址。
p.searchAddr = maxSearchAddr()
// 设置堆内存的互斥锁。
p.mheapLock = mheapLock
}
根据代码,可以看出该函数主要工作是:
- 参数检查:验证内存页面的大小设置是否符合预期,确保不超过设定的最大值;
- 内存统计设置:关联一个内存统计对象(
sysStat
)以跟踪内存使用情况; - 初始化内存使用追踪:设置一个用于追踪正在使用中的内存页面的数据结构;
- 执行系统级初始化:进行与操作系统相关的内存管理初始化工作;
- 设置内存分配起始地址:确定内存分配搜索的起始点,优化内存分配性能;
- 设置内存堆互斥锁:引入互斥锁以保障多线程环境下内存分配操作的线程安全。
流程很简单,注释很清楚了,就不再解释了,主要我们看下sysInit()
函数,看看它的做了什么,其源码如下:
//go 1.20.3 path: /src/runtime/mpagealloc_bit64.go
func (p *pageAlloc) sysInit() {
// 遍历 levelShift,这个数组定义了不同级别的chunk的容量对数
for l, shift := range levelShift {
// 根据位移计算每个级别的条目数。
entries := 1 << (heapAddrBits - shift)
// 计算需要预留的内存大小,并确保它按物理页面大小对齐。
b := alignUp(uintptr(entries)*pallocSumBytes, physPageSize)
// 为页面摘要信息预留内存。如果预留失败,则抛出异常。
r := sysReserve(nil, b)
if r == nil {
throw("failed to reserve page summary memory")
}
// 将预留的内存转换为不在堆上的切片。
sl := notInHeapSlice{(*notInHeap)(r), 0, entries}
// 将转换后的切片赋值给 p.summary[l],用于存储页面摘要。
p.summary[l] = *(*[]pallocSum)(unsafe.Pointer(&sl))
}
// 计算用于跟踪空闲和占用内存块的位图所需的字节数。
nbytes := uintptr(1<<heapAddrBits) / pallocChunkBytes / 8
// 为位图预留内存。如果预留失败,则抛出异常。
r := sysReserve(nil, nbytes)
// 将预留的内存转换为不在堆上的切片,用于存储位图。
sl := notInHeapSlice{(*notInHeap)(r), int(nbytes), int(nbytes)}
// 将转换后的切片赋值给 p.scav.index.chunks,用于跟踪内存的回收状态。
p.scav.index.chunks = *(*[]atomic.Uint8)(unsafe.Pointer(&sl))
}
在这个方法中,pageAlloc
结构体通过预留并设置内存来初始化它的内部数据结构,这些数据结构用于跟踪和管理内存分配。主要包括为页面摘要信息和内存块的位图预留内存,以及将这些预留的内存区域转换为特定的切片类型以便后续使用。这是 Go
语言内存管理系统的一部分,确保内存分配的高效和有序。
分配 — pageAlloc.alloc
pageAlloc.alloc
函数的主要职责是在 Go
运行时环境中分配一定数量的内存页。
这个过程不仅需要确保内存的有效利用,还需要考虑线程安全和性能优化。函数通过一系列复杂的步骤来搜索、验证和分配内存,这些步骤包括验证锁的状态、搜索足够的连续内存空间以及处理可能出现的异常情况。
代码定义如下:
//go 1.20.3 path: /src/runtime/mpagealloc.go
func (p *pageAlloc) alloc(npages uintptr) (addr uintptr, scav uintptr) {
//断言确保已经持有了 mheapLock。这是一个同步机制,用于确保在多线程环境中对堆内存的操作是安全的。
assertLockHeld(p.mheapLock)
// 检查 pageAlloc 的搜索起始地址是否已经超出了其维护的内存区域的末端。如果是,就直接返回 0,表示没有找到足够的空间。
if chunkIndex(p.searchAddr.addr()) >= p.end {
return 0, 0
}
// 设置搜索地址的初始值为最小的偏移地址。
searchAddr := minOffAddr
// 检查当前搜索地址的块内是否有足够的空间来分配请求的页数。
if pallocChunkPages-chunkPageIndex(p.searchAddr.addr()) >= uint(npages) {
// 计算当前搜索地址所在的块索引。
i := chunkIndex(p.searchAddr.addr())
// 获取当前块的最大可用连续页数,并检查是否满足请求的页数。
if max := p.summary[len(p.summary)-1][i].max(); max >= uint(npages) {
// 在当前块中查找足够大的连续空间来分配请求的页数。同时获取更新后的搜索地址。
j, searchIdx := p.chunkOf(i).find(npages, chunkPageIndex(p.searchAddr.addr()))
// 如果没有找到足够的空间,打印错误信息并抛出异常。
if j == ^uint(0) {
print("runtime: max = ", max, ", npages = ", npages, "\n")
print("runtime: searchIdx = ", chunkPageIndex(p.searchAddr.addr()), ", p.searchAddr = ", hex(p.searchAddr.addr()), "\n")
throw("bad summary data")
}
// 计算找到的空间的起始地址,并更新搜索地址。
addr = chunkBase(i) + uintptr(j)*pageSize
searchAddr = offAddr{chunkBase(i) + uintptr(searchIdx)*pageSize}
goto Found
}
}
// 如果之前的步骤没有找到足够的空间,调用 find 方法在整个 pageAlloc 中搜索。
addr, searchAddr = p.find(npages)
// 如果仍然没有找到足够的空间,对于请求的页数为 1 的情况,设置搜索地址为最大值,并返回 0。
if addr == 0 {
if npages == 1 {
p.searchAddr = maxSearchAddr()
}
return 0, 0
}
Found:
// 在找到的地址范围内进行实际的内存分配,并返回分配的内存地址和清理的页数。
scav = p.allocRange(addr, npages)
// 更新 pageAlloc 的搜索地址为新的搜索起点。
if p.searchAddr.lessThan(searchAddr) {
p.searchAddr = searchAddr
}
// 返回分配的内存地址和清理的页数。
return addr, scav
}
总结该函数主要流程:
- 确认已经持有对应的
mheapLock
,检查searchAddr
是否超出了内存分配区的末端,如果是,返回(0, 0)
表示没有足够的空间进行分配; - 初始化
searchAddr
为最小偏移地址 (minOffAddr
),判断当前searchAddr
对应的 chunk 中是否有足够的空间分配npages
个页面; - 如果有足够的空间,查找具体可用的连续页面的开始地址。如果找到了连续页面,计算页面的地址,更新
searchAddr
,并跳转到标签Found
; - 如果在当前
chunk
中没有找到足够的页面,则调用find
方法搜索整个堆以找到足够的空间;- 如果
find
方法找到了足够的空间,那么addr
将被设置为分配到的内存地址; - 如果
find
方法没有找到足够的空间,且请求的页面数为1
,那么设置searchAddr
为最大搜索地址,然后返回(0, 0)
。
- 如果
- 在
Found
标签处,调用allocRange
方法实际分配内存页,更新searchAddr
为较大的值,并返回内存地址和清理内存页的数量。
查找 — pageAlloc.find
pageAlloc.find
主要任务是遍历 pageAlloc
的多级摘要结构,寻找足够大的连续空闲内存区域以满足指定大小(npages
)的内存分配请求。它使用了一系列复杂的逻辑来高效地搜索和计算空闲内存区域的地址,确保找到的内存区域不仅足够大,而且满足一定的地址约束。如果找不到合适的空间,它会打印错误信息并抛出异常。其代码定义如下:
//go 1.20.3 path: /src/runtime/mpagealloc.go
func (p *pageAlloc) find(npages uintptr) (uintptr, offAddr) {
// 确保拥有内存堆锁,这是为了线程安全。
assertLockHeld(p.mheapLock)
// i 用于遍历 pageAlloc 的摘要层级。
i := 0
// firstFree 用于记录找到的第一个合适的空闲内存区域。
firstFree := struct {
base, bound offAddr
}{
base: minOffAddr,
bound: maxOffAddr,
}
// foundFree 是一个闭包函数,用于更新 firstFree 的值。
foundFree := func(addr offAddr, size uintptr) {
// 判断找到的空闲区域是否在 firstFree 的范围内。
if firstFree.base.lessEqual(addr) && addr.add(size-1).lessEqual(firstFree.bound) {
firstFree.base = addr
firstFree.bound = addr.add(size - 1)
} else if !(addr.add(size-1).lessThan(firstFree.base) || firstFree.bound.lessThan(addr)) {
// 如果空闲区域部分重叠,则打印错误信息并抛出异常。
print("runtime: addr = ", hex(addr.addr()), ", size = ", size, "\n")
print("runtime: base = ", hex(firstFree.base.addr()), ", bound = ", hex(firstFree.bound.addr()), "\n")
throw("range partially overlaps")
}
}
// lastSum 和 lastSumIdx 用于跟踪最后一个处理过的摘要和其索引。
lastSum := packPallocSum(0, 0, 0)
lastSumIdx := -1
// 遍历 pageAlloc 的摘要层级。
nextLevel:
for l := 0; l < len(p.summary); l++ {
// 计算每个块内的条目数。
entriesPerBlock := 1 << levelBits[l]
logMaxPages := levelLogPages[l]
// 更新索引 i。
i <<= levelBits[l]
entries := p.summary[l][i : i+entriesPerBlock]
// 计算搜索的起始索引。
j0 := 0
if searchIdx := offAddrToLevelIndex(l, p.searchAddr); searchIdx&^(entriesPerBlock-1) == i {
j0 = searchIdx & (entriesPerBlock - 1)
}
// base 和 size 用于跟踪当前找到的空闲区域的基址和大小。
var base, size uint
for j := j0; j < len(entries); j++ {
sum := entries[j]
// 如果当前摘要项为零,则重置大小并继续。
if sum == 0 {
size = 0
continue
}
// 更新 firstFree 的值。
foundFree(levelIndexToOffAddr(l, i+j), (uintptr(1)<<logMaxPages)*pageSize)
// 根据摘要项来更新 base 和 size。
s := sum.start()
if size+s >= uint(npages) {
if size == 0 {
base = uint(j) << logMaxPages
}
size += s
break
}
if sum.max() >= uint(npages) {
i += j
lastSumIdx = i
lastSum = sum
continue nextLevel
}
if size == 0 || s < 1<<logMaxPages {
size = sum.end()
base = uint(j+1)<<logMaxPages - size
continue
}
size += 1 << logMaxPages
}
// 如果找到了足够大的空闲区域,则返回其地址。
if size >= uint(npages) {
addr := levelIndexToOffAddr(l, i).add(uintptr(base) * pageSize).addr()
return addr, p.findMappedAddr(firstFree.base)
}
if l == 0 {
return 0, maxSearchAddr()
}
// 如果没有找到足够大的空闲区域,则打印错误信息并抛出异常。
print("runtime: summary[", l-1, "][", lastSumIdx, "] = ", lastSum.start(), ", ", lastSum.max(), ", ", lastSum.end(), "\n")
print("runtime: level = ", l, ", npages = ", npages, ", j0 = ", j0, "\n")
print("runtime: p.searchAddr = ", hex(p.searchAddr.addr()), ", i = ", i, "\n")
print("runtime: levelShift[level] = ", levelShift[l], ", levelBits[level] = ", levelBits[l], "\n")
for j := 0; j < len(entries); j++ {
sum := entries[j]
print("runtime: summary[", l, "][", i+j, "] = (", sum.start(), ", ", sum.max(), ", ", sum.end(), ")\n")
}
throw("bad summary data")
}
// 在最后一层进行查找。
ci := chunkIdx(i)
j, searchIdx := p.chunkOf(ci).find(npages, 0)
if j == ^uint(0) {
sum := p.summary[len(p.summary)-1][i]
print("runtime: summary[", len(p.summary)-1, "][", i, "] = (", sum.start(), ", ", sum.max(), ", ", sum.end(), ")\n")
print("runtime: npages = ", npages, "\n")
throw("bad summary data")
}
// 计算并返回找到的空闲内存区域的地址。
addr := chunkBase(ci) + uintptr(j)*pageSize
searchAddr := chunkBase(ci) + uintptr(searchIdx)*pageSize
foundFree(offAddr{searchAddr}, chunkBase(ci+1)-searchAddr)
return addr, p.findMappedAddr(firstFree.base)
}
总结该函数的主要流程:
- 锁检查:确保在调用函数时持有内存堆锁,以保证线程安全。初始化第一个空闲区域:定义一个结构体
firstFree
,初始设定为最小和最大的偏移地址; - 定义找到空闲区域的处理函数:创建一个闭包函数
foundFree
,用于在找到合适的空闲区域时更新firstFree
的值;初始化摘要索引和状态:设置lastSum
和lastSumIdx
为初始状态,以跟踪最后一个处理过的摘要和其索引; - 遍历
summary
层级:对pageAlloc
的summary
层级进行遍历,每个层级都对应不同大小的内存块。计算索引和条目:对每个层级,计算相关索引和摘要条目。搜索空闲内存块:遍历当前层级的摘要条目,寻找足够大的空闲内存块。这涉及到对每个条目的开始、最大和结束值的计算。更新空闲区域信息:根据找到的空闲块信息,更新firstFree
结构体; - 检查是否满足需求:检查当前找到的空闲内存是否满足所需的页面数(
npages
)。处理不同情况:根据不同情况进行处理,如找到足够大的内存块或需要进入下一个摘要层级; - 如果在所有层级中都未找到足够的内存,打印错误信息并抛出异常;如果找到合适的内存块,计算并返回其地址。
对于pageAlloc
的summary
的设计,其实是一颗基数树(radix tree
),基数树中,每个节点称之为 PallocSum
,是一个 uint64
类型,体现了索引的聚合信息,其特点如下:
- 每个父
pallocSum
有8
个子pallocSum
; - 根
pallocSum
总览全局,映射的bitMap
范围为全局的16 GB
空间(其max
最大值为2^21
,因此总空间大小为2^21*8KB=16GB
); - 从首层向下是一个依次八等分的过程,每一个
pallocSum
映射其父节点bitMap
范围的八分之一,因此第二层pallocSum
的bitMap
范围为16GB/8
=2GB
,以此类推,第五层节点的范围为16GB / (8^4) = 4 MB
; - 聚合信息时,自底向上. 每个父
pallocSum
聚合 8 个子pallocSum
的start、max、end
信息,形成自己的信息,直到根pallocSum
,坐拥全局16 GB
的start、max、end
信息; mheap
寻页时,自顶向下. 对于遍历到的每个pallocSum
,先看起start
是否符合,是则寻页成功;再看max
是否符合,是则进入其下层孩子pallocSum
中进一步寻访;最后看end
和下一个同辈pallocSum
的start
聚合后是否满足,是则寻页成功。
扩容 — pageAlloc.grow
runtime.pageAlloc.grow
方法的主要职责是扩展 pageAlloc
管理的内存范围,以便提供更多的内存页供分配。这个过程涉及与操作系统的交互、内存地址的计算和对齐,以及更新内存管理结构的状态。
该函数一般是在堆增长操作 runtime.mheap.grow
操作后执行,下面我们将深入探讨这个函数的源码,以理解其工作原理和执行流程。
//go 1.20.3 path: /src/runtime/mpagealloc.go
// grow 方法用于扩展 pageAlloc 的内存范围。
func (p *pageAlloc) grow(base, size uintptr) {
// 确保持有 mheap 锁。
assertLockHeld(p.mheapLock)
// 将范围的结束地址对齐到 pallocChunkBytes。
limit := alignUp(base+size, pallocChunkBytes)
// 将范围的开始地址向下对齐到 pallocChunkBytes。
base = alignDown(base, pallocChunkBytes)
// 从操作系统中增长内存。
p.sysGrow(base, limit)
// 检查是否是首次增长或者新的开始地址是否小于当前开始地址。
firstGrowth := p.start == 0
start, end := chunkIndex(base), chunkIndex(limit)
if firstGrowth || start < p.start {
p.start = start
}
// 如果新的结束地址大于当前结束地址,更新它。
if end > p.end {
p.end = end
}
// 将新的地址范围标记为使用中。
p.inUse.add(makeAddrRange(base, limit))
// 如果新的基地址小于当前的搜索地址,更新搜索地址。
if b := (offAddr{base}); b.lessThan(p.searchAddr) {
p.searchAddr = b
}
// 遍历新范围内的每个 chunk。
for c := chunkIndex(base); c < chunkIndex(limit); c++ {
// 如果 chunk 尚未分配,从操作系统中分配内存。
if p.chunks[c.l1()] == nil {
r := sysAlloc(unsafe.Sizeof(*p.chunks[0]), p.sysStat)
if r == nil {
throw("pageAlloc: out of memory")
}
*(*uintptr)(unsafe.Pointer(&p.chunks[c.l1()])) = uintptr(r)
}
// 将新分配的 chunk 标记为已清理。
p.chunkOf(c).scavenged.setRange(0, pallocChunkPages)
}
// 更新 pageAlloc 的内部状态。
p.update(base, size/pageSize, true, false)
}
整个 grow
方法的目的是在 pageAlloc
结构体需要更多内存时,动态地从操作系统中分配并准备内存区域,确保内存分配器可以继续满足内存申请的需求。其主要流程如下:
- 确保在执行
grow
方法时持有mheap
的锁,以保证线程安全,将请求的内存范围的开始和结束地址按pallocChunkBytes
对齐; - 通过
sysGrow
方法从操作系统申请新的内存区域, - 更新开始和结束地址:
- 如果是首次增长或新的开始地址小于当前的开始地址,则更新
pageAlloc
的开始地址; - 如果新的结束地址大于当前的结束地址,则更新
pageAlloc
的结束地址;
- 如果是首次增长或新的开始地址小于当前的开始地址,则更新
- 使用
inUse.add
方法将新分配的地址范围标记为正在使用;如果新的基地址小于当前的搜索地址,则更新pageAlloc
的搜索地址; - 分配和初始化
chunk
:- 遍历新分配的内存范围内的每个
chunk
; - 对于尚未初始化的
chunk
,使用sysAlloc
从操作系统中分配内存,并将其初始化; - 将新分配的
chunk
标记为已清理。
- 遍历新分配的内存范围内的每个
- 调用
update
方法更新pageAlloc
的内部状态,以反映新的内存布局。
fixalloc分配器
在heap
中,有着许多字段是fixalloc
分配器类型,例如字段:spanalloc
、cachealloc
、specialfinalizeralloc
、specialprofilealloc
、arenaHintAlloc
等,如下:
//go 1.20.3 path: /src/runtime/mheap.go
type mheap struct {
.......
spanalloc fixalloc // span*的内存分配器,只是分配空结构
cachealloc fixalloc // mcache*的内存分配器
specialfinalizeralloc fixalloc // specialfinalizer*的内存分配器
specialprofilealloc fixalloc // specialprofile*的内存分配器
arenaHintAlloc fixalloc // arenaHintAlloc 的内存分配器
......
}
在 Go
语言的运行时内存管理中,fixalloc
是一个专门用于固定大小对象的简单分配器。其核心原理是将若干未分配的内存块连接起来, 将未分配的区域的第一个字为指向下一个未分配区域的指针使用。
Go
的主分配堆中 malloc
(span
、cache
、treap
、finalizer
、profile
、arena hint
等)均 围绕它为实体进行固定分配和回收。
来看看其定义的源码结构体:
//go 1.20.3 path: /src/runtime/mfixalloc.go
type fixalloc struct {
size uintptr // 每个分配对象的大小。
first func(arg, p unsafe.Pointer) // 当分配新块时调用的构造函数。
arg unsafe.Pointer // 传递给构造函数 first 的参数。
list *mlink // 空闲对象列表,用于跟踪可用的对象。
chunk uintptr // 当前内存块的起始地址。
nchunk uint32 // 当前内存块的大小。
nalloc uint32 // 内存块中已分配对象的数量。
inuse uintptr // 当前已使用的内存总量。
stat *sysMemStat // 用于内存统计的结构体指针。
zero bool // 如果为 true,则在分配时清零内存。
}
根据结构体,我们可以得出一个大致的fixalloc
结构图:
fixalloc
分配器,非常简洁,只包含三个基本操作:初始化、分配、回收。
初始化 — fixalloc.init
fixalloc
作为内存分配器内部组件的来源于操作系统的内存,自然需要自行初始化,因此,fixalloc
的初始化也就不可避免的需要将自身的各个字段归零:
//go 1.20.3 path: /src/runtime/mfixalloc.go
_FixAllocChunk = 16 << 10
func (f *fixalloc) init(size uintptr, first func(arg, p unsafe.Pointer), arg unsafe.Pointer, stat *sysMemStat) {
// 检查 size 是否大于 _FixAllocChunk(一个预定义的最大分配大小)
// 如果是,则抛出异常,因为 fixalloc 不支持过大的对象分配。
if size > _FixAllocChunk {
throw("runtime: fixalloc size too large")
}
// 确保 size 至少为 mlink 结构体的大小。
// mlink 用于在内部自由列表中链接空闲对象。
if min := unsafe.Sizeof(mlink{}); size < min {
size = min
}
// 设置每个分配对象的大小。
f.size = size
// 设置用于初始化每个新内存块的构造函数。
f.first = first
// 设置传递给构造函数的参数。
f.arg = arg
// 初始化自由列表为 nil,表示当前没有空闲对象。
f.list = nil
// 初始化当前内存块的起始地址和大小为 0。
f.chunk = 0
f.nchunk = 0
// 初始化 nalloc 为可在 _FixAllocChunk 中分配的最大对象数。
// 这保证了每个对象都能在内存块中完整地分配。
f.nalloc = uint32(_FixAllocChunk / size * size)
// 初始化已使用的内存量为 0。
f.inuse = 0
// 设置内存统计结构体。
f.stat = stat
// 设置在分配时清零内存。
f.zero = true
}
这个 init
函数负责设置 fixalloc
结构体的各种参数和状态,为之后的固定大小对象分配做好准备。这包括设置对象大小、构造函数、内存统计以及一些内部的状态和计数器。初始化后,fixalloc
可以高效地分配和回收固定大小的内存块。
分配 — fixalloc.alloc
fixalloc
分配器分配源码如下:
//go 1.20.3 path: /src/runtime/mfixalloc.go
func (f *fixalloc) alloc() unsafe.Pointer {
// 检查是否在初始化 fixalloc 之前就调用了 alloc 方法。
// 如果是,打印错误信息并抛出异常。
if f.size == 0 {
print("runtime: use of FixAlloc_Alloc before FixAlloc_Init\n")
throw("runtime: internal error")
}
// 如果自由列表(list)不为空,从列表中分配一个对象。
if f.list != nil {
v := unsafe.Pointer(f.list) // 获取列表中的第一个对象。
f.list = f.list.next // 更新列表头指向下一个对象。
f.inuse += f.size // 更新已使用的内存量。
// 如果设置了 zero 标志,清零分配的内存。
if f.zero {
memclrNoHeapPointers(v, f.size)
}
return v // 返回分配的对象。
}
// 如果当前块的剩余空间不足以容纳一个新对象,
// 则分配一个新的内存块。
if uintptr(f.nchunk) < f.size {
f.chunk = uintptr(persistentalloc(uintptr(f.nalloc), 0, f.stat)) // 分配新块。
f.nchunk = f.nalloc // 重置 nchunk 为新块的大小。
}
// 从当前块中分配内存。
v := unsafe.Pointer(f.chunk) // 指向要分配的内存位置。
if f.first != nil {
f.first(f.arg, v) // 如果有构造函数,调用它初始化内存块。
}
f.chunk = f.chunk + f.size // 更新 chunk 为下一个分配位置。
f.nchunk -= uint32(f.size) // 更新剩余空间大小。
f.inuse += f.size // 更新已使用的内存量。
return v // 返回分配的内存。
}
fixalloc
基于自由表分为两种情况执行:
- 存在被释放、可复用的内存;
- 不存在可复用的内存
对于第一种情况,也就是在运行时内存被释放,但这部分内存并不会被立即回收给操作系统,我们直接从自由表中获得即可,但需要注意按需将这部分内存进行清零操作。
对于第二种情况,我们直接向操作系统申请固定大小的内存,然后扣除分配的大小即可。
回收 — fixalloc.free
回收的代码相当简单,不用过多解释,直接上代码:
//go 1.20.3 path: /src/runtime/mfixalloc.go
func (f *fixalloc) free(p unsafe.Pointer) {
// 减少已使用的内存量,每次释放固定大小的内存块。
f.inuse -= f.size
// 将传入的指针转换为 *mlink 类型,以便将其加入到自由列表中。
// mlink 是内部用于链接空闲内存块的结构。
v := (*mlink)(p)
// 将当前释放的内存块加入到自由列表的头部。
// 这是一个典型的链表头插法操作。
v.next = f.list
f.list = v
}
free
函数是 fixalloc
分配器的一部分,它处理固定大小内存块的释放。当一个内存块不再需要时,该函数将其加回 fixalloc
的自由列表中,以便将来重新使用。这种机制有助于减少内存分配和回收的开销,提高内存利用率。通过维护一个自由列表,fixalloc
能够高效地管理固定大小的内存块的生命周期。
linearAlloc分配器
linearAlloc
是一个内部使用的内存分配器,它以线性的方式管理内存,为 Go
运行时的某些组件提供所需的内存。由于其不支持内存回收和复杂的内存管理操作,因此它适用于生命周期长、大小固定的内存分配需求。
但由于它只作为 mheap_.heapArenaAlloc
和 mheap_.arena
在 32
位系统上使用,这里不做详细分析。
我们直接贴出源码以及注释:
//go 1.20.3 path: /src/runtime/malloc.go
type linearAlloc struct {
next uintptr // 下一个可用于分配的内存地址。
mapped uintptr // 已经映射到分配器的内存总量。
end uintptr // 管理内存区域的结束地址。
mapMemory bool // 标记是否需要映射更多内存。
}
结构图如下:
linearAlloc
的初始化以及分配函数代码如下:
//go 1.20.3 path: /src/runtime/malloc.go
func (l *linearAlloc) init(base, size uintptr, mapMemory bool) {
// 检查 base + size 是否会造成整数溢出。
// 如果会溢出,则减小 size 以避免溢出。
if base + size < base {
size -= 1
}
// 初始化 next 和 mapped 字段为 base,表示分配的起始地址。
l.next, l.mapped = base, base
// 设置 end 字段为 base + size,表示分配区域的结束地址。
l.end = base + size
// 设置 mapMemory 字段,决定是否在内存用尽时自动映射更多内存。
l.mapMemory = mapMemory
}
func (l *linearAlloc) alloc(size, align uintptr, sysStat *sysMemStat) unsafe.Pointer {
// 将 next 地址按照 align 对齐。
p := alignUp(l.next, align)
// 检查分配后的地址是否超出了分配区域的结束地址(end)。
// 如果超出,返回 nil,表示分配失败。
if p + size > l.end {
return nil
}
// 更新 next 地址为新分配的内存之后的地址。
l.next = p + size
// 对 next 地址进行向上对齐,并检查是否超出了已映射的内存(mapped)。
if pEnd := alignUp(l.next-1, physPageSize); pEnd > l.mapped {
// 如果 mapMemory 为 true,并且有更多内存需要映射,则映射更多内存。
if l.mapMemory {
n := pEnd - l.mapped
sysMap(unsafe.Pointer(l.mapped), n, sysStat) // 映射新的内存区域。
sysUsed(unsafe.Pointer(l.mapped), n, n) // 更新内存使用统计。
}
// 更新 mapped 字段为新映射的内存的结束地址。
l.mapped = pEnd
}
// 返回分配的内存地址。
return unsafe.Pointer(p)
}
小结
本文将内存管理的组件heapArena
、mheap
、mspan
、mcentral
、mcache
都做了基本的解析,能够大致知道这些组件的基本构成,但我们还没涉及到这些组件之间的关系以及组件的初始化和堆内存的分配以及分配的流程相关内容,下篇文章将重点介绍这些。
参考资料:
小徐先生 https://zhuanlan.zhihu.com/p/603335718
冰心丹 https://zhuanlan.zhihu.com/p/410317967
Go语言原本 https://golang.design/under-the-hood/zh-cn/
chatgpt https://chat.openai.com/