文章目录
- GoLang之堆内存系列二(堆内存分配mallocgc)
-
- 1.堆内存分配mallocgc介绍
- 2.第一部分:辅助GC
- 3.第二部分:空间分配
- 4.第三部分:位图标记
- 5.第四部分:收尾工作
- 6.总结
GoLang之堆内存系列二(堆内存分配mallocgc) 注:本文基于Windos系统上Go SDK v1.16进行讲解
1.堆内存分配mallocgc介绍
mallocgc是负责堆分配的关键函数,runtime中的new系列和make系列函数都依赖它。它的主要逻辑可以分成四个部分:2.第一部分:辅助GC
如果程序申请堆内存时正处于GC标记阶段,那么,当下已分配的堆内存还没标记完,你这边又要分配新的堆内存。万一内存申请的速度超过了GC标记的速度,那就不妙了~
所以,申请一字节内存空间需要做多少扫描工作,或者说,完成一字节扫描工作后,可以分配多大的内存空间,那都是根据GC扫描的进度更新计算的,绝对合理!
文章图片
不过既然都开始辅助GC了,也不能白折腾一场,切换来切换去的好麻烦~
所以每次执行辅助GC,最少要扫描64KB。
先不要替那些申请小块内存的协程感到不公平,因为协程每次执行辅助GC,多出来的部分会作为信用存储到当前G中,就像信用卡的额度一样,后续再执行mallocgc()时,只要信用额度用不完,就不用执行辅助GC了~
文章图片
此外,还有一种偷懒的办法来逃避辅助GC的责任,那就是:窃取信用
后台的GC mark worker执行扫描任务,会在全局gcController的bgScanCredit这里积累信用。如果能够窃取足够多的信用值来抵消当前协程背负的债务,那也就不用执行辅助GC了~
文章图片
过了辅助GC这一关,就进入到"第二部分:空间分配"3.第二部分:空间分配
这里就需要根据要分配的空间大小,以及是否为noscan型空间来选择不同的分配策略了。先来看一下是如何选择策略的:
maxSmallSize是32KB,maxTinySize等于16B。也就是说:
(1)小于16字节,而且是noscan类型的内存分配请求,会使用tiny allocator;
(2)大于32KB的内存分配,包括noscan和scannable类型,都会采用大块内存分配器;
(3)剩下的,大于等于16B且小于等于32KB的noscan类型;以及小与等于32KB的scannable类型的分配请求,都会直接匹配预置的大小规格来分配。
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 使用tiny allocator分配
} else {
// 使用mcache.alloc中对应的mspan分配
}
} else {
// 直接根据需要的页面数,分配大的mspan
}
文章图片
大于32KB的大块内存额外处理,这很好理解。因为预置的内存规格最大才32KB,所以会直接根据需要的页面数分配一个新的span。
而对于小于16字节的内存分配,也不直接匹配预置内存规格,主要是为了减少浪费~
如果需要连续分配16次1字节的内存,每次分配时匹配预置的内存规格8字节(这是最小的了),那么每次都会浪费7字节:
文章图片
文章图片
而tiny allocator能够将几个小块的内存分配请求合并,所以16次1字节的内存分配请求可以合并到一个16字节的内存块中:
诸如此类,可以提高内存利用率~
文章图片
那tiny allocator从哪里分配内存呢?
每个P的mcache这里有专门用于tiny allocator的内存(mcache.tiny),这是一个16字节大小的内存单元,mcache.tinyoffset记录这段内存已经用到哪里了:
type mcache struct {
nextSample uintptr
scanAllocuintptr
tinyuintptr
tinyoffset uintptr
tinyAllocs uintptr
alloc[numSpanClasses]*mspan
stackcache [_NumStackOrders]stackfreelist
flushGenuint32
}
如果tiny allocator要分配size大小的内存空间,而mcache中的tinyoffset经对齐后还够分配size大小的内存,就在tiny内存块中直接分配:
文章图片
如果剩余的空间不够了,就从当前P的mcache中找到对应的mspan,重新拿一个16字节大小的内存块过来用;
如果本地缓存中相应规格的mspan也没有空间了,就会从mcentral中拿一个新的mspan过来。分配完以后,如果新拿来的内存块的剩余空间比旧内存块的剩余空间大,那就用新的内存块把旧的tiny替换掉。
文章图片
这就是tiny allocator的大致工作过程。
至于最后一种,直接通过本地mcache与全局mcentral配合工作,获取规格匹配的mspan即可~
空间分配好了还没完,总要记录下哪些内存已分配,哪些数据需要GC扫描,才好继续内存管理工作吧?
文章图片
4.第三部分:位图标记
我们已经介绍过堆内存管理中主要的位图标记,不过,要标记一个内存块,就要先找到它对应的位图标记在哪儿。所以,这一次我们需要梳理一下:第一题:
通过一个堆内存地址,如何找到对应的heapArena和mspan~
让我们来做几道数学题!
已知:一个堆内存地址p,arena区域起始地址为arenaBaseOffset,每个arena大小为heapArenaBytes。
求:p处在第几个arena中?
答案:arena编号= (p - arenaBaseOffset) / heapArenaBytes
文章图片
第二题:
已知:amd64架构的Linux环境下,一个arena大小和对齐边界都是64M(26位),而虚拟地址空间中的线性地址有48位,那48位的线性地址可以寻址的虚拟地址空间,就是2的48次方这么大。
求:这么大空间可以划分成多少个arena?
答案:4M个(22位)
文章图片
如果我们直接把所有heapArena的地址放到一个数组中,并用arena编号作为索引来定位heapArena,那这个数组得有32MB,似乎 还可以接受~
文章图片
文章图片
但是在amd64架构的Windows环境下,一个arena大小只有4M,那么整个虚拟地址空间一共可以划分为64M个arena(26位)。
若是仍然采用arena编号来索引,那这个数组得有512MB,这就有点儿接受无能了!
所以,Go的开发者把heapArena的地址存到了一个二维数组里:
文章图片
文章图片
寻址heapArena时,也不能直接使用arena的编号了。而是根据arena编号计算出一个arenaIdx(其实就是arenIndex简写了),它本质上是个uint,只不过分两部分,分别作为两个维度的索引:
文章图片
在amd64架构的Windows环境下:
(1)arenas数组第一维有64个元素,所以arenaIdx第一维索引占6位;
(2)第二维数组长度为1M(20位),所以arenaIdx中低20位用作第二维的索引。
注:2^20*2^6=2^26
文章图片
但在amd64架构的Linux环境下:
这个arenas数组第一维只有一个元素,第二维有4M个元素,arenaIdx的低22位都用做第二维的索引,本质上和直接用arena编号是一样的~
文章图片
到这里,总算是能够根据内存地址找到对应的heapArena了~让我们来看最后一题:
剩下的就是找mspan了!
已知:arena中每个page大小为pageSize,每个arena中有pagesPerArena个page。
求:p处在这个arena中第几个page?
答案:page编号= (p / pageSize) % pagesPerArena
确定了page的索引,就能在heapArena.spans数组中找到对应的mspan了~
文章图片
不过标记完了也还不能结束…5.第四部分:收尾工作
这包括多个操作,我们只提要点:
(1)判断如果处在GC的标记阶段就标记新分配的对象;
(2)在memory profile开启的情况下,每分配nextSample字节内存以后,就进行一次采样;
(3)在分配的过程中,size可能是向上对齐过的,所以可能会变大。而dataSize保存了原来真实的size值,所以要从分配内存的goroutine的gcAssistBytes中减去因size对齐而额外多分配的大小;
(4)最后检测如果达到了GC的触发条件,就发起GC。
文章图片
6.总结
【GoLang底层|GoLang之堆内存系列二(堆内存分配mallocgc)】这一次我们了解了负责分配内存的mallocgc()函数的主要逻辑,期间介绍了:
(1)辅助GC;
(2)三种内存分配策略;
(3)从内存地址定位到heapArena和mspan的过程。
希望能帮助大家get到堆内存分配的大致框架~
推荐阅读
- Go高级工程师实战营资料齐全
- Go微服务入门到容器化实践,落地可观测的微服务电商项目资料齐全
- golang|golang打造p2p网络
- GoLang|GoLang使用sync.Once
- golang|Golang指针的应用场景理解
- 程序人生|互联网让我的人生逆袭
- redis数据结构附录
- Golang笔记|【记录】go mod命令 & go.mod 文件解析
- GoLang|GoLang之启动goroutine、sync.WaitGroup