Go 语言内存管理详解 | 青训营笔记

86 阅读6分钟

这是我参与「第五届青训营 」笔记创作活动的第7天

深入垃圾回收全流程

垃圾回收循环

标记准备阶段

最重要的任务是清扫上一阶段GC遗留的需要清扫的对象,因为使用了懒清扫算法,所以当执行下一次GC时,可能还有垃圾对象没有清扫。同时,会重置状态以及启动协程等等,上面许多重要的部分需要在STW中进行

标记准备阶段会为每个P启动一个标记协程,但不是所有的标记协程都有执行的机会,因为标记协程与正常执行用户代码的协程需要并行

计算标记协程的数量

标记协程消耗的CPU应该25%

这里有一个fractionUtilizationGoal附加参数,是专门为P为1、2、3、6设计的

这时一种基于时间的调度

切换到后台标记协程

当关闭STW准备再次启动所有协程时,每个逻辑处理器P都会进行一轮新的调度循环,调度循环开始时,调度器会判断程序是否处于GC阶段,如果是,则尝试判断当前P是否需要执行后台标记任务

并发标记阶段

后台标记任务:

后台标记flag:

Go语言的做法是先执行可以被抢占的后台标记任务,如果标记协程已经被其他协程抢占,那么当前的逻辑处理器P并不会执行其他协程,而是将其他协程转移到全局队列中

根对象扫描

全局变量扫描

如何通过指针找到指针对应的对象位置呢?

  • 先找到指针在哪一个heapArena
  • 再找到对应的mspan,进而找到其位于mspan第几个元素

finalizer

finailzer是特殊对象,其是在对象释放后会被调用的析构器

标记期间, 后台标记协程会遍历mspan的specials链表,扫描finalizer所位于的元素,并扫描当前的元素。注意,并不能把finalizer位于的span中的对象加入到根对象中,否则我们将失去回收该对象的机会

finalizer很有趣:比如Go语言中文件描述符用到了finalizer的话,在文件描述符不再被使用时,即使用户忘记了手动关闭文件描述符,在垃圾回收时也可以自动调用finalizer关闭文件描述符

finalizer可以将资源的释放托管给垃圾回收

栈扫描

每个栈帧函数的参数和局部变量都需要进行扫描,确认确认对象是否在使用,如果在则需要扫描位图判断对象是否包含指针

栈对象

栈对象是在栈上能够被寻址的对象。在垃圾回收期间,所有的栈对象都会存储到一棵二叉搜索树中

扫描灰色对象

在进行根对象扫描时,会将标记的对象放入本地队列中,放不下则放到全局队列中。 为了实现更快的查找,Go语言在内存分配时记录了对象中是否包含指针等元信息

其中有个重要的bitmap字段用位图的形式记录了每个指针大小的内存中的信息。每个指针大小的内存都会有两个bit位表示当前内存是否应该继续扫描以及是否包含指针

标记终止阶段

  • 重要任务是计算下一次触发GC时需要达到的堆目标,即调步算法

  • 统计用时、关闭写屏障、唤醒清扫协程等等

  • 下次GC触发率=上次GC触发率+1/2\times偏差率

  • 默认目标内存是上一次目标内存的两倍
  • 触发内存=触发率*目标内存(触发率不能大于0.95也不能小于0.6)

辅助标记

  • 用户协程内存分配速度快到后台标记协程来不及扫描时,GC标记阶段永远不会结束
  • 由于用户协程被分配了超过限度的内存而不得不将其暂停并切换到辅助标记工作
  • X = M * assistWorkPerByte(X表示后台标记协程需要多扫描的内存,M为新分配的内存
  • 在GC并发标记阶段,当用户协程分配内存时,先检查是否已经完成了指定的扫描工作,当前协程中的gcAssistBytes代表当前协程可以被分配的内存大小,类似资产池,当gcAssistBytes<0时,会尝试从全局资产池中获取

  • 全局资产池可以容忍用户协程分配的内存数量为M=X/assistWorkPerByte

  • 如果既无法从本地资产池也无法从全局资产池中获取资产,那么需要停止工作协程,执行辅助标记协程。需要额外扫描的内存大小为M * assistWorkPerByte

  • 如果辅助标记完成后,本地仍然没有足够的资产,则可能是因为当前协程被抢占,也可能是因为当前逻辑处理器的工作池中没有多余的标记工作

  • 如果是因为当前逻辑处理器的工作池中没有多余的标记工作,那么会陷入休眠状态,当后台协程扫描了足够的任务后,刷新全局资产池并将等待中的协程唤醒

屏障技术

解决并发标记的准确性问题:

  • 强三色不变性:白色对象都不能被黑色对象引用
  • 弱三色不变性:允许白色对象被黑色对象引用,但是白色对象必须有一条路径始终被灰色对象引用,即保证该对象能够被扫描到

屏障技术就是来维护三色不变性的,原则是在写入或删除对象时将可能活着的对象标记为灰色

  • 插入屏障:

    Write(src, i, ref):
        src[i] <- ref
        if ifBlack(src)
          shade(ref)
    
  • 删除屏障:

当然,它们都存在浮动垃圾问题。插入屏障在删除引用时可能标记一个已经变成垃圾对象。而删除屏障在删除引用时可能标记一个已经变成垃圾对象,但不会影响其准确性,因为浮动垃圾会在下一次垃圾回收中被回收

Go1.8之后使用了混合写屏障

  • 混合写屏障:要想在标记终止阶段不重新扫描根对象,需要使用写屏障和删除屏障混合的屏障技术

    WritePointer(slot, ptr):
        shade(*slot)
        shade(ptr)
        *slot = ptr
    

    编译器会在所有堆写入或者删除操作前判断当前是否为垃圾回收标记阶段,如果是则会执行对应的写屏障标记对象

垃圾清扫

  • 清扫协程的状态变为running,在结束STW阶段并开始重新调度循环时优先清扫协程
  • 垃圾清扫采取了懒清扫的策略,即执行少量清扫工作后,通过Gosched函数让渡自己的执行权利,不需要一直执行

懒清扫逻辑

清扫是以span为单位进行的

辅助清扫

会在两个阶段判断是否需要辅助扫描:

  1. 向mcentrel申请内存
  2. 大对象分配

系统驻留内存清除

一次只清除一个物理页

  • 为了将系统分配的内存保持在适当的大小,同时回收不再被使用的内存,Go使用了单独的后台清扫协程来清除内存。后台协程是在程序开始启动的,并且只启动一个
  • 清除策略占用当前线程CPU 1% 的时间进行清除
  • 在开始清除扫描时,会查找searchAddr所在的chunk块中是否存在即空闲又没有被清除的连续空间,如果查找不到,则通过基数树从上到下进行扫描,找到符合条件要求的区域