这是我参与「第五届青训营」伴学笔记创作活动的第28
内存管理
堆内存管理
- 初始化连续内存块作为堆
- 有内存申请的时候,
Allocator从堆内存的未分配区域分割小内存块 - 用
链表将已分配内存连接起来 - 需要信息描述每个内存块的元数据:大小,是否使用,下一个内存块的地址等
内存管理的挑战
- 内存分配需要
系统调用(用户态需要切到内核态),在频繁分配的时候,效率很低 - 多线程共享相同的内存空间,同时申请内存时需要
加锁,降低内存分配效率 - 不断的内存分配和回收会导致
内存碎片
TCMlloc:google的方法
特点:
-
为解决多线程加锁的问题,
会给每个线程一块私有的内存空间(ThreadCache),防止多线程竞争- 若线程还有空间,则直接
从该空间(ThreadCache)中取 - 若ThreadCache没有空间,则
从CentralCache中取,需要加锁,按页分配,降低了加锁的频率 - CentralCache若还没有,则
到PageHeap中取,需要加锁
- 若线程还有空间,则直接
-
page:内存页(8K),一块8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以page 为单位的 -
span:内存块,一个或多个连续的page 组成一个span -
sizeclass:空间规格,每个span 都带有一个sizeclass,标记着该span 中的page 应该如何使用 -
object: 对象,用来存储一个变量数据内存空间,一个span 在初始化时,会被切割成一堆等大的object ;假设object 的大小是16B ,span 大小是8K ,那么就会把span 中的page 就会被初始化8K / 16B = 512 个object 。所谓内存分配,就是分配一个object 出去
Tcmalloc对象内存分配
- 对象大小定义
- 小对象大小:0~256KB
- 中对象大小:256KB~1MB
- 大对象大小:>1MB
小对象的分配流程- ThreadCache-> CentralCache-> HeapPage,大部分时候,ThreadCache缓存都是足够的,不需要去访问CentralCache和HeapPage,无系统调用配合无锁分配,分配效率是非常高的
中对象分配流程- 直接在Page Heap中选择适当的大小即可,128 Page的Span所保存的最大内存就是1MB
大对象分配流程- 从large span set选择合适数量的页面组成span,用来存储数据
go的内存分配
mcache:小对象的内存分配直接走- size class从1到66,每个class两个span,一个指针,一个数据
- Span大小是8KB,按span class大小切分
mcentral- Span内的所有内存块都被占用时,没有剩余空间继续分配对象,mcache会向mcentral申请1个span,mcache拿到span后继续分配对象
- 当mcentral向mcache提供span时,如果没有符合条件的span,mcentral会向mheap申请span
mheap- 当mheap没有足够的内存时,mheap会向OS申请内存
- Mheap把Span组织成了树结构,而不是链表
- 然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图
- 为了更高效的分配、回收和再利用内存
与TCmalloc的区别
- 小对象在mcache分配,每类对象占用两个内存块span,分别存放数据和指针,指针用来回收内存
- mcentral中也维护了两个链表:nonempty(可能有有效内存空间)与empty(没有没存了),只有当两个量表都没有空间了才会向heap要
- heap中由两棵二叉排序树管理:free和scav,scav用于存放回收的空间
内存回收
引用计数(Python,PHP,Swift)
- 对每一个对象维护一个引用计数,当引用该对象的对象被销毁的时候,引用计数减1,当引用计数为0的时候,回收该对象
- 优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阈值时才回收
- 缺点:不能很好的处理
循环引用,而且实时维护引用计数,有也一定的代价
标记-清除(Golang)
- 从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有被标记的进行回收
- 优点:解决引用计数的缺点
- 缺点:需要STW(stop the word)即要
暂停程序运行
分代收集(Java)
- 按照生命周期进行划分不同的代空间,生命周期长的放入老年代,短的放入新生代,新生代的回收频率高于老年代的频率
mspan
- allocBits
-
- 记录了每块内存分配的情况
- gcmarkBits
-
- 记录了每块内存的引用情况,标记阶段对每块内存进行标记,有对象引用的内存标记为1,没有的标记为0
GC工作流程
Golang GC的大部分处理是和用户代码并行的
Mark:Mark Prepare: 初始化GC任务,包括开启写屏障(write barrier)和辅助GC(mutator assist),统计root对象的任务数量等。这个过程需要STWGC Drains: 扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空。该过程后台并行执行
Mark Termination:完成标记工作,重新扫描(re-scan)全局指针和栈因为Mark和用户程序是并行的,所以在Mark过程中可能会有新的对象分配和指针赋值,这个时候就需要通过写屏障(write barrier)记录下来,re-scan 再检查一下,这个过程也是会STW的Sweep:按照标记结果回收所有的白色对象,该过程后台并行执行Sweep Termination:对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC
三色标记
- GC 开始时,认为所有object 都是
白色,即垃圾 - 从root 区开始遍历,被触达的object 置成
灰色 - 遍历所有灰色object,将他们内部的引用变量置成
灰色,自身置成黑色 - 循环第3 步,直到没有灰色object 了,只剩下了黑白两种,白色的都是垃圾
- 对于
黑色object,如果在标记期间发生了写操作,写屏障会在真正赋值前将新对象标记为灰色 - 标记过程中,mallocgc新分配的object,会先被标记成
黑色再返回
垃圾回收触发机制
- 内存分配量
达到阀值触发GC- 每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC
阀值= 上次GC内存分配量* 内存增长率- 内存增长率由环境变量GOGC控制,默认为100,即每当内存扩大一倍时启动GC
- 每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC
- 定期触发GC
- 默认情况下,
最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod变量中被声明
- 默认情况下,
- 手动触发
- 程序代码中也可以使用
runtime.GC()来手动触发GC。这主要用于GC性能测试和统计。
- 程序代码中也可以使用