Golang GC 演进之路

Golang GC 演进之路

前言:网上关于GC 的文章错综复杂,有的文章前后混乱,自相矛盾;有的文章讲的内容较为浅显,看完以后还是一脸懵,所以花了一周时间,翻阅了大量文章,结合runtime 下的GC源码,对关于GC的知识进行了一下整理,方便以后回顾:
个人更喜欢用Notion写文章
Notion文章链接 :Gc演进之路
如有错误之处,欢迎指点。

一、标记清除算法

在golang 1.3之前的时候主要用的普通的标记-清除算法,此算法主要有以下两个步骤:

  • 标记(Mark phase)
  • 清除(Sweep phase)
  • 具体步骤如下:
    1. 暂停业务逻辑(STW),分类出可达和不可达对象,做出标记
      在这里插入图片描述

      目前程序的可达对象有: 1-2-3 4-7 五个对象

    2. 开始标记,程序对所有可达的对象做上标记
      在这里插入图片描述

      1-2-3 4-7 等五个可达对象做上标记

    3. 标记完成后,清除所有未标记对象

      在这里插入图片描述

      对象 5 6 不可达,被GC 清除

    4. 关闭STW,恢复业务程序。然后循环重复这个过程,直到Process 程序生命周期结束。

整个流程非常简单,但是有一点需要额外注意:mark and sweep 算法在执行的过程中,需要暂停程序!即STW(stop the word) ,STW过程中,CPU不执行代码逻辑,全部用于垃圾回收,这个过程的影响很大,所以STW也是一些回收机制最大的难题和希望优化的点。

标记清除算法缺点:

  • STW,让整个程序暂停,程序会出现严重卡顿
  • 标记需要扫描整个heap
  • 清除数据会产生heap碎片

Go V1.3 变动

执行GC的基本流程首先就是启动STW,然后才进行标记清除,最后暂停STW,如下图:
在这里插入图片描述

从整个流程来看,全部的GC时间都是处在STW范围之内的,这样程序暂停的时间过长,影响程序的性能。所以在Go V1.3 做了简单的优化,将STW 的停止时间提前,这样能减少STW的时间范围,如图所示:
在这里插入图片描述

不论如何优化,这个版本的GC都会面临一个严重的问题,就是 Mark And Sweep 算法会暂停整个程序。为了面对并解决这个问题,在Go 1.5版本就采用 三色标记法 来优化这个问题。

二、三色标记法

Golang中的垃圾回收主要应用三色标记法,GC过程和其他用户goroutine可并发运行,但需要一定时间的STW,所谓的三色标记法实际上就是通过三个阶段的标记来确定需要清除的对象都有哪些。

  • 具体步骤如下:
    1. 所有新建对象,默认都标记为白色

      在这里插入图片描述

      上图所示,我们程序可抵达的内存对象关系如左图,右边的标记表,是用来记录目前每个对象的标记颜色分类的。这里面的 程序 指的是一些对象的根节点集合。所以如果我们将”程序” 展开来,会得到类似如下的表现形式:

      在这里插入图片描述

    2. 每次GC回收开始,会从根节点开始遍历所有对象,把遍历到的对象全部标记为灰色(放入灰色的集合)

      在这里插入图片描述

      遍历root根节点集合,标记可达的节点为灰色。(这里的遍历非递归遍历,只遍历一次根节点,如图所示,当前可达的对象是 对象1对象4,那么本轮遍历就结束了,对象1对象4 被标记为灰色,灰色表就会多出这两个对象)

    3. 遍历灰色集合,将灰色对象引用的对象标记为灰色,然后将灰色对象自身标记为黑色,如图所示:

      在这里插入图片描述

      这一次遍历只扫描灰色对象,将灰色对象可直接抵达的白色对象标记为灰色,移动到灰色表:如 对象2对象7 ,而之前的灰色 对象1对象4 则会被标记为黑色,由灰色表移动到黑色标记表中;

      这一步要循环进行,直到灰色标记表中没有灰色对象存在
      在这里插入图片描述

      重复上一步,直到灰色标记表中没有任何对象

      在这里插入图片描述

      当我们全部的可达对象都遍历后,将不再存在灰色对象,目前内存全部数据只有两种颜色,黑色和白色。黑色对象就是我们所有的可达对象,白色对象全部是不可达对象,所以白色对象就是目前内存中需要被清除的对象。

    4. 回收所有白色对象
      在这里插入图片描述

      将所有白色对象清除

以上就是 三色并发标记法,这里会有很多并发流程被扫描,执行并发流程的内存可能相互依赖,为了在GC过程中保证数据的安全,我们在开始三色标记之前就会进行STW,在扫描确定黑白对象之后暂停STW

很明显这样的GC扫描性能实在是太低了,我们能不能不进行STW哪?

三、如果三色标记法不进行STW

理论上来说,如果整个GC的过程都没有STW,那么也就不会再有性能上的问题

如果没有STW,会发生什么?

  1. 我们把初始状态设置为已经经历了一轮扫描,目前黑色的有对象1对象4,灰色的有对象2对象7,其他的为白色对象,且对象2 引用了对象3的,如图所示:

在这里插入图片描述

  1. 如果三色标记的过程中没有启动STW,那么在整个GC扫描的过程中,任意对象均能发生 读写操作,如下图所示,在还没有扫描到对象2的时候,已经标记为黑色的对象4 引用了对象3
    在这里插入图片描述

  2. 同一时刻,灰色的对象2移除了对对象3的引用,这个时候白色的对象3实际上是挂在对象4下面的,如下图所示:
    在这里插入图片描述

  3. 按照三色标记法的算法逻辑,此时扫描到对象2的时候,对象2下面已经没有可达对象,白色的对象3就不会被标记为灰色,对象2会最终被标记为黑色,如下图所示:
    在这里插入图片描述

  4. 这个时候灰色标记表中已经没有灰色对象,标记完成,清理所有白色对象
    在这里插入图片描述

  5. 这个时候我们会发现,本来对象4合法的应用了对象3对象3不应该被回收的,却被GC给回收了。并且如果对象3下游还有很多可达的对象,也都会一并被清理掉,发生严重的内存泄露问题。

通过以上例子可以看出来,有两种情况,在三色标记法中是不希望被发生的:

  1. 黑色对象直接引用白色对象**(**白色被挂在黑色下,如下图对象4引用对象3)

    在这里插入图片描述

  2. 灰色对象与它可达关系的白色对象遭到破坏(灰色丢失了白色对象,如下图对象2解除了和对象3的引用关系)

    在这里插入图片描述

当以上两个条件同时发生,就**有可能**会出现对象丢失的现象

为了防止这种现象发生,最简单的方式就是STW,直接暂停程序禁止所有引用关系的变更,但是**STW的过程有明显的资源浪费,对整个程序有很大的影响。**那么是否可以在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间哪?

四、引进屏障机制

实际上只要我们让GC回收器,满足下面**两种规则之一**时,就可以保证对象不丢失。

  • 强三色不变式

    不存在黑色对象直接对白色对象的引用
    在这里插入图片描述

    如上图:黑色对象4对白色对象3的引用不被允许

  • 弱三色不变式

    所有被黑色对象引用的白色对象都处于灰色保护状态(灰色对象的可达对象)

    在这里插入图片描述

    如上图:如果白色对象3灰色对象3保护(是灰色对象的可达对象),黑色对象4引用白色对象3是被允许的

为了满足上面两个规则,GC算法演进了两种屏障机制:

屏障机制仅对堆空间对象操作使用,对栈空间对象操作不使用。

  • 插入写屏障

    A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B 必须被标记灰色)

    伪代码如下:

    func writePointer(slot,ptr){
    	// 将被引用的对象标记为灰色
    	shade(ptr)
    	// 引用对象
    	*slot =ptr
    }
    

    调用场景:

    A.添加下游对象(nil, B)   //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
    A.添加下游对象(C, B)     //A 将下游对象C 更换为B,  B被标记为灰色
    

    满足规则:强三色不变式


    如果A引用B,那么shade(B),给B标记灰色。那么A是黑色的时候,即使B是白色的,也会被强制标记为灰色,就不存在黑色直接指向白色对象的问题,满足强三色不变式。

    • 过程模拟
      1. 所有创建的对象全部标记为白色,放入白色标记表中
        在这里插入图片描述

      2. 遍历root根节点,将 对象1对象4 标记为灰色,放入灰色标记表

        在这里插入图片描述

      3. 遍历灰色标记表,将可达对象从白色 标记为 灰色 , 遍历之后的灰色 标记为 黑色
        在这里插入图片描述

      4. 这个时候,由于并发特性,对象4 新建引用对象8对象1新建引用对象9
        在这里插入图片描述

      5. 因为对象1在栈区,不触发屏障规则;对象4在堆区,触发屏障规则,对象8标记为灰色

        在这里插入图片描述

      6. 循环上述流程,完成三色标记,直到灰色标记表中没有对象

        在这里插入图片描述

        栈不使用插入屏障,当全部三色标记扫描之后,栈上可能依然存在白色对象被引用的情况(如上图对象9),所以要对栈重新进行三色标记扫描,但这次为了对象不丢失,需要启用STW,直到栈空间三色标记结束。

      7. 在回收白色对象前,对栈空间进行re-scan 重新,扫描一遍,本次扫描启用STW保护栈(防止黑色对象直接引用白色对象)

        在这里插入图片描述

      8. 在STW 保护状态下,对栈中的对象进行一次三色标记,直到没有灰色对象
        在这里插入图片描述

      9. 停止STW

        在这里插入图片描述

      10. 最后将栈和堆空间 扫描剩余的全部 白色节点清除.

        在这里插入图片描述

    缺点:因为栈上对象屏障不适用,所以可能存在黑的的栈对象指向白色的堆对象,需要对栈空间进行re-scan 操作,且过程需要STW,来保证不丢对象。延迟不可控,平均大概10-100ms

  • 删除写屏障

    被删除的对象,如果自身为白色,那么被标记为灰色

    伪代码如下:

    func writePointer(slot,ptr){
    	// 将原本引用的对象标记为灰色
    	shade(*slot)
    	// 引用新的对象
    	*slot =ptr
    }
    

    调用场景:

    A.添加下游对象(nil, B) //A 之前没有下游, 新添加一个下游对象B
    A.添加下游对象(C, B) //A 将下游对象C 更换为B,  C 对象的引用被删除,将C对象标记为灰色
    

    满足规则:弱三色不变式


    假设A是黑对象,B是白色对象,初始状态C引用B,在标记的过程中,A引用了B,这个时候黑色直接引用白色对象,是被允许的,因为满足弱三色,B是被C引用保护的;当C不再引用白色对象B的时候,因为屏障机制,会把B标记为灰色,这个时候又满足强三色。

    • 过程模拟
      1. 所有新建的对象,统一标记为白色,放入白色标记表中
        在这里插入图片描述

      2. 遍历root根节点,将可达对象标记为灰色

        在这里插入图片描述

      3. 遍历灰色节点,将可达对象标记为灰色,自身标记为黑色

        在这里插入图片描述

      4. 新增对象1对象6的引用,删除对象5对象6的引用,如果不开启删除屏障6-7路径上的对象均会被清除回收

        在这里插入图片描述

      5. 开启删除屏障,对象6标记为灰色 ,保护了自身和下游可达对象不被清除

        在这里插入图片描述

      6. 循环遍历灰色标记表,完成三色标记
        在这里插入图片描述

        这样最后能保证被灰色对象5删除引用,但是被黑色对象1引用的 对象 6-7 都能被保留下来,不被清除回收

    缺点:

    1. 回收精度低,一个对象即使被删除了,它和它的可达对象依然可以存活,直到下一轮GC
    2. 在GC开始时,会扫描记录整个栈做快照,保证所有堆上在用对象都处于灰色保护状态

    go没有直接使用删除屏障,goV1.5用的写屏障,是因为删除屏障不适用于栈大的场景,栈越大,开始时期STW扫描时间越长。现代服务器上的程序,普遍的栈空间都不小,所以删除屏障不适用。一般适用于栈内存很小的地方,比如嵌入式、物联网等程序

    思考:如果在GC开始的时候,不STW暂停整个应用程序,而是改用一个栈一个栈的暂停进行扫描,保证栈的原子性,是不是就能将STW去除掉了?


    不可行,有可能会出现对象丢失,内存泄漏的风险。

    • 过程模拟
      1. 分别有 G1 G2 G3 三个栈及其引用对象关系如图,对其依次进行扫描

        在这里插入图片描述

      2. 先扫描G1,对其可达对象对象1 对象2进行标记,最后标记为黑色

        在这里插入图片描述

      3. 在扫描G2的过程中,堆上对象对象2 新增了对对象9的引用(这里是删除屏障,不做
        任何操作)

        在这里插入图片描述

      4. 还没有扫描到G3的时候,G3对象4 移除了对对象5的引用 ,因为是栈上的对象,所以这里也没有删除屏障,不做任何操作

        在这里插入图片描述

      5. 依次扫描,扫描完G3后,标记状态如下

        在这里插入图片描述

        因为对象2在之前已经标记为黑色了,不会再进行扫描

      6. 最后进行清除回收的时候,对象9对象5就被错误的回收了,实际上是被引用的对象

        在这里插入图片描述

    怎么解决这个问题哪?


    有以下两个方案可以解决栈空间解除引用关系导致的内存泄漏问题:

      1. 开始时STW,对栈空间扫描快照,将所有栈对象标记为黑色,可达的堆对象标记为灰色;之后配合堆对象删除写屏障就能保证对象不丢失( 删除写屏障的方案)

      这样即使栈对象解除了对任意对象的引用,被解除的对象也是处于被保护状态,在接下来的三色标记清除中,也不会发生被引用的对象被清除回收严重泄漏问题。

    • 2.加入插入写屏障的逻辑

      堆上对象对象2 新增对象9的引用时,根据写屏障逻辑,将对象9标记为灰色

      在这里插入图片描述

      这样对象9及它的可达对象也会被保护,满足强三色

    这里的方案二在不进行整体程序STW的的情况下,也能够保证GC的过程中对象不丢失,这就是目前在用的混合写屏障机制

五、混合写屏障机制

总结一下前面插入写屏障和删除写屏障的短板:

  • 插入写屏障:结束时需要STW来重新扫描栈;
  • 删除写屏障
    1. 回收精度低
    2. GC开始时STW 扫描堆栈记录初始快照,这个过程会保护开始时刻的所有存活对象

google工程师在Go V1.18对GC做了重大升级,引入了混合写屏障机制,从名字上就能看出来是同时开启删除屏障和插入屏障。这一升级避免了对栈re-scan的过程,同时在GC开始时也不用开启STW进行整栈扫描,结合了两者的优点。

具体流程:

  1. GC开始时扫描栈空间,将可达对象全部标记为黑色,这样可达的堆上对象会被标记为灰色(这里的扫描不是像删除屏障一样整栈扫描,而是一个栈一个栈的扫描,会先把被扫描的这个g挂起,扫描标记完再恢复,保证栈颜色改变的原子性),不需要STW
  2. 标记期间,所有新分配的对象(无论是堆上还是栈上)都会被标记为黑色,不会被清除
  3. 堆上被删除的对象标记为灰色
  4. 堆上被添加引用的对象标记为灰色

伪代码:

func writePointer(slot,ptr){
	// 将原本引用的对象标记为灰色
	shade(*slot)
	// 将新增的对象标记为灰色
	shade(ptr)
	// 引用新的对象
	*slot =ptr
}
  • 过程模拟

    GC开始时,扫描栈区,将所有可达对象全部标记为黑色

    在这里插入图片描述

    • 场景一:对象被一个堆对象删除引用,同事成为栈对象的下游

      1. 对象1新增引用对象7,因为对象1在栈上,不启用屏障机制,直接引用

        在这里插入图片描述

      2. 对象4删除对对象7的引用关系

        在这里插入图片描述

      3. 对象4 在堆上,根据删除屏障,对象7被标记为灰色

        在这里插入图片描述

    • 场景二:对象被一个栈对象删除引用,成为另外一个栈对象的下游

      1. 栈上新建对象9,根据屏障机制,标记阶段任何新建的对象都直接标记为黑色
        在这里插入图片描述

      2. 对象9 添加引用对象3 ,栈上不启用屏障,直接添加引用

        在这里插入图片描述

      3. 对象2 删除引用 对象3,栈上不启用屏障,直接删除引用

        在这里插入图片描述

    • 场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游

      1. 堆上新增对象9,任意新增对象均标记为黑色

        在这里插入图片描述

      2. 对象9新增引用对象7,根据插入屏障,对象7标记为灰色

        在这里插入图片描述

      3. 对象4删除引用对象7,根据删除屏障,对象7标记为灰色

        在这里插入图片描述

    • 场景四:对象从一个栈对象删除应用,成为另一个堆对象的下游

      1. 栈对象1 删除对栈对象2的引用,栈上无屏障,直接删除

        在这里插入图片描述

      2. 堆对象4新增对象2的引用(go里面这里的对象2在编译期间逃逸分析会判断内存逃逸到堆上),对象2已经被标记为黑色,直接添加引用
        在这里插入图片描述

      3. 堆对象4 删除引用对象7 ,根据删除屏障,对象7被标记为灰色

        在这里插入图片描述

    上面四种情况能涵盖标记过程中的所有对象的修改情况,在混合屏障机制下,能保证对象不会被误回收。

核心概念:

混合写屏障是一种同时标记和处理写操作的机制。它的目标是在垃圾回收的标记阶段,确保程序在运行时对对象的修改(特别是对指针引用的修改)不会导致垃圾回收器丢失对新对象的追踪。这种机制尤其在并发环境下很关键,防止黑色对象指向未被标记的白色对象(导致白色对象被错误地回收)。

优势:

  1. 并发标记:在标记阶段,GC和应用程序是并发进行的。虽然扫描栈的时候会短暂暂停被扫描的Goroutine,但是其他Goroutine任然可以继续运行,消减了STW时间。
  2. 整个GC扫描标记阶段都不需要整体的STW
  3. 标记完成后不需要对栈进行re-scan

缺点:

  1. 继承了删除屏障的缺点,回收精度低

六、总结

  • Go V1.3: 普通标记清除,整个过程需要启动STW,效率极低;
  • Go V1.5: 三色标记法,堆空间启动插入写屏障,栈空间不启动,全部扫描后,对栈空间进行re-scan(需要STW),效率一般;
  • Go V1.8: 三色标记法,混合写屏障,只在堆空间生效,栈空间不生效。整个过程几乎不需要STW,效率极高,大部分标记回收工作都是在程序运行时并行完成的,但仍需要在特定时刻暂停程序执行。具体来说:
    1. 开始时的 STW:暂停所有 Goroutine,确保所有线程进入安全点,启动并发栈扫描,并开启写屏障。
    2. 结束时的 STW:暂停 Goroutine,处理最后的标记和回收操作,关闭写屏障,确保所有存活对象都被正确标记。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

疯狂的程需猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值