GoLang之堆内存系列二(堆内存分配mallocgc)

文章目录

  • 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扫描的进度更新计算的,绝对合理!

GoLang之堆内存系列二(堆内存分配mallocgc)_第1张图片

不过既然都开始辅助GC了,也不能白折腾一场,切换来切换去的好麻烦~
所以每次执行辅助GC,最少要扫描64KB。
先不要替那些申请小块内存的协程感到不公平,因为协程每次执行辅助GC,多出来的部分会作为信用存储到当前G中,就像信用卡的额度一样,后续再执行mallocgc()时,只要信用额度用不完,就不用执行辅助GC了~

GoLang之堆内存系列二(堆内存分配mallocgc)_第2张图片

此外,还有一种偷懒的办法来逃避辅助GC的责任,那就是:窃取信用
后台的GC mark worker执行扫描任务,会在全局gcController的bgScanCredit这里积累信用。如果能够窃取足够多的信用值来抵消当前协程背负的债务,那也就不用执行辅助GC了~

GoLang之堆内存系列二(堆内存分配mallocgc)_第3张图片

过了辅助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
}

GoLang之堆内存系列二(堆内存分配mallocgc)_第4张图片

大于32KB的大块内存额外处理,这很好理解。因为预置的内存规格最大才32KB,所以会直接根据需要的页面数分配一个新的span。
而对于小于16字节的内存分配,也不直接匹配预置内存规格,主要是为了减少浪费~
如果需要连续分配16次1字节的内存,每次分配时匹配预置的内存规格8字节(这是最小的了),那么每次都会浪费7字节:

GoLang之堆内存系列二(堆内存分配mallocgc)_第5张图片

GoLang之堆内存系列二(堆内存分配mallocgc)_第6张图片

而tiny allocator能够将几个小块的内存分配请求合并,所以16次1字节的内存分配请求可以合并到一个16字节的内存块中:
诸如此类,可以提高内存利用率~

GoLang之堆内存系列二(堆内存分配mallocgc)_第7张图片

那tiny allocator从哪里分配内存呢?
每个P的mcache这里有专门用于tiny allocator的内存(mcache.tiny),这是一个16字节大小的内存单元,mcache.tinyoffset记录这段内存已经用到哪里了:


type mcache struct {
  nextSample uintptr
  scanAlloc  uintptr
  tiny       uintptr
  tinyoffset uintptr
  tinyAllocs uintptr
  alloc      [numSpanClasses]*mspan
  stackcache [_NumStackOrders]stackfreelist
  flushGen   uint32
}

如果tiny allocator要分配size大小的内存空间,而mcache中的tinyoffset经对齐后还够分配size大小的内存,就在tiny内存块中直接分配:

GoLang之堆内存系列二(堆内存分配mallocgc)_第8张图片

如果剩余的空间不够了,就从当前P的mcache中找到对应的mspan,重新拿一个16字节大小的内存块过来用;
如果本地缓存中相应规格的mspan也没有空间了,就会从mcentral中拿一个新的mspan过来。分配完以后,如果新拿来的内存块的剩余空间比旧内存块的剩余空间大,那就用新的内存块把旧的tiny替换掉。

GoLang之堆内存系列二(堆内存分配mallocgc)_第9张图片

这就是tiny allocator的大致工作过程。
至于最后一种,直接通过本地mcache与全局mcentral配合工作,获取规格匹配的mspan即可~
空间分配好了还没完,总要记录下哪些内存已分配,哪些数据需要GC扫描,才好继续内存管理工作吧?

GoLang之堆内存系列二(堆内存分配mallocgc)_第10张图片

4.第三部分:位图标记

我们已经介绍过堆内存管理中主要的位图标记,不过,要标记一个内存块,就要先找到它对应的位图标记在哪儿。所以,这一次我们需要梳理一下:
通过一个堆内存地址,如何找到对应的heapArena和mspan~
让我们来做几道数学题!

第一题:

已知:一个堆内存地址p,arena区域起始地址为arenaBaseOffset,每个arena大小为heapArenaBytes。
求:p处在第几个arena中?
答案:arena编号= (p - arenaBaseOffset) / heapArenaBytes

GoLang之堆内存系列二(堆内存分配mallocgc)_第11张图片

第二题:

已知:amd64架构的Linux环境下,一个arena大小和对齐边界都是64M(26位),而虚拟地址空间中的线性地址有48位,那48位的线性地址可以寻址的虚拟地址空间,就是2的48次方这么大。
求:这么大空间可以划分成多少个arena?
答案:4M个(22位)

GoLang之堆内存系列二(堆内存分配mallocgc)_第12张图片

如果我们直接把所有heapArena的地址放到一个数组中,并用arena编号作为索引来定位heapArena,那这个数组得有32MB,似乎 还可以接受~

GoLang之堆内存系列二(堆内存分配mallocgc)_第13张图片
GoLang之堆内存系列二(堆内存分配mallocgc)_第14张图片

但是在amd64架构的Windows环境下,一个arena大小只有4M,那么整个虚拟地址空间一共可以划分为64M个arena(26位)。
若是仍然采用arena编号来索引,那这个数组得有512MB,这就有点儿接受无能了!
所以,Go的开发者把heapArena的地址存到了一个二维数组里:

GoLang之堆内存系列二(堆内存分配mallocgc)_第15张图片

GoLang之堆内存系列二(堆内存分配mallocgc)_第16张图片

寻址heapArena时,也不能直接使用arena的编号了。而是根据arena编号计算出一个arenaIdx(其实就是arenIndex简写了),它本质上是个uint,只不过分两部分,分别作为两个维度的索引:

GoLang之堆内存系列二(堆内存分配mallocgc)_第17张图片

在amd64架构的Windows环境下:
(1)arenas数组第一维有64个元素,所以arenaIdx第一维索引占6位;
(2)第二维数组长度为1M(20位),所以arenaIdx中低20位用作第二维的索引。
注:2^20*2^6=2^26

GoLang之堆内存系列二(堆内存分配mallocgc)_第18张图片

但在amd64架构的Linux环境下:
这个arenas数组第一维只有一个元素,第二维有4M个元素,arenaIdx的低22位都用做第二维的索引,本质上和直接用arena编号是一样的~

GoLang之堆内存系列二(堆内存分配mallocgc)_第19张图片

到这里,总算是能够根据内存地址找到对应的heapArena了~
剩下的就是找mspan了!

让我们来看最后一题:

已知:arena中每个page大小为pageSize,每个arena中有pagesPerArena个page。
求:p处在这个arena中第几个page?
答案:page编号= (p / pageSize) % pagesPerArena
确定了page的索引,就能在heapArena.spans数组中找到对应的mspan了~

GoLang之堆内存系列二(堆内存分配mallocgc)_第20张图片

不过标记完了也还不能结束…

5.第四部分:收尾工作

这包括多个操作,我们只提要点:
(1)判断如果处在GC的标记阶段就标记新分配的对象;
(2)在memory profile开启的情况下,每分配nextSample字节内存以后,就进行一次采样;
(3)在分配的过程中,size可能是向上对齐过的,所以可能会变大。而dataSize保存了原来真实的size值,所以要从分配内存的goroutine的gcAssistBytes中减去因size对齐而额外多分配的大小;
(4)最后检测如果达到了GC的触发条件,就发起GC。

GoLang之堆内存系列二(堆内存分配mallocgc)_第21张图片

6.总结

这一次我们了解了负责分配内存的mallocgc()函数的主要逻辑,期间介绍了:
(1)辅助GC;
(2)三种内存分配策略;
(3)从内存地址定位到heapArena和mspan的过程。
希望能帮助大家get到堆内存分配的大致框架~

你可能感兴趣的:(GoLang底层,golang)