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的对象称为大对象
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%.
小对象的映射表
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% |
微对象与小对象的内存分配
mcacheruntime/mcache.go
mcache
(67<<1)|int(noscan)mcache
tinySpanClassmcache
mcachemcache
size_to_class8size_to_class128
大对象的内存分配
1 << 1 | int(noscan))
mcache
mheapmcachemcenter
GC原理
垃圾收集涉及的内容是方方面面的,其贯穿程序的整个生命周期以及所有与内存相关联的组件,以下仅整理Golang中垃圾收集的过程,不涉及GC的具体实现代码,关于实现以后再单独写文章进行记录
Golang的垃圾收集器是逐渐演进的,这里不对历史进行追溯,在当前版本,垃圾收集器使用三色标记法,并且通过混合写屏障(插入与删除)来保证并发垃圾收集的性能与内存安全性
对于三色标记法以及写屏障技术这里不进行展开,对此进行介绍的文章很多,这里仅研究Golang的GC触发时机、各种阶段的任务、GC时的内存分配等等
其基本过程是启动GC时进行一些状态检查以及准备工作,然后开始与用户程序并行执行进行内存状态的标记,标记过程中会打开内存屏障,以保证新创建的对象不会被错误的清理,当标记完成后进入清理阶段,该阶段与用户程序并行
在Golang的GC过程中会触发两次STW,均发生在GC状态变更的时候
GC的触发时机
Golang中在以下情况会触发GC测试,测试的条件有三种类型
- 堆内存大小测试(若当前堆活动内存大于等于上次GC时控制器计算的控制大小)
- 时间测试(若最后一次GC时间距离上一次GC已经经过设定时间,默认时间为2分钟)
- GC周期数测试(若新开始的周期数大于当前已经进行的周期数)
测试时选择一种条件进行测试,若条件满足则启动新一轮的GC,在以下几种情况中会进行GC测试
mcachemcenter
runtime.GC
GC的各个阶段以及阶段任务
Golang中GC有三种状态,可以分为四个阶段任务,状态有下列三种
_GCoff_GCmark_GCmarktermination
由三种状态的切换可以将GC分为四个阶段任务
_GCoff
mcachepanic_GCmark
第二个阶段是后台标记阶段,后台标记阶段将在STW恢复后开始,此时标记扫描工作将与程序并行执行,上一个阶段创建的用于后台处理标记的G会被唤醒,内存的标记工作将由这些G来执行
后台标记的任务数量与GOMAXPROC的数量相等,但并非所有的处理器P都会被用于执行标记任务,可用于执行标记任务的处理器数量是全部处理器数量的一定比例,其默认为百分之25,若开启了debug模式则其数值等于GOMAXPROC,在计算专用处理器P的数量时会进行四舍五入的取整操作,若GC处理时的性能分数仍然达不到百分之25的要求,那么会临时征用其他的处理器P处理标记工作
对于标记任务的调度要优先于其他任务,其过程是在创建后台标记任务时,标记任务将自己加入到后台标记工作池中,之后自身进入休眠等待唤醒,调度器在调度其他任务之前将会检查当前是否在后台标记阶段,如果是那么检查是否有未被调度执行的标记任务以及当前允许用于处理标记任务的处理器P数量是否满足条件,若有则唤醒该任务,否则处理用户程序任务,后台标记任务在执行时是不可抢占的
标记完成后进入第三个阶段,标记终止阶段,在该阶段会刷新所有处理器的写屏障缓冲,然后切换GC状态,主要流程如下:
_GCmarktermination_GCoff
之后进入最后的阶段,并行清理阶段,该阶段将会对之前标记的所有白色内容执行清理,然后释放堆栈内存,内存的清理工作是与用户程序并行执行的,并且必须在下一次GC之前完成
GC时调度器与内存分配的辅助工作
_GCoff
在后台标记阶段,若标记任务的数量以及足够,但是当前P空闲,此时也会协助进行标记,此任务也是可以被抢占的