原文: GO内存分配与GC
Thread-Caching Malloc
TCMalloc是谷歌公开的一种内存管理与分配的方式,它的特点是能在本地快速分配某些对象,降低对共享内存的访问,从而降低内存分配过程中对锁的竞争,提升内存分配效率
Golang的内存分配是基于TCMalloc模型实现的,理解TCMalloc对理解Golang内存分配至关重要,这里简要说明一下TCMalloc的内存分配机制
Page与Span
TCMalloc中,内存以Span(跨度)进行管理,每个Span包含1个到多个Page页,Page是内存的最基本单位,每个Page的大小为4KB
小对象与大对象
TCMalloc中,将尺寸小于等于32KB的对象称为小对象,对于尺寸大于32KB的对象称为大对象,它们有不同的分配方式
小对象在分配内存时会将一个Page页进行切割以存储多个对象,例如用于存储8Bytes的小对象的数据页,它将切割出512个存储区
大对象在存储时以页面对齐并且占据整数的页,一个33KB的对象在存储时它会占用9个数据页
在TCMalloc模型中,内存的分配管理有三种模型,以下分别介绍
ThreadCache
第一种是ThreadCache也即线程本地缓存,该缓存适用于一些固定尺寸的对象分配
ThreadCache以一个固定的映射关系分配不同大小的对象,每个大小的级别称为一个Class,在TCMalloc的相关介绍中并未找到对Class映射表的约定,这里需要找源码去分析了,以后补充,不过据介绍,大约存在170余个的不同Class
对象的大小基本遵循以下规则: 以存储8Bytes最小对象的Class为起始,每个Class比前一个Class存储的对象大小按照8B、16B、32B、64B等等的间隔递增,最大的间隔为256B,例如下表简要介绍了递增关系
Class级别 | Bytes大小 | 单个Page可分配的数量 | 尾部浪费的Bytes |
---|---|---|---|
1 | 8 | 512 | 0 |
2 | 16 | 256 | 0 |
3 | 24 | 170 | 16 |
4 | 32 | 128 | 0 |
5 | 48 | 85 | 16 |
6 | 64 | 64 | 0 |
... | ... | ... | ... |
所有的Class以该Class的级别编号作为索引保存在一个List中,每个Class都是一个Span的链表,链表中每个Span根据Class的级别会存储不同数量的Page页,并将这些页的总内存切分为该Class对应的大小的对象
分配时将待分配的对象大小与映射表对齐,找到需要的Class后从该Class的链表中弹出表头节点进行存储,然后将该对象从链表的头部删除,在释放一个对象时,将该内存对象加入到该Class的表尾
当ThreadCache中某个Class的空闲空间不足时,将会向CenterCache批量申请新的内存加入到该Class的空闲链表中,当某个Class的空闲内存超出了预订大小(2MB)时,将会通过垃圾搜集器将多于的空闲内存归还到CenterCache中
在此ThreadCache中,内存的分配都是发生在每个线程的内部,因此这些内存的访问不需要加锁,也是内存分配速度最快的
CenterCache
第二种是CenterCache也即全局中心缓存,该缓存与ThreadCache一样仅适用于一些固定Class的对象分配,不过该缓存是全局的,对该缓存的访问需要加锁
当ThreadCache本地的存储空间不足以分配新的内存时,将对CenterCache加锁,然后从CenterCache中批量获取该尺寸的一批连续的内存到ThreadCache中,然后继续分配本地内存的分配过程
当CenterCache中空闲内存不足时,将向PageHeap申请新的空闲内存,并切割为需要的尺寸存放到空闲空间中
释放时也是通过批量的方式将空闲内存归还到PageHeap中,内存的释放归还由垃圾收集器负责
PageHeap
第三种是PageHeap也即页堆,该缓存是全局的,对PageHeap的访问需要加锁,PageHeap管理着整个程序的全部内存
PageHeap对内存的管理方式与ThreadCache类似,其以Page的页数为索引保存在一个List中,List中每个索引存储的是一个Span的链表,每个Span保存了对应的页数,例如索引为1的Span链表中,每个Span包含一个Page,索引为2的Span链表中,每个Span包含两个Page,以此类推,最大的连续Page为255个(没有包含0个Page的Span链表,所以List的0号索引不用,List最多为256个元素,因此最大的连续页数为255)
所有的大对象分配将会直接分配在PageHeap中,分配时计算该对象使用的页数然后从对应的Span链表中取出一个Span返回给程序进行内存分配
PageHeap的内存由垃圾收集器进行管理
垃圾收集
当ThreadCache中的缓存大小达到阀值时(默认为2MB)将对该缓存执行垃圾清理,该阀值会随着线程数量的增多而下降,以免在大量线程的情况下浪费内存
在进行垃圾收集时将会根据ThreadCache的历史内存操作简单测算未来可能出现的内存访问情况,对不同的Class执行不同的管理,从未用到的Class空闲空间将会被回收放入CenterCache中以供其他线程使用,经常分配和释放的Class将会保留更长的空闲链表以避免过多的对CenterCache进行访问
Golang内存管理与GC(Ver: 1.17)
Golang中的内存管理基于TCMalloc模型,因此其管理单位与TCMalloc模型一致,都使用Page与Span的概念进行管理,因此理解TCMalloc模型也就基本理解了Golang的内存管理模式,具体的差异不过是对一些概念的微调以及具体实现过程中客制化的内容,以下介绍在Golang中有区别的地方
微对象、小对象与大对象
在Golang中,对象的分配更进一步细分为微对象、小对象以及大对象
将不含有指针且大小小于16Byte的对象称为微对象
大小小于等于32KB且不属于微对象的对象称为小对象
大于32KB的对象称为大对象
微对象大小的选择并不是最小的8Bytes,选择16是有意义的,在runtime/malloc.go:987
有对此的注释描述
Tiny allocator.
Tiny allocator combines several tiny allocation requests into a single memory block. The resulting memory block is freed when all subobjects are unreachable. The subobjects must be noscan (don't have pointers), this ensures that the amount of potentially wasted memory is bounded.
Size of the memory block used for combining (maxTinySize) is tunable.
Current setting is 16 bytes, which relates to 2x worst case memory wastage (when all but one subobjects are unreachable).
8 bytes would result in no wastage at all, but provides less opportunities for combining.
32 bytes provides more opportunities for combining, but can lead to 4x worst case wastage.
The best case winning is 8x regardless of block size.Objects obtained from tiny allocator must not be freed explicitly.
So when an object will be freed explicitly, we ensure that its size >= maxTinySize.SetFinalizer has a special case for objects potentially coming from tiny allocator, it such case it allows to set finalizers for an inner byte of a memory block.
The main targets of tiny allocator are small strings and standalone escaping variables. On a json benchmark the allocator reduces number of allocations by ~12% and reduces heap size by ~20%.
小对象的映射表
Golang中对于小对象的Class有明确的映射,这里做一下简要的介绍,完整的映射表见runtime/sizeclasses.go
Class(级别) | Bytes(每个对象) | Bytes(Span大小) | 包含的对象数量 | 末尾浪费的字节大小 | 最多会产生多少浪费 |
---|---|---|---|---|---|
1 | 8 | 8192 | 1024 | 0 | 87.5% |
2 | 16 | 8192 | 512 | 0 | 43.75% |
3 | 24 | 8192 | 341 | 8 | 29.24% |
4 | 32 | 8192 | 256 | 0 | 21.88% |
5 | 48 | 8192 | 170 | 32 | 31.52% |
... | ... | ... | ... | ... | ... |
18 | 256 | 8192 | 32 | 0 | 5.86% |
19 | 288 | 8192 | 28 | 128 | 12.16% |
... | ... | ... | ... | ... | ... |
35 | 1408 | 16384 | 11 | 896 | 14.00% |
... | ... | ... | ... | ... | ... |
67 | 32768 | 32768 | 1 | 0 | 12.50% |
微对象与小对象的内存分配
微对象与小对象的分配都是在ThreadCache上,ThreadCache在Golang中对应的是mcache
结构体,其定义在runtime/mcache.go
中
mcache
结构体维护了136(索引0-67一共68个乘以2)个Class的数组,这里数组的数量是Class级别数量的两倍
这是因为在Golang中,每个级别都有两个Span列表用于存储,有指针与无指针的小对象是分别存储的,计算时将该Class级别索引位置向左移一位(乘2),然后如果不含指针将其与1做位或运算(加1),例如级别为67的Class,在获取Span链表时,其Class宿主中的位置为(67<<1)|int(noscan)
,mcache
结构中还维护了内存分配的状态与统计信息
微对象不含指针,在分配时固定使用一个Span列表来进行内存分配操作,其索引是tinySpanClass
常量(当前是5,也就是Class级别为2且不含指针)指定的,mcache
结构中维护了当前获取的Span块中已分配的对象偏移量,微对象分配时首先对已分配的Span块偏移量进行向上对齐,以确保之后分配的内存都是对齐的
然后会检查即将分配的大小与当前偏移量相加是否大于微对象内存块的上限,也就是16Bytes,如果是那么获取一个新的块来进行分配并更新mcache
的统计信息,否则将在已有的数据块上进行对象的内存分配,然后更新mcache
的统计信息
小对象的分配则直接对分配的内存大小向上对齐并计算其所属的Class,通过size_to_class8
或size_to_class128
来获取该对象大小对应的Class级别,然后会根据该对象是否含有指针来计算该Class的索引位置
大对象的内存分配
由上边小对象的分配可知,最小级别的小对象在分配时,对应的Class列表索引最小是2(Class级别为1的索引为1,计算时为1 << 1 | int(noscan))
因此Class列表的前两个索引被用来作为大对象分配时的Span链表,mcache
在分配大对象时根据是否含有指针将其索引映射为Class列表中0或1的Span链表
并直接从PageHeap也即Golang中的mheap
上直接分配内存,不过这里分配内存之后并不是将分配的Span加入到mcache
的Class列表中,而是直接加入到CenterCache也即Golang中的mcenter
中对应Class级别的Span列表中,以便垃圾收集期间对齐进行垃圾清理
GC原理
垃圾收集涉及的内容是方方面面的,其贯穿程序的整个生命周期以及所有与内存相关联的组件,以下仅整理Golang中垃圾收集的过程,不涉及GC的具体实现代码,关于实现以后再单独写文章进行记录
Golang的垃圾收集器是逐渐演进的,这里不对历史进行追溯,在当前版本,垃圾收集器使用三色标记法,并且通过混合写屏障(插入与删除)来保证并发垃圾收集的性能与内存安全性
对于三色标记法以及写屏障技术这里不进行展开,对此进行介绍的文章很多,这里仅研究Golang的GC触发时机、各种阶段的任务、GC时的内存分配等等
其基本过程是启动GC时进行一些状态检查以及准备工作,然后开始与用户程序并行执行进行内存状态的标记,标记过程中会打开内存屏障,以保证新创建的对象不会被错误的清理,当标记完成后进入清理阶段,该阶段与用户程序并行
在Golang的GC过程中会触发两次STW,均发生在GC状态变更的时候
GC的触发时机
Golang中在以下情况会触发GC测试,测试的条件有三种类型
- 堆内存大小测试(若当前堆活动内存大于等于上次GC时控制器计算的控制大小)
- 时间测试(若最后一次GC时间距离上一次GC已经经过设定时间,默认时间为2分钟)
- GC周期数测试(若新开始的周期数大于当前已经进行的周期数)
测试时选择一种条件进行测试,若条件满足则启动新一轮的GC,在以下几种情况中会进行GC测试
- 在处理新的微、小对象内存分配请求时,若
mcache
内存不足,向mcenter
申请新的内存时,会触发GC测试,该测试的条件是堆内存大小测试 - 在处理新的大对象内存分配请求时,一定会触发GC测试,测试条件是堆内存大小测试
- Golang的系统监控线程会检查系统的状态,尝试进行GC,该测试条件是时间测试
另外用户可以还可以通过runtime.GC
函数主动发起一次GC,该情况会强制启动GC(通过将开始的GC周期数+1),启动时若已在GC过程中,将中断该GC过程并开始一轮新的GC
GC的各个阶段以及阶段任务
Golang中GC有三种状态,可以分为四个阶段任务,状态有下列三种
-
_GCoff
该状态GC没有在运行,此状态下写屏障没有开启 -
_GCmark
该状态GC正在执行标记工作,此状态下写屏障会被开启 -
_GCmarktermination
该状态GC正在执行标记工作,此状态写写屏障会被开启,程序会进入STW
由三种状态的切换可以将GC分为四个阶段任务
首先是启动阶段,该阶段GC状态为_GCoff
,在启动阶段将会进行一些GC启动前的准备工作,包括:
- 当前处理器P与协程G是否满足一些条件,判定的条件还未细看,待补充
- 获取GC需要的锁,并在获取锁后再次检查是否满足GC启动条件,若不满足则结束本次GC
- 检查所有处理器P的
mcache
的延迟刷新缓存是否已经刷新,该检查属于状态一致性检查,若系统内状态不满足要求那么程序直接通过panic
结束 - 创建出用于执行GC扫描的协程G,但此时这些G不会被启动,会在扫描阶段启动
- 重置系统状态: 重置所有G的GC状态、重置堆内存的标记扫描状态、重置GC工作的一些状态
- 进入STW
- 将GC状态更改为
_GCmark
,开启内存屏障,扫描所有的根对象并标记,将所有微对象标记为黑色 - 恢复STW
- 释放锁
第二个阶段是后台标记阶段,后台标记阶段将在STW恢复后开始,此时标记扫描工作将与程序并行执行,上一个阶段创建的用于后台处理标记的G会被唤醒,内存的标记工作将由这些G来执行
后台标记的任务数量与GOMAXPROC的数量相等,但并非所有的处理器P都会被用于执行标记任务,可用于执行标记任务的处理器数量是全部处理器数量的一定比例,其默认为百分之25,若开启了debug模式则其数值等于GOMAXPROC,在计算专用处理器P的数量时会进行四舍五入的取整操作,若GC处理时的性能分数仍然达不到百分之25的要求,那么会临时征用其他的处理器P处理标记工作
对于标记任务的调度要优先于其他任务,其过程是在创建后台标记任务时,标记任务将自己加入到后台标记工作池中,之后自身进入休眠等待唤醒,调度器在调度其他任务之前将会检查当前是否在后台标记阶段,如果是那么检查是否有未被调度执行的标记任务以及当前允许用于处理标记任务的处理器P数量是否满足条件,若有则唤醒该任务,否则处理用户程序任务,后台标记任务在执行时是不可抢占的
标记完成后进入第三个阶段,标记终止阶段,在该阶段会刷新所有处理器的写屏障缓冲,然后切换GC状态,主要流程如下:
- 获取GC需要的锁
- 确认所有标记工作已完成,若有未完成的任务则继续处理
- 刷新所有处理器P的写屏障缓存,并再次确认是否所有标记工作已完成
- 进入STW
- 再次刷新所有处理器的写屏障缓存
- 若需要重新恢复STW则恢复STW并重新进入标记终止阶段
- 切换GC状态为
_GCmarktermination
- 堆栈的标记、统计、操作等,这里还没有仔细研究,以后补充
- 切换GC状态为
_GCoff
,关闭内存屏障 - GC内部状态的统计工作
- 恢复STW
- 释放锁
之后进入最后的阶段,并行清理阶段,该阶段将会对之前标记的所有白色内容执行清理,然后释放堆栈内存,内存的清理工作是与用户程序并行执行的,并且必须在下一次GC之前完成
GC时调度器与内存分配的辅助工作
在GC的第二个阶段中,用户程序的内存分配与后台标记工作将并行执行,若某个G的内存分配速度过快,可能会导致GC的速度无法跟上新内存增长的速度,因此在GC开始时,将会为所有用户程序的G分配一个分数,若该分数为负数,则此G需要协助GC进行标记工作以赚取足够的分数值,此时该协助工作是可以被抢占的,若GC状态不为_GCoff
,那么内存分配器还会在内存分配完成后直接将该内存标记为黑色
在后台标记阶段,若标记任务的数量以及足够,但是当前P空闲,此时也会协助进行标记,此任务也是可以被抢占的
网友评论