在之前的文章给大家讨论过Golang世界中的内存管理机制内存管理,其实是为了GC垃圾回收做铺垫,在上一篇文章中,其实也有很多地方涉及到了GC的内容,之所以没有放在一起,是因为篇幅过长,GC在Go语言中,是一个非常重要的一个内容,在本章中,我们将继续探索golang世界中的内存世界。
本章内容参考小徐先生和刘丹冰老师的博客,加上个人注解,对应go的1.24.1版本,由于我的博客是在语雀,这里就附上我的语雀链接,方便大家更好的查看GC
一.垃圾回收算法
1.1 概念
垃圾回收(Garbage Collection,简称GC)是一种内存管理策略,由垃圾收集器以类似守护协程的方式在后台运作,按照既定的策略为用户回收那些不再被使用的对象,释放对应的内存空间。
来说说GC的优缺点吧
- 优点
- 屏蔽内存回收的细节
拥有 GC 能力的语言能够为用户屏蔽复杂的内存管理工作,使用户更好地聚焦于核心的业务逻辑.
- 以全局视野执行任务
现代软件工程项目体量与日剧增,一个项目通常由团体协作完成,研发人员负责各自模块的同时,不可避免会涉及到临界资源的使用. 此时由于缺乏全局的视野,手动对内存进行管理无疑会增加开发者的心智负担. 因此,将这部分工作委托给拥有全局视野的垃圾回收模块来完成,方为上上之策.
- 缺点
- 提高了下限但降低了上限
将释放内存的工作委托给垃圾回收模块,研发人员得到了减负,但同时也失去了控制主权. 除了运用有限的GC调优参数外,更多的自由度都被阉割,需要向系统看齐,服从设定.
- 增加了额外的成本
全局的垃圾回收模块化零为整,会需要额外的状态信息用以存储全局的内存使用情况. 且部分时间需要中断整个程序用以支持垃圾回收工作的执行,这些都是GC额外产生的成本.
- GC的总结
除开少量追求极致速度的特殊小规模项目之外,在绝大多数高并发项目中,GC模块都为我们带来了极大的裨益,已经成为一项不可或缺的能力.
1.2 常见的垃圾回收算法
(1)标记清除

这张图也很清晰,就做一个简单的说明吧:
标记清扫(Mark-Sweep)算法,分为两步走:
- 标记:标记出当前还存活的对象
- 清扫:清扫掉未被标记到的垃圾对象
这是一种类似于排除法的间接处理思路,不直接查找垃圾对象,而是标记存活对象,从而取补集推断出垃圾对象.
至于标记清扫算法的不足之处,通过上图也得以窥见一二,那就是会产生内存碎片. 经过几轮标记清扫之后,空闲的内存块可能零星碎片化分布,此时倘若有大对象需要分配内存,可能会因为内存空间无法化零为整从而导致分配失败.
(2)标记压缩

标记压缩(Mark-Compact)算法,是在标记清扫算法的基础上做了升级,在第二步"清扫"的同时还会对存活对象进行压缩整合,使得整体空间更为紧凑,从而解决内存碎片问题.
标记压缩算法在功能性上呈现得很出色,而其存在的缺陷也很简单,就是实现时会有很高的复杂度.
(3)半空间复制

相信用过 Java 的同学对半空间复制(Semispace Copy)算法并不会感到陌生,它的核心点如下:
- 分配两片相等大小的空间,称为 fromspace 和 tospace
- 每轮只使用 fromspace 空间,以GC作为分水岭划分轮次
- GC时,将fromspace存活对象转移到tospace中,并以此为契机对空间进行压缩整合
- GC后,交换fromspace和tospace,开启新的轮次
显然,半空间复制算法应用了以空间换取时间的优化策略,解决了内存碎片的问题,也在一定程度上降低了压缩空间的复杂度. 但其缺点也同样很明显------比较浪费空间
(4)引用计数

引用计数(Reference Counting)算法是很简单高效的:
- 对象每被引用一次,计数器加1
- 对象每被删除引用一次,计数器减1
- GC时,把计数器等于 0 的对象删除
然而,这个朴素的算法存在一个致命的缺陷:无法解决循环引用或者自引用问题.
二.Golang世界的GC机制
前文对垃圾知识做了一个简单的补充,那么接下来,就在此基础之上,我们来看看Golang的GC模块做了怎么样的设计和取舍。
我们就来看看go语言的在GC方面的变革吧
Golang在GC的演进过程中也经历了很多次变革,Go V1.3之前的标记-清除(mark and sweep)算法
2.1 标记清除算法
2.1.1 算法的具体步骤
标记清除法,在之前简单的说过,这里会举刘丹冰老师的例子,来方便我们加快记录和理解:
- 第一步,暂停程序业务逻辑, 分类出可达和不可达的对象,然后做上标记。

图中表示是程序与对象的可达关系,目前程序的可达对象有对象1-2-3,对象4-7等五个对象。
- 第二步, 开始标记,程序找出它所有可达的对象,并做上标记。如下图所示:

所以对象1-2-3、对象4-7等五个对象被做上标记。
- 第三步, 标记完了之后,然后开始清除未标记的对象. 结果如下。

操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 STW(stop the world)
,STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,所以STW也是一些回收机制最大的难题和希望优化的点。所以在执行第三步的这段时间,程序会暂定停止任何工作,卡在那等待回收执行完毕。
- 第四步, 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束。
以上便是标记-清除(mark and sweep)回收的算法。
2.1.2 标记清除算法的缺点
标记清除算法明了,过程鲜明干脆,但是也有非常严重的问题。
- STW,stop the world;让程序暂停,程序出现卡顿 (重要问题);
- 标记需要扫描整个heap;
- 清除数据会产生heap碎片。
Go V1.3版本之前就是以上来实施的, 在执行GC的基本流程就是首先启动STW暂停,然后执行标记,再执行数据回收,最后停止STW,如图所示。

从上图来看,全部的GC时间都是包裹在STW范围之内的,这样貌似程序暂停的时间过长,影响程序的运行性能。所以Go V1.3 做了简单的优化,将STW的步骤提前, 减少STW暂停的时间范围.如下所示

上图主要是将STW的步骤提前了一步,因为在Sweep清除的时候,可以不需要STW停止,因为这些对象已经是不可达对象了,不会出现回收写冲突等问题。
但是无论怎么优化,Go V1.3都面临这个一个重要问题,就是mark-and-sweep 算法会暂停整个程序 。
Go是如何面对并这个问题的呢?接下来G V1.5版本 就用三色并发标记法来优化这个问题.
2.2 三色标记法
2.2.1 具体介绍和流程
Golang中的垃圾回收主要应用三色标记法,GC过程和其他用户goroutine可并发运行,但需要一定时间的STW(stop the world) ,所谓三色标记法实际上就是通过三个阶段的标记来确定清楚的对象都有哪些?
先来阐述一下它的要点:
- 对象分为三种颜色标记:黑、灰、白
- 黑对象代表,对象自身存活,且其指向对象都已标记完成
- 灰对象代表,对象自身存活,但其指向对象还未标记完成
- 白对象代表,对象尙未被标记到,可能是垃圾对象
- 标记开始前,将根对象(全局对象、栈上局部变量等)置黑,将其所指向的对象置灰
- 标记规则是,从灰对象出发,将其所指向的对象都置灰. 所有指向对象都置灰后,当前灰对象置黑
- 标记结束后,白色对象就是不可达的垃圾对象,需要进行清扫.
接着看一下这个算法的执行流程
- 第一步 , 每次新创建的对象,默认的颜色都是标记为"白色",如图所示。

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

- 第二步, 每次GC回收开始, 会从根节点开始遍历所有对象,把遍历到的对象从白色集合放入"灰色"集合如图所示。

这里 要注意的是,本次遍历是一次遍历,非递归形式,是从程序抽次可抵达的对象遍历一层,如上图所示,当前可抵达的对象是对象1和对象4,那么自然本轮遍历结束,对象1和对象4就会被标记为灰色,灰色标记表就会多出这两个对象。
- 第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合,如图所示。

这一次遍历是只扫描灰色对象,将灰色对象的第一层遍历可抵达的对象由白色变为灰色,如:对象2、对象7. 而之前的灰色对象1和对象4则会被标记为黑色,同时由灰色标记表移动到黑色标记表中。
-
第四步 , 重复第三步 , 直到灰色中无任何对象,如图所示。
当我们全部的可达对象都遍历完后,灰色标记表将不再存在灰色对象,目前全部内存的数据只有两种颜色,黑色和白色。那么黑色对象就是我们程序逻辑可达(需要的)对象,这些数据是目前支撑程序正常业务运行的,是合法的有用数据,不可删除,白色的对象是全部不可达对象,目前程序逻辑并不依赖他们,那么白色对象就是内存中目前的垃圾数据,需要被清除。
-
第五步 : 回收所有的白色标记表的对象. 也就是回收垃圾,如图所示。
以上我们将全部的白色对象进行删除回收,
剩下的就是全部依赖的黑色对象。
以上便是三色并发标记法
,不难看出,我们上面已经清楚的体现三色
的特性。但是这里面可能会有很多并发流程均会被扫描,执行并发流程的内存可能相互依赖,为了在GC过程中保证数据的安全,我们在开始三色标记之前就会加上STW,在扫描确定黑白对象之后再放开STW。但是很明显这样的GC扫描的性能实在是太低了。
那么Go是如何解决标记-清除(mark and sweep)算法中的卡顿(stw,stop the world)问题的呢?
2.2.2 没有STW的三色标记法
可能会有同学就是纳闷,为什么非要让程序停下来扫描不可呢?没有他,也不会出现性能上的问题,可是为什么非要它不可能?
因为如果不暂停程序, 程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性,我们来看看一个场景,如果三色标记法, 标记过程不使用STW将会发生什么事情?
没有STW机制的三色标记法的可能流程
我们把初始状态设置为已经经历了第一轮扫描,目前黑色的有对象1和对象4, 灰色的有对象2和对象7,其他的为白色对象,且对象2是通过指针p指向对象3的,如图所示。

现在如果三色标记过程不启动STW,那么在GC扫描过程中,任意的对象均可能发生读写操作,如图所示,在还没有扫描到对象2的时候,已经标记为黑色的对象4,此时创建指针q,并且指向白色的对象3。

与此同时灰色的对象2将指针p移除,那么白色的对象3实则就是被挂在了已经扫描完成的黑色的对象4下,如图所示。

然后我们正常指向三色标记的算法逻辑,将所有灰色的对象标记为黑色,那么对象2和对象7就被标记成了黑色,如图所示。

那么就执行了三色标记的最后一步,将所有白色对象当做垃圾进行回收,如图所示。

但是最后我们才发现,本来是对象4合法引用的对象3,却被GC给"误杀"回收掉了。
可以看出,有两种情况,在三色标记法中,是不希望被发生的。
- 条件1: 一个白色对象被黑色对象引用**(白色被挂在黑色下)**
- 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏**(灰色同时丢了该白色)**
如果当以上两个条件同时满足时,就会出现对象丢失现象!
并且,如图所示的场景中,如果示例中的白色对象3还有很多下游对象的话, 也会一并都清理掉。
为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响。那么是否可以在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?
答案是可以的,我们只要使用一种机制,尝试去破坏上面的两个必要条件就可以了。
2.3 屏障机制
为了解决上述的问题,于是乎就有了屏障机制,只要 满足下面两种情况的一种,就能保证不会丢失。
这两种方式就是"强三色不变式"和"弱三色不变式"
- 强三色不变式
意思就是不存在黑色对象引用到白色对象的指针,实际上就是强制性不允许黑色对象引用白色对象,这样就不会出现白色对象被误杀的情况了

- 弱三色不变式
所有被黑色对象引用的白色对象都处于灰色的保护状态。
弱三色不变式强调,黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。 这样实则是黑色对象引用白色对象,白色对象处于一个危险被删除的状态,但是上游灰色对象的引用,可以保护该白色对象,使其安全。

为了遵循上述两个方式,GC 算法演进到两种屏障模式,分别是插入屏障和删除屏障。
2.3.1 插入写屏障

具体操作:就是在A对象引用B对象的时候,此时B对象会被标记为灰色
满足:强三色不变式(不存在黑色对象引用白色对象的情况,因为白色会强制变成灰色)
栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用, 所以"插入屏障"机制,在栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中.
但是如果栈不添加,当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况
所以要对栈重新进行三色标记扫描, 但这次为了对象不丢失, 要对本次标记扫描启动STW暂停. 直到栈空间的三色标记结束.
2.3.2 删除写屏障

与之相对的,删除写屏障就是为了实现弱三色不变式。
具体操作:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色
保证当一个白色对象即将被上游删除引用前,会触发屏障将其置灰,之后再删除上游指向其的引用.
2.3.3 总结
插入写屏障和删除写屏障的短板:
- 插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
- 删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。
因为栈对象可能涉及频繁的轻量操作,倘若这些高频率的操作都需要一一触发屏障,那不炸了嘛。所以通过STW来对栈对象进行兜底。
2.4 混合写屏障
到头来还是没有完全避免掉这个STW,在后续为了彻底消除这个STW成本,Golang 1.8 引入了混合写屏障机制,可以视为糅合了插入写屏障+删除写屏障的加强版本,要点如下:
- GC 开始前,以栈为单位分批扫描,将栈中所有对象置黑
- GC 期间,栈上新创建对象直接置黑
- 堆对象正常启用插入写屏障
- 堆对象正常启用删除写屏障
注意混合写屏障是Gc的一种屏障机制,所以只是当程序执行GC的时候,才会触发这种机制。
他是满足:变形的弱三色不变式。
简单总结一下混合写屏障:是一种优化计数,用于解决三色标记法中的并发问题。当GC和程序并发运行时,可能会出现新引用导致对象状态不一致的情况。
来看下混合写屏障的具体场景分析
依旧拿刘丹冰老师的案例:(老师的例子确实很形象)


场景一
对象被一个堆对象删除引用,成为栈对象的下游
//前提:堆对象4->对象7 = 对象7; //对象7 被 对象4引用
栈对象1->对象7 = 堆对象7; //将堆对象7 挂在 栈对象1 下游
堆对象4->对象7 = null; //对象4 删除引用 对象7


这个情况下,由于栈无保障机制,所以是建立正常的引用,无需额外操作,但是当对象4删除对象7的时候,会触发删除写屏障,直接就将对象置为灰色。
场景二
一个堆对象删除引用,成为另外一个堆对象的下游
堆对象10->对象7 = 堆对象7; //将堆对象7 挂在 堆对象10 下游
堆对象4->对象7 = null; //对象4 删除引用 对象7



对象10(黑色)要想引用白色,就会触发插入写屏障,将对象7变为灰色,从而保护对象6.
场景三
一个栈对象删除引用,成为堆对象的下游
堆对象10->对象7 = 堆对象7; //将堆对象7 挂在 堆对象10 下游
堆对象4->对象7 = null; //对象4 删除引用 对象7



这里会触发删除写屏障,从而直接将对象7变为灰色。
场景四
一个栈对象删除引用,成为另一个栈对象的下游
new 栈对象9;
对象8->对象3 = 对象3; //将栈对象3 挂在 栈对象9 下游
对象2->对象3 = null; //对象2 删除引用 对象3



由于栈对象都是黑的,就没有任何的影响。
2.5 总结
- GoV1.3- 普通标记清除法,整体过程需要启动STW,效率极低。
- GoV1.5- 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通
- GoV1.8-三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。