一、垃圾回收
1、垃圾回收介绍
GC
,全称 Garbage Collection
,即垃圾回收,是一种自动内存管理的机制。
程序在运行时会向操作系统申请内存,当内存不再需要时,垃圾回收会主动将其回收并后续提供其他代码进行内存申请时候复用内存,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。
垃圾回收的实现为我们在日常开发中提供了很大的帮助。一方面,受益于GC
,我们在日常开发中无需操心内存管理、不再需要对内存进行手动的申请和释放操作,GC
在程序运行时自动释放残留的内存。另一方面,GC
基本上对于我们来说不可见,仅当我需要进行一些优化操作时,通过提供可调控的API,对GC
的运行时机、运行开销等参数进行把控时,GC
才会浮出水面。
2、根对象
根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,主要包括:
-
全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
-
栈空间:每个
goroutine
都包含自己的执行栈空间,这些执行栈空间上包含栈上的变量及指向分配的堆内存区块的指针。 -
寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些用户代码分配的堆内存区块。
3、GC实现方式
常见的GC
算法其存在形式可以归结为追踪
和引用计数
这两种形式的混合运用。
-
追踪式:从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。
Go
、Java
等语言的实现等均为追踪式GC。 -
引用计数:每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式GC。
常见的GC实现方式:
追踪式GC:
- 标记清除:从根对象出发,将确定存活的对象进行标记,并清除未标记可回收的对象。
- 标记整理:为了解决内存碎片问题,在标记过程中,将对象尽可能整理到一块连续的内存上,再进行统一的清除,从而减少内存碎片。
- 分代式:将对象根据存活时间长短从而进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。根据分代假设对对象进行回收,一般来说,如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间
引用计数GC:根据对象自身的引用计数来回收,当引用计数归零时立即回收。
二、Go的垃圾回收
1、Go使用的垃圾回收方式
对于Go来说,Go的垃圾回收目前使用的是无分代、不整理、并发的三色标记清除算法。具体来说:
- 无分代:对象没有根据存活时间长短而分代
- 不整理:垃圾回收的过程中不对对象进行移动与整理
- 并发:垃圾回收与用户代码并发执行
将对象整理的好处在于解决内存碎片的问题,允许使用顺序内存分配器。但Go在运行时分配算法基于tcmalloc
,基本上没有碎片问题。同时,顺序内存分配器在多线程的场景下并不适用。因此,对对象进行整理不会带来实质性的性能提升。
另外,分代式GC
主要的垃圾回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但Go编译器会通过逃逸分析将大部分新生对象存储在栈上(栈对象在goroutine
死亡后栈也会被直接回收,不需要GC
参与),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。因此,Go在垃圾回收实现上并没有使用分代GC
。
关于逃逸分析,可以看一下我之前的博客~ 链接:juejin.cn/post/731614…
2、什么是三色标记法
所谓三色标记法实际上是通过三个阶段来标记对象,从而确定清除的对象都有哪些。三色标记是一种描述追踪式回收器的方法,在实践中并没有实际含义。即三色标记法实际上是指标记清除的垃圾回收。
从垃圾回收器的视角来看,三色标记规定了三种不同类型的对象,并用不同的颜色相称:
- 白色对象:垃圾回收器未访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
- 灰色对象:垃圾回收器已访问到的对象,但是需要检查该对象的指针指向的一个或多个其他对象,因为可能还指向白色对象。
- 黑色对象:垃圾回收器已访问到的对象,并且其指向的一个或多个其他对象均已被扫描,该对象的指针不会指向白色对象。
三色标记的垃圾回收过程,实际上是对象颜色标记不断转换的过程。具体的步骤如下:
- 第一步:当垃圾回收开始时,只有白色对象。
- 第二步:垃圾回收开始,会一次遍历所有根对象,将其着色为灰色对象。
- 第三步:随后遍历所有灰色对象,回收器将灰色对象的指针指向的对象从着色为灰色对象。当灰色对象的指针指向的所有对象均被回收器扫描时,该灰色对象会被着色为黑色。
- 第四步:重复遍历所有灰色对象的操作(第三步的操作),直至无灰色对象。
- 第五步:当整个堆遍历完成时,只剩下黑色和白色对象,这时的黑色对象为可达对象,即存活;而白色对象为不可达对象,即死亡,需要被清除。
第一步:程序期初创建,对象全部标记为白色对象,将所有对象放入白色集合中。
第二步:当GC开始时,会从根节点开始遍历所有对象,将遍历到的对象从白色集合放入灰色集合,即着色为灰色。
如上图所示,一次遍历所有的根节点,当前可抵达的对象是对象1和对象4,当本轮遍历结束,对象1和对象4就会被标记为灰色,灰色标记表就会多出这两个对象。
第三步:遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合。
如上图,扫描灰色对象,将灰色对象1和对象5指向的可抵达的对象由白色变为灰色,如上图的对象2、对象6,而后对象1和对象5会被标记为黑色对象,将这两个对象从灰色标记表移动到黑色标记表中。
第四步:重复第三步, 直到灰色中无任何对象,如图所示。
如上图,当全部可达对象遍历完后,将不再存在灰色对象,内存中的对象全部都只有黑色对象与白色对象。黑色对象就是程序逻辑可达(需要的)对象,不可删除。白色的对象则是全部不可达对象,程序逻辑并不依赖他们,即内存中目前的垃圾数据,需要被清除。
第五步: 回收所有的白色对象,即回收垃圾对象。
上述便是三色标记法的一个大致流程。在这过程中,可能会存在很多并发情况,并发回收的根本问题在于,用户态代码在回收过程中会并发地更新对象图,从而造成用户态代码和回收器可能对对象图的结构产生不同的认知。为了在GC过程中保证数据的安全,我们在开始三色标记之前就会加上STW,在扫描确定黑白对象之后再放开STW。但是很明显这样的GC扫描的性能实在是太低了。
3、什么是STW?
STW
是Stop the World
的缩写,也可以是Start the World
的缩写。通常意义上指代从Stop the World
这一动作发生时到 Start the World
这一动作发生时这一段时间间隔。STW
在垃圾回收过程中为了保证垃圾回收的正确性、防止无止境的内存增长等问题而不可避免的需要停止用户态代码进一步操作对象图的一段过程。
我们都知道如果在开始三色标记之前加上STW,在扫描确定黑白对象之后再放开STW。那么GC的扫描性能会大大下降,同时用户态代码也处于卡顿状态。如果我们不加入STW,则可能不会再存在性能上的问题。
如果三色标记法没有STW,会发送什么情况呢?
我们回到把初始状态设置为已经经历了第一轮扫描,已知黑色对象有对象1和对象5,灰色对象有对象2和对象5,其余为白色对象,如下图。
如果在三色标记的过程中,不启动STW,那么在GC扫描的过程中,任意的对象指针均可能会发生改变,如下图,还未扫描对象2时,已经标记为黑色对象的对象5创建了q指针,指向了白色对象3(此时已存在灰色对象2指向白色对象3的p指针)。与此同时将灰色对象2指向白色对象3的p指针给移除。
此时,白色对象3被挂在了已被标记为黑色对象5上。
然后正常执行三色标记的算法逻辑,将所有灰色的对象标记为黑色,那么对象2和对象6就被标记成了黑色。
最后将白色对象作为垃圾进行回收。
从上述流程可以看到,原本对象5合法引用的对象3,却被GC给误回收了,这会导致对象5的q指针会发生对象丢失,更糟糕的是,如果被误清除对象3后续还有很多下游对象,也会一并被清除,从而可能发生不可预估的错误。
因此,在三色标记法中,以下两种情况是不希望发生的。
- 条件一:用户态代码修改了对象图,导致某一个黑色对应指向了白色对象。
- 条件二:灰色对象与其指向的白色对象之间的可达关系被破坏,即从灰色对象出发,到达白色对象的、未经访问过的路径被用户态代码给破坏。
为了防止上述情况的发送,最简单的方式就是采用STW,直接禁止用户态代码对对象图的修改,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响。那么是否可以在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?
我们可以通过使用一种机制,尝试去破坏上述两个条件即可。这种机制就是屏障机制。
4、屏障机制
在聊到在三色标记法中两种不希望发生的情况。只要避免其中一个条件,则不会出现对象丢失的情况,原因在于:
- 如果条件一被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;
- 如果条件二被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发的话,总会存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。
我们不妨将上述进行两种定义:
- 当条件一和条件二都不满足时,我们称这种情况为强三色不变性(strong tricolor invariant),即强三色不变性是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况。
- 当用户态代码让黑色对象指向白色对象时(满足条件一),我们称这种情况为弱三色不变性(weak tricolor invariant)
在弱三色不变性
的情况下,如果用户态代码进一步破坏了灰色对象可达该白色对象(被黑色对象引用的白色对象)的路径,即打破了弱三色不变性
,也破坏了垃圾回收器的正确性。
因此弱三色不变性
强调,黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。这样,即使黑色对象引用白色对象,白色对象处于一个危险被删除的状态,但是上游灰色对象的引用,可以保护该白色对象,使其安全。
强弱三色不变性都能够保证垃圾回收器的正确性,为了避免这种情况的发生(破坏强弱三色不变性,垃圾回收器的正确性被打破),必须引入额外的辅助操作,来保证对象图始终处于强弱三色不变性,这个辅助操作就是屏障机制。
屏障机制主要为写屏障
以及混合写屏障
,其中,写屏障这个概念又可以分为插入屏障
和删除屏障
。
插入写屏障
插入写屏障具体来说,就是当黑色对象A新引用对象B时,需要将对象B标记为灰色对象这一动作。(防止黑色对象引用白色对象)
上述的执行动作即为插入写屏障,我们知道,对象在分配上可能分配到栈区或堆区,栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用, 所以插入写屏障
机制,在栈空间的对象操作中不使用,而仅仅使用在堆空间对象的操作中。
插入写屏障的介入,让对象图满足强三色不变式(不存在黑色对象引用白色对象的情况了,因为白色会强制变成灰色)。
具体场景:
- 对象A原本未指向任何对象,用户态代码修改后,对象A指向了对象B,此时对象B需要修改为灰色。
- 对象A原本指向对象C,用户态代码修改后,对象A指向了对象B,此时对象B需要修改为灰色。
具体举个例子来说明一下GC三色标记并发时插入写屏障的流程:
- 程序创建时,所有对象为白色,所有对象放入白色标记表
- 遍历根节点,将扫描到的对象标记为灰色对象
- 遍历灰色对象,将可达的对象从白色标记为灰色,同时将遍历后的灰色对象标记为黑色对象
- 由于并发执行,用户态代码对对象图进行修改,将对象1新添加对象6,对象3新添加对象7
- 由于对象1处于栈区,因此不触发写屏障,对象1新添加引用的对象6仍然为白色对象;对象4处于堆区,触发写屏障,因此将对象4新添加引用的对象7置为灰色对象
- 持续进行三色标记,直到没有灰色对象可遍历
我们可以发现上述的流程中,栈区中是会有可能存在白色对象被引用,但是会被误回收的情况发生,例如上述情况的对象9,因此,需要对栈区重新进行三色标记扫描,同时为了对象不丢失,重新三色标记栈区的过程是需要启动STW暂停. 直到栈空间的三色标记结束。
- 在回收白色对象前,重新扫描栈区的对象,此过程添加STW暂停保护,防止用户态代码对对象图进行修改
- 重新标记,直到没有灰色对象后,停止STW
- 清除白色对象
删除写屏障
删除写屏障具体来说,就是被删除引用的对象,如果自身为灰色或者白色,那么被标记为灰色。
删除写屏障也是写屏障的一种,删除写屏障的介入,让对象图能够满足弱三色不变性(保护灰色对象到白色对象的路径不会断)
具体场景:
- 对象A原本指向对象B,用户态代码修改后,对象A删除了对对象B的引用,若对象B此前为白色对象,此时对象B需要修改为灰色。
- 对象A原本指向对象B,用户态代码修改后,对象A指向了对象C,对象A删除了对对象B的引用,若对象B此前为白色对象,此时对象B需要修改为灰色。
具体举个例子来说明一下GC三色标记并发时删除写屏障的流程:
- 程序创建时,所有对象为白色,所有对象放入白色标记表
- 遍历根节点,将扫描到的对象标记为灰色对象
- 由于用户态代码的操作,对象1删除了对对象2的引用,如果不触发删除写屏障,2-3-4这一路径与主路径断开,这三个对象后续均会被清除
- 触发删除写屏障,将删除引用的对象2标记为灰色。
- 遍历灰色对象,将可达对象的对象从白色标记为灰色,遍历后的灰色对象标记为黑色对象
- 循环三色标记,直到没有灰色对象可遍历,然后删除白色对象
从上述例子可以看到,删除写屏障的介入让垃圾回收的回收精度降低,一个对象(上述对象2)即使被删除了最后一个指向它的指针也依旧可以活过这一轮,需要在下一轮GC中才会被清理掉,例如上述的对象2、3、4。
混合写屏障
在聊到插入写屏障和删除写屏障时,我们都提及到这两个写屏障的缺点:
- 插入写屏障:结束时需要
STW
来重新扫描栈,避免误杀栈区中的存活对象 - 删除写屏障:回收精度低,可能遗漏一些需要删除的对象
在Go V1.8版本引入了混合写屏障机制(hybrid write barrier)
,避免了对栈重新扫描的过程,极大的减少了STW的时间。结合了插入写屏障与删除写屏障两者的优点。
混合写屏障的规则如下:
- GC开始时,将栈区的对象全部扫描并标记为黑色,之后无需再进行第二次扫描,即无需STW
- GC期间,任何在栈上创建的新对象,均为黑色,因为栈中不使用屏障技术,保证栈的运行效率。
- 被删除引用的对象标记为灰色。
- 被添加引用的对象标记为灰色。
Go中的混合写屏障满足弱三色不变性
,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine
的栈,使其变黑并一直保持,这个过程不需要STW
,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行二次扫描操作了,减少了STW
的时间。
5、触发GC的时机
Go 语言中对 GC 的触发时机存在两种形式:
-
主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
-
被动触发,分为两种方式:
- 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
- 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。