Golang GC 演进之路
前言:网上关于GC 的文章错综复杂,有的文章前后混乱,自相矛盾;有的文章讲的内容较为浅显,看完以后还是一脸懵,所以花了一周时间,翻阅了大量文章,结合runtime
下的GC源码,对关于GC的知识进行了一下整理,方便以后回顾:
个人更喜欢用Notion写文章
Notion文章链接 :Gc演进之路
如有错误之处,欢迎指点。
一、标记清除算法
在golang 1.3之前的时候主要用的普通的标记-清除算法,此算法主要有以下两个步骤:
- 标记(Mark phase)
- 清除(Sweep phase)
- 具体步骤如下:
-
暂停业务逻辑(
STW
),分类出可达和不可达对象,做出标记
目前程序的可达对象有:
1-2-3
4-7
五个对象 -
开始标记,程序对所有可达的对象做上标记
对
1-2-3
4-7
等五个可达对象做上标记 -
标记完成后,清除所有未标记对象
对象
5
6
不可达,被GC 清除 -
关闭
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
,所谓的三色标记法
实际上就是通过三个阶段的标记来确定需要清除的对象都有哪些。
- 具体步骤如下:
-
所有新建对象,默认都标记为白色
上图所示,我们程序可抵达的内存对象关系如左图,右边的标记表,是用来记录目前每个对象的标记颜色分类的。这里面的
程序
指的是一些对象的根节点集合。所以如果我们将”程序” 展开来,会得到类似如下的表现形式: -
每次GC回收开始,会从根节点开始遍历所有对象,把遍历到的对象全部标记为灰色(放入灰色的集合)
遍历
root根节点集合
,标记可达的节点为灰色
。(这里的遍历非递归遍历,只遍历一次根节点,如图所示,当前可达的对象是对象1
和对象4
,那么本轮遍历就结束了,对象1
和对象4
被标记为灰色,灰色表就会多出这两个对象) -
遍历灰色集合,将灰色对象引用的对象标记为灰色,然后将灰色对象自身标记为黑色,如图所示:
这一次遍历只扫描灰色对象,将灰色对象可直接抵达的
白色对象
标记为灰色
,移动到灰色表:如对象2
、对象7
,而之前的灰色对象1
、对象4
则会被标记为黑色,由灰色表移动到黑色标记表中;这一步要循环进行,直到灰色标记表中没有
灰色对象
存在
重复上一步,直到灰色标记表中没有任何对象
当我们全部的可达对象都遍历后,将不再存在灰色对象,目前内存全部数据只有两种颜色,黑色和白色。黑色对象就是我们所有的可达对象,白色对象全部是不可达对象,所以白色对象就是目前内存中需要被清除的对象。
-
回收所有白色对象
将所有白色对象清除
-
以上就是 三色并发标记法
,这里会有很多并发流程被扫描,执行并发流程的内存可能相互依赖,为了在GC
过程中保证数据的安全,我们在开始三色标记之前就会进行STW
,在扫描确定黑白对象之后暂停STW
。
很明显这样的GC扫描性能实在是太低了,我们能不能不进行STW
哪?
三、如果三色标记法不进行STW
理论上来说,如果整个GC的过程都没有STW,那么也就不会再有性能上的问题
如果没有STW,会发生什么?
- 我们把初始状态设置为已经经历了一轮扫描,目前黑色的有
对象1
和对象4
,灰色的有对象2
和对象7
,其他的为白色对象,且对象2
引用了对象3
的,如图所示:
-
如果三色标记的过程中没有启动
STW
,那么在整个GC扫描的过程中,任意对象均能发生 读写操作,如下图所示,在还没有扫描到对象2的时候,已经标记为黑色的对象4
引用了对象3
-
同一时刻,灰色的
对象2
移除了对对象3
的引用,这个时候白色的对象3
实际上是挂在对象4
下面的,如下图所示:
-
按照三色标记法的算法逻辑,此时扫描到
对象2
的时候,对象2下面已经没有可达对象,白色的对象3
就不会被标记为灰色,对象2
会最终被标记为黑色,如下图所示:
-
这个时候灰色标记表中已经没有灰色对象,标记完成,清理所有白色对象
-
这个时候我们会发现,本来
对象4
合法的应用了对象3
,对象3
不应该被回收的,却被GC给回收了。并且如果对象3
下游还有很多可达的对象,也都会一并被清理掉,发生严重的内存泄露问题。
通过以上例子可以看出来,有两种情况,在三色标记法中是不希望被发生的:
-
黑色对象直接引用白色对象
**(**白色被挂在黑色下,如下图对象4引用对象3) -
灰色对象与它可达关系的白色对象遭到破坏
(灰色丢失了白色对象,如下图对象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是白色的,也会被强制标记为灰色,就不存在黑色直接指向白色对象的问题,满足强三色不变式。- 过程模拟
-
所有创建的对象全部标记为白色,放入白色标记表中
-
遍历root根节点,将
对象1
和对象4
标记为灰色,放入灰色标记表 -
遍历灰色标记表,将可达对象从
白色
标记为灰色
, 遍历之后的灰色
标记为黑色
-
这个时候,由于并发特性,
对象4
新建引用对象8
,对象1
新建引用对象9
-
因为
对象1
在栈区,不触发屏障规则;对象4
在堆区,触发屏障规则,对象8
标记为灰色 -
循环上述流程,完成三色标记,直到灰色标记表中没有对象
栈不使用插入屏障,当全部三色标记扫描之后,栈上可能依然存在白色对象被引用的情况(如上图
对象9
),所以要对栈重新进行三色标记扫描,但这次为了对象不丢失,需要启用STW
,直到栈空间三色标记结束。 -
在回收白色对象前,对栈空间进行
re-scan
重新,扫描一遍,本次扫描启用STW
保护栈(防止黑色对象直接引用白色对象) -
在STW 保护状态下,对栈中的对象进行一次三色标记,直到没有灰色对象
-
停止STW
-
最后将栈和堆空间 扫描剩余的全部 白色节点清除.
-
缺点:因为栈上对象屏障不适用,所以可能存在黑的的栈对象指向白色的堆对象,需要对栈空间进行
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标记为灰色,这个时候又满足强三色。
- 过程模拟
-
所有新建的对象,统一标记为白色,放入白色标记表中
-
遍历root根节点,将可达对象标记为灰色
-
遍历灰色节点,将可达对象标记为灰色,自身标记为黑色
-
新增
对象1
对对象6
的引用,删除对象5
对对象6
的引用,如果不开启删除屏障
,6-7
路径上的对象均会被清除回收 -
开启
删除屏障
,对象6标记为灰色
,保护了自身和下游可达对象不被清除 -
循环遍历灰色标记表,完成三色标记
这样最后能保证被灰色对象5删除引用,但是被黑色对象1引用的
对象 6-7
都能被保留下来,不被清除回收
-
缺点:
回收精度低
,一个对象即使被删除了,它和它的可达对象依然可以存活,直到下一轮GC- 在GC开始时,会扫描记录整个栈做快照,保证所有堆上在用对象都处于灰色保护状态
go没有直接使用删除屏障,goV1.5用的写屏障,是因为删除屏障不适用于栈大的场景,栈越大,开始时期STW扫描时间越长。现代服务器上的程序,普遍的栈空间都不小,所以删除屏障不适用。一般适用于栈内存很小的地方,比如嵌入式、物联网等程序
思考:如果在GC开始的时候,不STW暂停整个应用程序,而是改用一个栈一个栈的暂停进行扫描,保证栈的原子性,是不是就能将STW去除掉了?
不可行
,有可能会出现对象丢失,内存泄漏的风险。- 过程模拟
-
分别有
G1 G2 G3
三个栈及其引用对象关系如图,对其依次进行扫描 -
先扫描
G1
,对其可达对象对象1 对象2
进行标记,最后标记为黑色 -
在扫描G2的过程中,堆上对象
对象2
新增了对对象9
的引用(这里是删除屏障,不做
任何操作) -
还没有扫描到
G3
的时候,G3
上对象4
移除了对对象5
的引用 ,因为是栈上的对象,所以这里也没有删除屏障,不做任何操作 -
依次扫描,扫描完G3后,标记状态如下
因为
对象2
在之前已经标记为黑色了,不会再进行扫描 -
最后进行清除回收的时候,
对象9
和对象5
就被错误的回收了,实际上是被引用的对象
-
怎么解决这个问题哪?
有以下两个方案可以解决栈空间解除引用关系导致的内存泄漏问题:
-
- 开始时STW,对栈空间扫描快照,将所有栈对象标记为黑色,可达的堆对象标记为灰色;之后配合堆对象删除写屏障就能保证对象不丢失( 删除写屏障的方案)
这样即使栈对象解除了对任意对象的引用,被解除的对象也是处于被保护状态,在接下来的三色标记清除中,也不会发生被引用的对象被清除回收严重泄漏问题。
-
2.加入插入写屏障的逻辑
堆上对象
对象2
新增对象9
的引用时,根据写屏障逻辑,将对象9标记为灰色这样
对象9
及它的可达对象也会被保护,满足强三色
这里的方案二在不进行整体程序STW的的情况下,也能够保证GC的过程中对象不丢失,这就是目前在用的
混合写屏障机制
- 过程模拟
五、混合写屏障机制
总结一下前面插入写屏障和删除写屏障的短板:
- 插入写屏障:结束时需要STW来重新扫描栈;
- 删除写屏障
- 回收精度低
- GC开始时STW 扫描堆栈记录初始快照,这个过程会保护开始时刻的所有存活对象
google工程师在Go V1.18对GC做了重大升级,引入了混合写屏障机制,从名字上就能看出来是同时开启删除屏障和插入屏障。这一升级避免了对栈re-scan的过程,同时在GC开始时也不用开启STW进行整栈扫描,结合了两者的优点。
具体流程:
- GC开始时扫描栈空间,将可达对象全部标记为黑色,这样可达的堆上对象会被标记为灰色(这里的扫描不是像删除屏障一样整栈扫描,而是一个栈一个栈的扫描,会先把被扫描的这个g挂起,扫描标记完再恢复,保证栈颜色改变的原子性),
不需要STW
- 标记期间,所有新分配的对象(无论是堆上还是栈上)都会被标记为黑色,不会被清除
- 堆上被删除的对象标记为灰色
- 堆上被添加引用的对象标记为灰色
伪代码:
func writePointer(slot,ptr){
// 将原本引用的对象标记为灰色
shade(*slot)
// 将新增的对象标记为灰色
shade(ptr)
// 引用新的对象
*slot =ptr
}
-
过程模拟
GC开始时,扫描栈区,将所有可达对象全部标记为黑色
-
场景一:对象被一个堆对象删除引用,同事成为栈对象的下游
-
对象1
新增引用对象7
,因为对象1
在栈上,不启用屏障机制,直接引用 -
对象4
删除对对象7
的引用关系 -
对象4
在堆上,根据删除屏障,对象7
被标记为灰色
-
-
场景二:对象被一个栈对象删除引用,成为另外一个栈对象的下游
-
栈上新建
对象9
,根据屏障机制,标记阶段任何新建的对象都直接标记为黑色
-
对象9
添加引用对象3
,栈上不启用屏障,直接添加引用 -
对象2
删除引用对象3
,栈上不启用屏障,直接删除引用
-
-
场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游
-
堆上新增
对象9
,任意新增对象均标记为黑色 -
对象9
新增引用对象7
,根据插入屏障,对象7
标记为灰色
-
对象4
删除引用对象7
,根据删除屏障,对象7
标记为灰色
-
-
场景四:对象从一个栈对象删除应用,成为另一个堆对象的下游
-
栈对象1
删除对栈对象2
的引用,栈上无屏障,直接删除 -
堆对象4
新增对象2
的引用(go里面这里的对象2在编译期间逃逸分析会判断内存逃逸到堆上),对象2
已经被标记为黑色,直接添加引用
-
堆对象4
删除引用对象7
,根据删除屏障,对象7
被标记为灰色
-
上面四种情况能涵盖标记过程中的所有对象的修改情况,在混合屏障机制下,能保证对象不会被误回收。
-
核心概念:
混合写屏障是一种同时标记和处理写操作的机制。它的目标是在垃圾回收的标记阶段,确保程序在运行时对对象的修改(特别是对指针引用的修改)不会导致垃圾回收器丢失对新对象的追踪。这种机制尤其在并发环境下很关键,防止黑色对象指向未被标记的白色对象(导致白色对象被错误地回收)。
优势:
- 并发标记:在标记阶段,GC和应用程序是并发进行的。虽然扫描栈的时候会短暂暂停被扫描的Goroutine,但是其他Goroutine任然可以继续运行,消减了STW时间。
- 整个GC扫描标记阶段都不需要整体的STW
- 标记完成后不需要对栈进行re-scan
缺点:
- 继承了删除屏障的缺点,回收精度低
六、总结
- Go V1.3: 普通标记清除,整个过程需要启动STW,效率极低;
- Go V1.5: 三色标记法,堆空间启动插入写屏障,栈空间不启动,全部扫描后,对
栈空间
进行re-scan
(需要STW)
,效率一般; - Go V1.8: 三色标记法,混合写屏障,只在堆空间生效,栈空间不生效。整个过程几乎不需要STW,效率极高,大部分标记回收工作都是在程序运行时并行完成的,但仍需要在特定时刻暂停程序执行。具体来说:
开始时的 STW:暂停所有 Goroutine,确保所有线程进入安全点,启动并发栈扫描,并开启写屏障。
结束时的 STW:暂停 Goroutine,处理最后的标记和回收操作,关闭写屏障,确保所有存活对象都被正确标记。