垃圾收集器(GC)

227 阅读12分钟

这是我参与2022首次更文挑战的第13天,活动详情查看:2022首次更文挑战

主要有两种收集方式:

  • 手动管理:C、C++、Rust,由工程师手动控制

  • 自动管理: Python、Ruby、Java、Go

    • 类似stop the word

上一节了解了内存分配器,这次来了解一下垃圾收集器,回收堆上的内存空间

分配与回收,主宰了整个内存空间的生与死

设计原理

要回收的不一定是垃圾,但是一定是不要的东西,如何判断其是否不要?

标记清除

也即Mark-Sweep,跟踪式垃圾收集器,标记着不再需要的东西,再将其对应的空间加入到空闲链表,对象不是不在了,而是没法再被找到

分为两个阶段进行清除:

  1. 标记,任何东西的存在都是有痕迹的,程序也不例外,在被使用的对象,会被程序级级引用,从根对象出发,标记一路上使用到的东西
  2. 清除,遍历堆中所有的对象,没被标记的都会被清除,加入到空闲空间的链表

如果stop the word,是没有办法完全进行标记的

栈上的对象也是根对象

三色抽象

多数收集器都会使用三色抽象,缩短长时间的STW,只有三种颜色:

  • 黑:活跃对象,没有引用外部指针或者根对象可达的对象
  • 白:潜在的垃圾,可能会被回收
  • 灰:活跃对象,存在指向白色的指针,收集器会进行这些对象的扫描

一开始还没有颜色,收集器不知从何开始扫描,因此根对象为灰,三个步骤:

  1. 从灰集合中开始,标记为黑
  2. 将所有黑指向的对象标记为灰
  3. 重复直到世上不再有灰

完成后就只剩黑和白,收集器回收掉白

仍旧需要STW,如果用户在回收期间改变了对象指针就难以标记一些新增的对象引用导致错误回收

无法实现并发、增量回收

屏障技术

一种屏障指令

  • CPU以及编译器执行内存相关操作的时候遵守特定的约束
  • 保证一定的顺序性,屏障前执行的指令一定先于屏障后执行的指令

并发、增量回收的条件:下面二选一

  • 强三色不变性:黑不会指向白,只会指向灰、黑
  • 弱三色不变性:黑可指向白,但被指向的白必然存在另外一条路径是从灰开始经过多个白到达的

屏障技术通过维护上述不变性来实现并发、增量回收

类似钩子函数,在程序进行对象创建、更新、读取的时候会执行的代码,这多出来的代码会影响执行效率

两种屏障:

  • 写屏障
  • 读屏障

go使用的是写屏障:

  • Dijkstra的插入写屏障
  • Yuasa的删除写屏障

是指针对程序对对象引用关系的操作,如何改变关联对象的颜色 0

这里面要论证两个事情:

  • 写屏障如何保证不变性
  • 不变性如何保证并发、增量删除的正确性

参考:golang.design/under-the-h…

插入写屏障

核心思想:避免黑指向白,因此在新对象被黑指之前,需要变成灰

在用户修改对象指向的指针的时候会触发以下代码

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

其中shade会修改参数指针为灰色,该对象的后续对象为白色

ptr为白色,会转变为灰色

  • 能够保证改变指向后的对象不会被删除

  • 但是之前的对象由于没有被删除原有的指向关系,最终依旧变成了黑色

  • 较为保守部分不会被使用到的对象也会被标记为黑色

  • 栈上的对象的修改操作都会引起插入写屏障的话,性能很不好

    • 回收过程始终标记栈为恒灰的话需要STW

删除写屏障

在用户修改对象指向的指针的时候会触发以下代码

writePointer(slot, ptr)
    shade(*slot)
    *slot = ptr

shade会把参数原来指向的对象颜色给标记成灰色,该对象的后续对象为白色

  • 适用于栈上对象的回收
  • 由于存在重新标记成灰、白的行为,因此会产生重复的冗余扫描

混合写屏障

go1.8之后采用了如下的混合写屏障

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

由于删除写屏障没有对栈开启,可以通过简单的将栈引用白色对象,就可以让白色对象躲过一轮GC

在混合写屏障中,一旦栈变成黑色,那么后续被栈引用的对象也必须为黑色

2016年的时候似乎还未实现

增量与并发

长时间STW对于实时的引用往往是难以接受的,可以通过以下两个方面对收集器进行优化

  • 增量收集:增量标记、清除垃圾
  • 并发收集:并发收集清除垃圾

需要以下几个东西来保证

  • 写屏障来保证正确性
  • 并不是等到内存溢出才进行回收

并发、增量收集

保证三色不变性时,可以保证正确性

  • 打开写屏障

    • 一定程度降低程序效率
  • 增量GC中新增的GC也是会有时间消耗的

演进过程

并发垃圾收集

标记的过程和程序运行同时运行!

上述已经讲了并发收集的正确性保证,这里主要是叙述其工作流程

并发收集的前提就是多核

执行过程:

  • 合适的时机触发回收

    • 需要部分计算资源进行扫描标记内存中的对象
  • 启动标记程序

  • 启动写屏障

  • 当申请内存的速度超过了扫描速度

    • 会导致扫描一直无法结束
    • 需要申请内存的程序辅助完成垃圾的扫描
  • 异步清理,增量回收内存

1.6中将垃圾的清理过程变成了状态机,每一个状态有不同的动作

回收堆目标

Go的默认配置当堆内存达到上次清理后内存的两倍时,出发新一轮的GC

  • 可通过GOGC环境变量改变

由于时并发进行的,GC没有办法精确控制堆内存大小(并发回收),只能在达到目标前触发回收

  • 如何计算什么时候触发?

    • Pacing算法(垃圾收集调度算法)
    • 在节约程序的计算资源的同时,防止堆超过预期大小

实现原理

执行周期:清除终止、标记、标记终止、清除

  • 清除终止

    • 暂停程序,处理器进入safe point
    • 如当前GC为强制触发,部分处于特殊状态的mspan需要被处理
  • 标记状态

    • 将状态切换到标记状态

    • 开启写屏障

    • 根对象入队

    • 标记进程、用户程序辅助进程并发进行内存的标记

      • 覆写的对象会被标记为灰(写屏障)
      • 新对象会被标记为黑(新的一般都被引用了)
    • 扫描根对象**(G的栈、全局对象、不在堆重的运行时对象)**

      • 扫描栈期间的时候会STW
    • 循环处理灰色队列中对象

      • 标记为黑,将指向的对象标记为灰
      • 分布式算法检查工作进度
  • 标记终止阶段

    • **STW,**关闭辅助程序
    • 清理处理器的mcache
  • 清理阶段

    • 关闭写屏障
    • 恢复用户程序,新创建的对象被标记为白
    • 后台并发清理mspan,申请内存的时候会触发清理

触发时机

  • 由runtime.gcTrigger.test方法来决定

  • 有两种触发条件

    • 触发条件1:(系统轮询)

      • 允许垃圾收集
      • 程序没有崩溃
      • 一段时间内没有触发垃圾收集
    • 触发条件2:(惰性清理)

      • 当前使用内存为上次GC后内存的2倍(倍数可配置)

上述的变量值可以通过全局变量获得

当触发条件满足的时候,会调用runtime.gcStart,开始标记阶段,而一般带有gcTrigger的地方,都会是触发gc的地方:

  • sysmon、forcegchelper会定期轮询(系统轮询)
  • GC由用户手动触发(主动触发)
  • mallocgc在进行内存分配时,根据堆内存大小触发GC(惰性触发)

foregchelper后台触发

在用户程序启动的时候启动的后台G,只有触发GC一个功能

在循环中主动陷入休眠等待被唤醒

在sysmon系统监控G中,当条件满足时,会唤醒forcegchelper G,塞进全局待运行G

  • 将后台监控G和GC G分离,可以并发进行,防止阻塞sysmon

手动触发GC

阻塞调用

  • 需要等待上一轮GC完成

  • 调用gcstart,等待标记的完成

  • 循环调用sweepone,直到清理完所有待处理的mspan

    • 在完成这一步之后,可以将当前处理器让出来了
    • 用户程序在进行申请内存的时候,也会惰性触发处理mspan
  • 可以发布当前阶段内存情况的快照

惰性触发

在进行对象内存分配的时候可能也会触发GC

在以下情况下会触发GC:

  • 线程mcache中不存在空闲空间,创建微对象、小对象从mcentral中获取
  • 申请32KB以上的大对象

GC启动

  • 首先是执行gcStart

    • 主要是一些准备工作

    • 完成上次GC清理阶段没有清理完成的mspan

    • 收集GC扫描所需要的参数

    • gcBgMarkStartWorkers 会为每个P创建一个并发辅助GC Mark G,等待被唤醒

      • 该G有三种模式

        • 专门标记——不会被抢占
        • 使用率达标——当GC Mark占用的CPU使用率不到25%时,启用
        • 空闲执行——P空闲就GC Mark
      • schedule每轮会调用findRunnabledGCWorker获取应该执行的后台GC Mark G

      • 最后的 开始标记 中会根据当前的CPU数、GC使用率来决定上述不同G的量

        • 将对应的G放到gcMarkWorker中,后续能被schedule中的find RunnabledGCWorker获取
    • 暂停用户程序

      • 获取全局所有的P
      • 停止当前P
      • 等待系统调用的P
      • 将所有的标记为GC暂停中
    • 开启后台标记工作

    • 扫描栈上、全局变量等根对象,加入扫描队列

    • 开启用户程序、标记任务可以将对象涂黑(开启写屏障)

    • 启动用户程序

      • 从网络轮询器中获取需要处理的任务
      • 调整全局P数量大小
      • 唤醒、分配P
    • 后台任务也会开始标记堆中对象

并发标记与辅助标记的标记过程

也即gcBgMarkWorker的执行

  • 获取当前M,将当前的G打包成gcBgMarkNode

  • 主动进入休眠,等待被唤醒

    • 需要等待GC控制器的直接唤醒
    • 唤醒后会被塞到gcBgMarkerPool中
  • 根据P上的gcMarkWorkerMode决定扫描策略

    • 就上面那三种模式
    • 然后进入gcDrain开始扫描标记
  • 所有标记任务完成后调用gcMarkDone

    • 即所有的标记任务都陷入等待并且没有剩余工作

工作池

在调用gcDrain的时候会传入gcWork(工作池)

  • 工作池Work是个全局对象

    • 管理灰色对象等

    • 全局的Work管理多个独立的gcWork

      • 引入了工作窃取机制
    • 写屏障、根对象扫描、栈扫描都会用到gcWork

提供生产者消费者抽象

  • 有两个缓存区,当主没有或者满了,切到备,两个都没有,尝试从全局的缓冲区操作

扫描对象

gcDrain中的操作

扫描工作缓冲区中的灰色对象

  • 设置当前的运行策略

    • 那三种,决定了什么时候重新进入睡眠
  • 标记根对象

    • markroot方法会扫描缓存、数据段、存放全局以及静态变量的BSS段、G的栈内存
  • 完成后,从gcWork中获取待执行的任务

    • 三色标记的过程

写屏障

通过全局变量writeBarrier.enable字段控制是否启用写屏障

会调用gcWriteBarrier(汇编代码)

混合写屏障开启后,新对象都会被标记为黑

  • 在mallocgc的入口处就有判断

辅助标记

为了保证用户程序分配速度小于等于后台的标记速度:申请的内存大于当前G辅助标记的字节数时,就需要借债了

  • gcAssistBytes为本G辅助标记的字节数

  • bgScanCredit为后台G辅助标记的字节数

  • 通过gcAssistAlloc借债

    • 计算借出去的字节数需要完成标记的任务数量

    • 如果全局的信用不足以覆盖,那么会直接调用gcDrain完成指定数量的标记(借钱的G去干活)

    • 完成后还是处于全局信用不足的状态

      • 当前G陷入睡眠
      • 加入全局的辅助标记队列,一起赚钱还债
  • 通过gcFlushBgCredit还债

    • 如果辅助队列中不存在休眠等待的G,直接加到全局信用

    • 如果有休眠的G,那么用当前的钱能换几个G是几个

      • 钱有多,加到全局信用

标记终止

  • 当前gcWork中没有任务时(尝试从全局窃取之后),schedule不会再唤醒GC Mark G
  • 当没有任务,且所有的GC Mark G都处于睡眠时,GC Mark完成了
  • 关闭写屏障
  • 唤醒所有协助垃圾收集的用户程序
  • 进入标记终止阶段,开始内存清理

内存清理

  • mspan中释放白的对象

    • 可以被异步触发(比如申请内存的时候)
  • 如果整个mspan都是白的,直接整个被回收

    • 也可以被异步触发(比如向堆申请内存的时候)