Go 内存管理
人工回收
c语言由开发者来手动分配与回收内存,调用malloc或calloc分配内存,调用free清空这块内存。
手动回收会存在内存泄漏问题,即分配后没有释放。
自动管理
动态内存管理,即垃圾回收。
优点:
1.增加安全
2.跨系统
3.更少代码
4.运行时校验
5.数组边界检查
程序运行时对象存储在两个位置,堆和栈。栈存储函数值,堆存函数外部引用的值。
垃圾回收
并发标记回收(Concurrent Mark Sweep CMS)算法
GC算法有基于复制的,也有基于分区的。为什么Go选择CMS算法,原因亮点:
Go的内存分配策略缓解了CMS容易产生内存碎片的缺陷
- Go的GC算法采用TCMalloc分配器的内存分配思路,降低碎片率。
Go有值类型数据,struct类型。
- 有了值类型,编译器就不用关心函数间的逃逸分析,只关注函数内的逃逸分析,由此可以将生命周期短的对象在栈上分配。
TCMalloc的分配思想
TC是thread cache的缩写,核心思想:TCMalloc会给每个线程分配一个thread-local cache,用于线程的分配。避免了多线程操作,所以不需要加锁,减少锁竞争带来的性能损耗。
当t-l cache空间不足时,才会向下一级内存管理器请求新的空间。
TCMalloc引入了 Thread cache、Central cache 以及 Page heap 三个级别的管理器来管理内存,可以充分利用不同级别下的性能优势。TCMalloc 的多级管理机制非常类似计算机系统结构的内存多级缓存机制。
在Go的内存管理机制,有几个重要的数据结构,mspan、heapArena、mcache、mcentral以及mheap。
- mspan和heapArena维护了Go的虚拟内存布局;
- mcache、mcentral 以及 mheap 则构成了 Go 的三层内存管理器。
虚拟内存布局
mspan
- Go的内存管理基本单元是mspan,每个mspan维护一快连续虚拟内存空间,内存起始地址由startAddr记录。
- 每个mspan存储的内存空间都是内存页的整数倍,由npages保存。Go的内存页是8KB。
mspan结构如下:
type mspan struct {
next *mspan // next span in list, or nil if none
prev *mspan // previous span in list, or nil if none
startAddr uintptr // address of first byte of span aka s.base()
npages uintptr // number of pages in span
...
spanclass spanClass // size class and noscan (uint8)
...
}
heapArena
- heapArena相当于Go的内存块,x86-64架构的Linux系统上,一个heapArena维护的内存空间是64MB。
- heapArena记录了mspan数组,长度为pagesPerArena,等于ArenaSize/PageSize,用来管理每一个内存页。
- 整个heapArena的内存基址由zeroedBase记录。
heapArena结构如下:
type heapArena struct {
...
spans [pagesPerArena]*mspan
zeroedBase uintptr
}
三层内存管理器
spanClass
- 在Go的三级内存管理中,维护的对象都是小于32KB的小对象,并按照大小分为67类,称为spanClass。
- 每一个spanClass都用来存固定大小的对象。代码定义在runtime.sizeclasses中可以看到。
- 对于代码中的注释,以 class 3 为例做个介绍。class 3 是说在 spanClass 为 3 的 span 结构中,存储的对象的大小是 24 字节,整个 span 的大小是 8192 字节,也就是一个内存页的大小,可以存放的对象数目最多是 341。
- tail waste 这里是 8 字节,这个 8 是通过 8192 mod 24 计算得到,意思是,当这个 span 填满了对象后,会有 8 字节大小的外部碎片。而 max waste 的计算方式则是 [(24−17)×341+8]÷8192 得到,意思是极端场景下,该 span 上分配的所有对象大小都是 17 字节,此时的内存浪费率为 29.24%。
- 以上 67 个存储小对象的 spanClass 级别,再加上 class 为 0 时用来管理大于 32KB 对象的 spanClass,共总是 68 个 spanClass。这些数据都是通过在 runtime.mksizeclasses.go 中计算得到的。我们从上边的注释可以看出,Go 在分配的时候,是通过控制每个 spanClass 场景下的最大浪费率,来保障堆内存在 GC 时的碎片率的。
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// ...
// 67 32768 32768 1 0 12.50% 8192
mcache
- mcache是Go的线程缓存,对应TCMalloc的Thread-Local cache结构。
- mcache与线程绑定,每个goroutine申请内存时,都不会与其他goroutine发生竞争。
- mcache会维护长度为numSpanClasses(68*2)的mspan数组,其中包含scan和noscan队列。
- mcache结构如下:
type mcache struct {
...
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
...
}
mcentral
- 当mcache需要扩容时,需要向mcentral申请,mcentral对应于TCMalloc的Central Cache结构。
- mcentral也维护固定一种spanClass的mspan。
- 另外还有两个span集合,partial 里存放的是包含着空闲空间的 mspan 集合,full 里存放的是不包含空闲空间的 span 集合。这里每种集合都存放两个元素,用来区分集合中 mspan 是否被清理过。
- mcentral 不同于 mcache,每次请求 mcentral 中的 mspan 时,都可能发生不同线程直接的竞争。因此,在使用 mcentral 时需要进行加锁访问,具体来讲,就是 spanSet 的结构中会有一个 mutex 的锁的字段。
mcentral结构如下:
type mcentral struct {
spanclass spanClass
partial [2]spanSet // list of spans with a free object
full [2]spanSet // list of spans with no free objects
}
mheap
- mheap是Go的运行时的只有一个实例的全局变量。
- mheap对应TCMalloc中的Page heap结构。
- heapArena的二维数组就在mheap中。
- mheap 中存放了 68×2 个不同 spanClass 的 mcentral 数组,分别区分了 scan 队列以及 noscan 队列。
type mheap struct {
lock mutex
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
}
var mheap_ mheap
对象分配机制
对象三个级别:
- 微小对象:0-16字节的非指针类型对象
- 小对象:16-32KB的对象以及小于16字节的指针对象
- 大对象:spanClass为0的对象。
具体分配策略:
- 微小对象会被分配到spanClass为2的mspan中,最大16字节,分配时会尽量将微小对象放到同一个内存块,降低碎片化。
- 我们在 mcache 中提到的三个字段:tiny、tinyoffset 和 tinyAllocs 就是用来维护微小对象分配器的。tiny 是指向当前在使用的 16 字节内存块的地址,tinyoffset 则是指新分配微小对象需要的起始偏移,tinyAllocs 则存放了目前这个 mcache 中共存放了多少微小对象。
- 对于小对象的分配,整体逻辑跟 TCMalloc 是保持一致的,就是依次向三级内存管理器请求内存,一旦内存请求成功则返回。
- 对于大对象的分配,Go 并不会走上述的三次内存管理器,而是直接通过调用 mcache.allocLarge 来分配大内存。allocLarge 会以内存页的倍数大小来通过 mheap_.alloc 申请对应大小内存,并构建起 spanClass 为 0 的 mspan 对象返回。
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 微小对象分配
} else {
// 小对象分配
}
} else {
// 大对象分配
}
内存回收机制
CMS:三色标记清除算法。
三色标记清除算法分几个阶段
清除终止阶段
- 这阶段会进行STW(Stop The World),让所有线程进入安全点,若是强制GC的话,还要清除上次GC未清除的mspan。
标记阶段
- gcphase状态从_GCoff切换到_GCmark,并打开write barrier和mutator assists,将根对象压入扫描队列中,此时所有的mutator还处于STW状态。
- 重启mutator,此时后台的标记线程和mutator assists可以共同帮助GC进行标记,标记过程中,write barrier 会把所有被覆盖的指针以及新指针都标记为灰色,而新分配的对象指针则直接标记为黑色。
- 标记线程开始根对象扫描,包括所有栈对象、全局变量对象,还有所有堆外的运行时数据结构,扫描完根对象后,标记线程会继续扫描灰色队列中的对象,将对象标为黑色并以此将其引用的对象标灰入队。
- 由于 Go 中每个线程都有一个 Thread Local 的 cache,GC 采用的是分布式终止算法来检查各个 mcache 中是否标记完成。
标记终止阶段
- 在标记终止阶段会进行 STW,暂停所有的线程。STW 之后将 gcphase 的状态切换到 _GCmarktermination,并关闭标记进程和 mutator assists。此外还会进行一些清理工作,刷新 mcache。
清除阶段
- 在开始清除阶段之前,GC 会先将 gcphase 状态切换到 _GCoff,并关闭 write barrier。接着再恢复用户线程执行,此时新分配的对象只会是白色状态。并且清除工作是增量是进行的,所以在分配时,也会在必要的情况下先进行内存的清理。这个阶段的内存清理工作会在后台并发完成。
write barrier
- write barrier的作用是拦截写操作。CMS和G1算法都使用了write barrier来保证并发标记的完整性,防止漏标。
三色标记算法
- 白色:还未搜索的对象
- 灰色:已经搜索,但还未扩展的对象
- 黑色:已经搜索,已做完扩展的对象。
并发标记中最严重的问题就是漏标。如果一个对象是活跃对象,但它没有被标记,这就是漏标。这就会出现活跃对象被回收的情况。
垃圾收集器
go的垃圾收集器包括两部分,mutator和collector。collector执行垃圾收集逻辑和找到应该释放内存的对象。mutator执行程序代码并将新对象分配给堆。
垃圾收集有两个阶段,标记和清除。
- 标记阶段。收集器遍历堆并标记不再需要的对象。
- 清除阶段。删除这些对象。go通过三色标记算法来实现。
go让所有goroutine通过一个叫stop the world机制来达到垃圾收集的安全点。
三色法:
根对象为灰色,其他所有对象为白色,当扫描一个堆栈,