GO堆内存管理

Go语言的runtime将堆内存空间分配为一个一个的arena。每个arena的起始地址被定义为常量arenaBaseOffset。在amd64架构的linux环境下,每个arena的大小是64MB,每个arena包含8192个page,每个page大小为8KB。

随着程序运行的过程中,所需要的内存块不固定,有大有小。大小不一的碎块化内存,会导致内存使用率低的问题,另一方面找到大小合适的内存块的代价会因碎片化而增加。为降低碎片化内存给程序性能造成的不良影响。Go语言的堆分配采用了与tcmalloc内存分配器类似的算法。简言之就是将内存页按照预置的大小规格划分成块,然后将不同规格的内存块放入对应的空闲链表中。程序分配内存时,内存分配器会根据要申请的内存大小,找到最匹配的规格,然后从对应空闲链表中分配一个内存块。Go中1.16 runtime包中给出了67种预置的大小规格,最小为8B~最大32KB。所以在划分的arena里又会按需划分出不同的span,每个span中包含一组连续的page,并且按照特定规格划分成等大的内存块(arena、span、page、内存块)。

在堆内存之外,有一大票用于管理堆内存的数据结构。mheap用于管理整个堆内存,一个arena对应一个heapArena结构,一个span对应一个mspan结构。

mheap有一个全局的mspan管理中心,它是一个长度为136的数组,每个元素是一个mcentral结构加上一个padding。mcentral对应一种mspan规格,记录在mcentral.spanclass属性字段中,spanclass高7位标记内存块大小规格编号(1~67位对应[8B,32KB],编号0对应大于32KB的大块内存)。最低1位标记是否需要GC扫描,包含指针的需要GC扫描置为0(scannable),不含指针的置为1(noscan)。mcentral.partial和mcentral.full分别管理未用尽和已用尽的mspan,每个又会放到两个并发安全的spanSet中,代表已清扫和未清扫。

mheap的全局的mspan管理中心方便获取各种大小规则的mspan。但是为了保障多个P之间并发安全,免不了频繁加锁、解锁,为降低多个P之间的竞争性,Go语言的每个P都有一个本地的小对象缓存p.mcache,在这里取用就无需加锁操作。p.mcache.alloc为长度为136的*mspan数组,p.mcache.tiny为专门用于分配小于16字节的noscan类型的tiny内存。当程序申请内存时,首先在本地P的mcache本地缓存去找对应的mspan,如果未找到或已用尽,那么就去mcentral.partial获取一个放到本地,把已用尽的归还到mcentral.full中去。

heapArena结构中存储着arena的元数据,里面有一群位图标记。

heapArena.bitmap:用1位标记这个arena中一个指针大小的内存单元是标量还是指针(0代表标量,1代表指针),再用1位来标记这块内存空间的后续单元是否包含指针(0终止,不包含,1扫描,包含)。为了便于操作,bitmap中用1字节标记arena中的4个指针大小的内存空间,低4位用户标记指针/标量,高4位用于标记扫描/终止。

heapArena.pageInUse:是一个uint8类型的数组,长度为1024,所以一共8192位。实际上不是标记哪个页面被使用了。但实际上,这个位图只标记处于使用状态(mSpanInUse)的span的第一个page。例如arena中第一个使用状态的span包含两个page,对应pageInUse中第0位标记为1,第二个span也处于使用状态,它包含三个page,但只有第一个page对应的第2位会被标记为1。

heapArena.pageMarks:用法和heapArena.pageInUse一样,只标记每个span的第一个page。用于GC标记。在GC标记阶段会修改这个位图,标记哪些span中存在被标记的对象,在GC清扫阶段会根据这个位图,来释放不含标记对象的span。

heapArena.spans:是一个*mspan类型的数组,大小为8192,正好对应arena中的8192分page。用于定位一个page对应的mspan在哪里。一个mspan管理着一组连续的page,同mcentral一样,将划分的内存块规格类型记录在mspan.spanclass中。mspan.nelem记录着当前span共划分成了多少个内存块。mspan.freeIndex记录着下一个空闲内存块的索引。与heapArena不同的,mspan这里的位图标记是面向划分好的内存块单元,mspan.allocBits用于标记哪些内存块已经被分配了。mspan.gcmarkBits是当前span的标记位图,在GC标记阶段会对这个位图进行标记,一个二进制位对应span中的一个内存块,到GC清扫阶段会释放掉旧的allocBits,然后把标记好的gcmarkBits用作allocBits,这样未被GC标记的内存块就能回收利用了。当然会重新分配一段清零的内存给gcmarkBits位图。

mallocgc是负责堆内存分配的关键函数。runtime中的new和make函数都依赖它。主要逻辑可以分为四个部分。

1、辅助GC

每次执行辅助GC,至少要扫描64KB。协程每次执行辅助GC,多出来的部分会作为信用存储到当前G中,就像信用卡的额度一样,后续再在执行mallocgc()时,只要信用额度用不完(gcAssistBytes),就不用执行辅助GC了。此外还有一种偷懒的办法,来逃避辅助GC的责任,那就是窃取信用,后台的GC mark worker执行扫描任务会在全局gcController这里(bgScanCredit)积累信用,如果能够窃取足够多的信用值,来抵消当前协程背负的债务,那也不用执行辅助GC了。

2、空间分配

根据空间分配大小以及是否是noscan类型空间选择不同的分配策略。

  • 小于16B且是noscan类型的内存分配请求会使用“tiny allocator”。
  • 大于32KB的内存分配包括noscan和scannable类型,都会采用大块内存分配器。
  • 其余大于等于16KB且小于等于32KB的noscan类型或小于等于32KB的scannable类型都会直接匹配预置的大小规格来分配。

大于32KB的大块内存额外处理,因为预置的内存规格最大才32KB,所以直接根据需要的页面数,分配一个新的span。

而对于小于16字节的内存分配,也不直接匹配预置内存规格,主要是为了减少浪费,如果需要连续分配16次1字节的内存,每次分配时匹配预置的内存规格8字节(这里最小的了)那么每次都会浪费7字节。而tiny allocator能够将几个小块的内存分配请求合并,所以16次1字节的内存分配请求可以合并到一个16字节的内存块中。诸如此类,可以提高内存使用率。那么tiny allocator从哪里分配内存呢?上文提到,每个P有专门用于tiny allocator的内存(mcache.tiny这是一个16字节大小的内存单元,mcache.tinyoffset记录这段内存已经用到哪里了)。如果不够分配,就从当前P的mcache种找到对应的mspan重新拿一个16字节大小的内存块过来用,如果本地缓存中响应规格的mspan也没有空间了,就会从mcentral中拿一个新的mspan过来,分配完以后,如果新拿过来的内存块剩余空间比旧内存块的剩余空间还大,那就用新的内存块把旧的tiny替换掉。

其余情况,直接通过本地mcache与全局mcentral配合工作,找到匹配规格的mspan即可。

3、位图标记

分配内存后,还没完,要记录那些内存已分配,那些数据需要GC扫描。才好继续内存管理工作。

要标记一个内存块,就要先找到对应的位图标记在哪里。接下来要梳理一下,通过一个堆内存地址,如何找到对应的heapArena和mspan。

已经一个堆内存地址p。arena编号=(p-arenaBaseOffset)/ heapArenaBytes

Go的开发者,把heapArena的地址存储到了一个二维数组中,寻址heapArena时候,不直接使用编号,而是根据arean编号计算出一个arenaIdx,本质上是一个uint,只不过分为两部分,分别作为两个维度的索引,找到heapArena后,在继续找mspan。

page编号=(p/pageSize)% pagesPerArena。确定了page的索引就可以在heapArena.spans数组中找到对应mspan的地址了。

4、收尾工作

如果当前处于GC标记阶段,就需要对新分配的对象进行标记。而且,如果此次内存分配达到了GC的触发条件,还要触发新一轮的GC。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值