Go内存管理
原文
- 前文提到Go内存管理源自TCMalloc
- 但它比TCMalloc还多了2件东西:
- 逃逸分析
- 垃圾回收
- 这是2项提高生产力的绝佳武器
- 这一大章节
- 我们先介绍Go内存管理和Go内存分配
- 最后涉及一点垃圾回收和内存释放
Go内存管理的基本概念
- Go内存管理的许多概念在TCMalloc中已经有了
- 含义是相同的
- 只是名字有一些变化
- 先给大家上一幅宏观的图
- 借助图一起来介绍
- 借助图一起来介绍
-
Page
- 与TCMalloc中的Page相同
- x64架构下1个Page的大小是8KB
- 上图的最下方
- 1个浅蓝色的长方形代表1个Page。
-
Span
- Span与TCMalloc中的Span相同
- Span是内存管理的基本单位
- 代码中为mspan
- 一组连续的Page组成1个Span
- 所以上图一组连续的浅蓝色长方形
- 代表的是一组Page组成的1个Span
- 所以上图一组连续的浅蓝色长方形
- 另外,1个淡紫色长方形为1个Span。
-
mcache
- mcache与TCMalloc中的ThreadCache类似
- mcache保存的是各种大小的Span
- 并按Span class分类
- 小对象直接从mcache分配内存
- 它起到了缓存的作用
- 并且可以无锁访问
- 但是mcache与ThreadCache也有不同点
- TCMalloc中是每个线程1个ThreadCache
- Go中是每个P拥有1个mcache
- 因为在Go程序中
- 当前最多有GOMAXPROCS个线程在运行
- 所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问
- 线程的运行又是与P绑定的
- 把mcache交给P刚刚好
-
mcentral
- mcentral与TCMalloc中的CentralCache类似
- 是所有线程共享的缓存
- 需要加锁访问
- 它按Span级别对Span分类
- 然后串联成链表
- 当mcache的某个级别Span的内存被分配光时
- 它会向mcentral申请1个当前级别的Span
- 但是mcentral与CentralCache也有不同点
- CentralCache是每个级别的Span有1个链表
- mcache是每个级别的Span有2个链表
- 这和mcache申请内存有关
- 稍后我们再解释
-
mheap
- mheap与TCMalloc中的PageHeap类似
- 它是堆内存的抽象
- 把从OS申请出的内存页组织成Span
- 并保存起来
- 当mcentral的Span不够用时会向mheap申请内存
- 而mheap的Span不够用时会向OS申请内存
- mheap向OS的内存申请是按页来的
- 然后把申请来的内存页生成Span组织起来
- 同样也是需要加锁访问的
- 但是mheap与PageHeap也有不同点:
- mheap把Span组织成了树结构,而不是链表,并且还是2棵树
- 然后把Span分配到heapArena进行管理
- 它包含地址映射和span是否包含指针等位图
- 这样做的主要原因是为了更高效的利用内存:分配、回收和再利用
- 然后把Span分配到heapArena进行管理
Go内存大小转换
-
- 代码里简称size指申请内存的对象大小
-
- 代码里简称class
- 它是size的级别
- 相当于把size归类到一定大小的区间段
- 比如size[1,8]属于size class 1
- size(8,16]属于size class 2
- 代码里简称class
-
span class:
- 指span的级别
- 但span class的大小与span的大小并没有正比关系
- span class主要用来和size class做对应
- 1个size class对应2个span class
- 2个span class的span大小相同,只是功能不同
- 1个用来存放包含指针的对象
- 一个用来存放不包含指针的对象
- 不包含指针对象的Span就无需GC扫描了
- 2个span class的span大小相同,只是功能不同
-
num of page:
- 代码里简称npage
- 代表Page的数量
- 其实就是Span包含的页数,用来分配内存
Go内存分配
- Go中的内存分类并不像TCMalloc那样分成小、中、大对象
- 但是它的小对象里又细分了一个Tiny对象
- Tiny对象指大小在1Byte到16Byte之间
- 并且不包含指针的对象
- 小对象和大对象只用大小划定,无其他区分
- 小对象是在mcache中分配的
- 大对象是直接从mheap分配的
- 从小对象的内存分配看起
小对象的内存分配
- 大小转换这一小节
- 我们介绍了转换表
- size class从1到66共66个
- 代码中_NumSizeClasses=67
- 代表了实际使用的size class数量
- 即67个,从1到67
- size class 0实际并未使用到
- 代码中_NumSizeClasses=67
// 上文提到1个size class对应2个span class:
numSpanClasses = _NumSizeClasses * 2
- numSpanClasses为span class的数量为134个
- 所以span class的下标是从0到133
- 所以上图中mcache标注了的span class是
- span class 0到span class 133
- 每1个span class都指向1个span
- 也就是mcache最多有134个span
- 为对象寻找span
- 寻找span的流程如下:
- 计算对象所需内存大小size
- 根据size到size class映射,计算出所需的size class
- 根据size class和对象是否包含指针计算出span class
- 获取该span class指向的span
- 寻找span的流程如下:
- 以分配一个不包含指针的,大小为24Byte的对象为例,根据映射表:
- 对应的size class为3
- 它的对象大小范围是(16,32]Byte
- 24Byte刚好在此区间
- 所以此对象的size class为3
- Size class到span class的计算如下:
// noscan为true代表对象不包含指针
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
- 所以对应的span class为7
- 所以该对象需要的是span class 7指向的span
span class = 3 << 1 | 1 = 7
- 从span分配对象空间
- Span可以按对象大小切成很多份
- 这些都可以从映射表上计算出来
- 以size class 3对应的span为例
- span大小是8KB
- 每个对象实际所占空间为32Byte
- 这个span就被分成了256块
- 可以根据span的起始地址计算出每个对象块的内存地址
-
随着内存的分配
-
span中的对象内存块,有些被占用,有些未被占用
-
比如上图
- 整体代表1个span
- 蓝色块代表已被占用内存,绿色块代表未被占用内存
- 当分配内存时
- 只要快速找到第一个可用的绿色块
- 并计算出内存地址即可
- 如果需要
- 还可以对内存块数据清零。
- 整体代表1个span
-
当span内的所有内存块都被占用时
- 没有剩余空间继续分配对象
- mcache会向mcentral申请1个span
- mcache拿到span后继续分配对象
- mcache向mcentral申请span
- mcentral和mcache一样
- 都是0~133这134个span class级别
- 但每个级别都保存了2个span list,即2个span链表:
- nonempty:
- 这个链表里的span
- 至少有1个空闲的对象空间
- 是mcache释放span时加入到该链表的
- 这个链表里的span
- empty:
- 这个链表里的span
- 所有的span都不确定里面是否有空闲的对象空间
- 当一个span交给mcache的时候,就会加入到empty链表。
- 这个链表里的span
- nonempty:
- mcentral和mcache一样
- 这两个东西名称一直有点绕
- 建议直接把empty理解为没有对象空间就好了
- mcache向mcentral申请span时
- mcentral会先从nonempty搜索满足条件的span
- 如果没有找到再从emtpy搜索满足条件的span
- 然后把找到的span交给mcache
- mheap的span管理
- mheap里保存了两棵二叉排序树
- 按span的page数量进行排序:
- free:
- free中保存的span是空闲并且非垃圾回收的span
- scav:
- scav中保存的是空闲并且已经垃圾回收的span
- free:
- 如果是垃圾回收导致的span释放
- span会被加入到scav
- 否则加入到free
- 比如刚从OS申请的的内存也组成的Span
- mheap中还有arenas
- 由一组heapArena组成
- 每一个heapArena都包含了连续的pagesPerArena个span
- 这个主要是为mheap管理span和垃圾回收服务
- mheap本身是一个全局变量
- 它里面的数据
- 也都是从OS直接申请来的内存
- 并不在mheap所管理的那部分内存以内
- 它里面的数据
- 由一组heapArena组成
-
mcentral向mheap申请span
- 当mcentral向mcache提供span时
- 如果empty里也没有符合条件的span
- mcentral会向mheap申请span
- 此时
- mcentral需要向mheap提供需要的内存页数和span class级别
- 然后它优先从free中搜索可用的span
- 如果没有找到
- 会从scav中搜索可用的span
- 如果还没有找到
- 它会向OS申请内存
- 再重新搜索2棵树,必然能找到span
- 如果找到的span比需要的span大
- 则把span进行分割成2个span
- 其中1个刚好是需求大小
- 把剩下的span再加入到free中去
- 然后设置需要的span的基本信息
- 然后交给mcentral
- 当mcentral向mcache提供span时
-
mheap向OS申请内存
- 当mheap没有足够的内存时
- mheap会向OS申请内存
- 把申请的内存页保存为span
- 然后把span插入到free树
- 在32位系统中,mheap还会预留一部分空间
- 当mheap没有空间时
- 先从预留空间申请
- 如果预留空间内存也没有了
- 才向OS申请
大对象的内存分配
- 大对象的分配比小对象省事多了
- 99%的流程与mcentral向mheap申请内存的相同
- 所以不重复介绍了
- 不同的一点在于
- mheap会记录一点大对象的统计信息
- 详情见mheap.alloc_m()
- mheap会记录一点大对象的统计信息
Go垃圾回收和内存释放
- 如果只申请和分配内存,内存终将枯竭
- Go使用垃圾回收收集不再使用的span
- 调用mspan.scavenge()把span释放还给OS
- (并非真释放,只是告诉OS这片内存的信息无用了,如果你需要的话,收回去好了)
- 然后交给mheap
- mheap对span进行span的合并
- 把合并后的span加入scav树中
- 等待再分配内存时
- 由mheap进行内存再分配
- 调用mspan.scavenge()把span释放还给OS
- Go程序是怎么把内存释放给操作系统的?
- 释放内存的函数是sysUnused,它会被mspan.scavenge()调用:
func sysUnused(v unsafe.Pointer, n uintptr) {
// MADV_FREE_REUSABLE is like MADV_FREE except it also propagates
// accounting information about the process to task_info.
madvise(v, n, _MADV_FREE_REUSABLE)
}
- 注释说 _MADV_FREE_REUSABLE 与 MADV_FREE 的功能类似
- 它的功能是给内核提供一个建议:
- 这个内存地址区间的内存已经不再使用,可以进行回收
- 但内核是否回收,以及什么时候回收,这就是内核的事情了
- 如果内核真把这片内存回收了
- 当Go程序再使用这个地址时
- 内核会重新进行虚拟地址到物理地址的映射
- 所以在内存充足的情况下
- 内核也没有必要立刻回收内存
Go的栈内存
- 从一个宏观的角度看
- 内存管理不应当只有堆,也应当有栈
- 每个goroutine都有自己的栈
- 栈的初始大小是2KB
- 100万的goroutine会占用2G
- 但goroutine的栈会在2KB不够用时自动扩容
- 当扩容为4KB的时候
- 百万goroutine会占用4GB
- 栈的初始大小是2KB
总结
Go的内存分配原理就不再回顾了,它主要强调两个重要的思想:
-
使用缓存提高效率
- 在存储的整个体系中到处可见缓存的思想
- Go内存分配和管理也使用了缓存,利用缓存
- 一是减少了系统调用的次数
- 二是降低了锁的粒度、减少加锁的次数
- 从这2点提高了内存管理效率
-
以空间换时间,提高内存管理效率
- 空间换时间是一种常用的性能优化思想
- 这种思想其实非常普遍
- 比如Hash、Map、二叉排序树等数据结构的本质就是空间换时间
- 在数据库中也很常见
- 比如数据库索引、索引视图和数据缓存等
- 再如Redis等缓存数据库也是空间换时间的思想