Go-内存分配与内存回收 | 青训营笔记

331 阅读6分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记

1. 引入自动内存管理

  • 动态内存:程序在运行时根据需求动态分配内存:malloc()
  • 自动内存管理(垃圾回收):由程序语言的运行时系统管理动态内存,避免手动内存管理,专注于实现业务逻辑,保证内存使用的正确性和安全性,如常见的double-free problem(连续释放了两次内存),use-after-free problem(释放了内存后又使用这块内存),手动分配内存就会产生这些问题
  • GC的任务:为新对象分配空间、找到存活对象、回收死亡对象的内存空间

2. GC中的相关概念以及常见的GC策略

  • Mutator:业务线程,作用是分配新对象,修改对象指向的关系
  • Collector:GC线程,找到存活对象,回收死亡对象的内存空间
  • Serial GC:只有一个Collecter对所有协程进行内存回收,在内存回收时需要stw
  • Parallel GC:支持多个collectors同时回收的GC算法
  • Concurrent GC:Mutator和Collector可以同时执行Collectors必须感知对象指向关系的改变,通过三色标记和混合写屏障来实现

3. GC算法的性能指标标准

  • 安全性(Safety):不能回收存活的对象基本要求
  • 吞吐量(Throughtput):1-GC时间/程序执行总时间花在GC上的时间
  • 暂停时间(Pause time):stop the world(STW)业务是否感知
  • 内存开销(Space overhead):GC元数据开销,额外开辟空间创建GC需要的数据结构等
  • 常用的GC算法:引用计数(Reference Counting),追踪垃圾回收(Tracing grabage collection)

4. 追踪垃圾回收

  • 对象被回收的条件:指针指向关系不可达的对象
  • 步骤:
  1. 标记根对象:第一步标记静态变量、全局变量、常量、线程栈等,因为这些变量在程序中随时可能被引用到,所以直接标记为存活
  2. 标记可达对象:求指针指向关系的传递闭包:从跟对象出发,找到所有可达对象
  3. 清理不可达对象:根据对象的声明周期,使用不同标记和清理策略
    • 将存活对象复制到另外的内存空间中(Copying GC),离散的存活对象复制完成后内存排列会变得更加紧凑
    • 将死亡对象的内存标记为“可分配”(Mark-sweep GC),使用free list管理空闲内存,free list类似于记录一个空闲内存的起始地址和结束地址的链表
    • 移动并整理存存活对象(Mark-compact GC),原地整理对象,在一个内存块内将多个存活对象原地整理,放在内存块的最前面,内存排列会更加紧凑,后面内存分配更加方便,与Copying GC类似,但其是将存活对象复制到另一个内存块中,后者是在同一个内存块内进行操作

5. 分代GC(Generation GC)

  • 分代假说(Generation hypothesis):most objects die young,很多对象在分配出来后很快就不再使用了,通俗点的例子就是在执行某个函数中创建的临时变量在函数返回后就不会再引用了,这时候就是没用的对象可以回收
  • 对象的年龄:指的是对象经历过GC的次数
  • 目的:对于年轻和老年的对象指定不同的GC策略,降低整体内存管理的开销
  • 不同年龄的对象处于heap的不同区域
  • 年轻代:常规对象的分配,由于存活对象很少,可以采用copying collection,GC吞吐率很高
  • 老年代:对象趋向于一直存活,反复复制开销较大,可以使用mark-sweep collection,碎片太多可根据使用mark-compact GC进行压缩让碎片减少

6. 引用计数GC

  • 每个对象都有一个与之关联的引用数目
  • 对象存活的条件:当且仅当引用计数大于0
  • 优点:1. 内存管理的操作被平摊到程序执行过程中;2.内存管理不需要了解runtime的实现细节:C++的智能指针
  • 缺点:
    • 维护引用计数的开销较大,通过原子操作(atomic包?)保证引用计数操作的原子性和可见性
    • 无法回收环形数据结构,当有环形引用的存在时即使环中的所有对象不可达,因为引用计数都不为0所以无法回收——通过week reference可以解决
    • 内存开销:每个对象都引入额外内存空间存储引入数目
    • 回收内存时依然可能引发暂停,如当一个对象引用计数变为0后,其对象中的引用的其他对象的引用计数都变为0,导致一个大对象的内存回收

7. Go内存分配-分块

  • 目标:为对象在heap上分配内存
  • 提前将内存分块,通过申请一大块内存再次分成固定大小和特定大小的块,每次分配根据对象大小,选择最小大于对象大小的块返回,mspan为将大块内存分成固定大小的块,而从mspan中再次分配特定大小的块才是为对象内存分配所设计的
  • noscan mspan:分配不包含指针的对象——GC不需要扫描
  • scan mspan:分配包含指针的对象——GC需要扫描,因为在一个对象中包含指针的对象的话,在GC过程中也需要访问该指针进行标记,否则将会被GC回收

8. Balance GC

  • 因为Go内存分配是按块来分且每次分配会根据对象的大小选择最小大于对象大小的块进行分配,则有可能产生碎片,Balance GC用来解决这个问题
  • 每个g都绑定一大块内存(1KB),称作goroutine allocation buffer(GAB)
  • GAB用于noscan类型的小对象分配,一般<128B
  • 使用三个指针维护GAB:base(基址),end(结束地址),top(可分配的首地址)
  • GAB对Go内存管理来说是一个大对象,本质是将多个小对象分配合并为一个大对象进行分配
  • 存在的问题:内存碎片,在一个大对象中只存在一个很小的对象,导致大对象的其他内存空间得不到释放;通过检测GAB总大小,将GAB中存活的对象复制到另外的GAB中,这样原来的GAB就可以释放,避免内存泄露,这里使用的是copying GC算法管理小对象,体现了根据对象的生命周期,使用不同的标记和清理策略