Golang 中的 GC

标签:Go语言首次发布:2024-04-19最近修改:2024-04-19

GC 发展历程

Go 语言的垃圾收集器从诞生的第一天起就一直在演进,除了少数几个版本没有大更新之外,几乎每次发布的小版本都会提升垃圾收集的性能,而与性能一同提升的还有垃圾收集器代码的复杂度,下面是垃圾收集器的演进过程:

  • v1.0:完全串行的标记和清除过程,需要暂停整个程序;
  • v1.1:在多核主机并行执行垃圾收集的标记和清除阶段;
  • v1.3:运行时基于只有指针类型的值包含指针的假设增加了对栈内存的精确扫描支持,实现了真正精确的垃圾收集;
  • v1.5:实现了基于三色标记清扫的并发垃圾收集器;
  • v1.6:实现了去中心化的垃圾收集协调器;
  • v1.7:通过并行栈收缩将垃圾收集的时间缩短至 2ms 以内;
  • v1.8:使用混合写屏障将垃圾收集的时间缩短至 0.5ms 以内;
  • v1.9:彻底移除暂停程序的重新扫描栈的过程;
  • v1.10:更新了垃圾收集调频器(Pacer)的实现,分离软硬堆大小的目标;
  • v1.12:使用新的标记终止算法简化垃圾收集器的几个阶段;
  • v1.13:通过新的 Scavenger 解决瞬时内存占用过高的应用程序向操作系统归还内存的问题;
  • v1.14:使用全新的页分配器优化内存分配的速度

从 Go 语言垃圾收集器的演进能够看到该组件的实现和算法变得越来越复杂,最开始的垃圾收集器还是不精确的单线程 STW 收集器,但是最新版本的垃圾收集器却支持并发垃圾收集、去中心化协调等特性。我个人认为发展历程当中有几个关键的时期:

Golang 版本 GC 机制 特点
v1.3 及以前 标记清除法 STW 机制几乎覆盖整个 GC 流程,让程序出现卡顿
v1.5 三色标记法 减少了 STW 的时间,但是也存在两个问题(多标-浮动垃圾问题漏标-悬挂指针问题
v1.8 及以后 三色标记法+混合写屏障机制 混合写屏障机制解决了上述两个问题

三色标记法

基本概念

三色标记法将对象的颜色分为了白、灰、黑,三种颜色。

  • 白色: 该对象没有被标记过(对象垃圾);
  • 灰色: 该对象已经被标记过了,但该对象下的属性没有全被标记完(GC 需要从此对象中去寻找垃圾);
  • 黑色: 该对象已经被标记过了,且该对象下的属性也全部都被标记过了(程序所需要的对象);

GC 流程

  1. 每次新创建的对象,默认的颜⾊都被标记为⽩⾊。

    image-20240418121144937

  2. 每次执⾏ GC 回收,都会从根节点开始遍历所有对象,把遍历到的对象从⽩⾊集合放⼊“灰⾊”集合。

    image-20240418121252449

  3. 遍历“灰⾊”集合,将灰⾊对象引⽤的对象从⽩⾊集合放⼊“灰⾊”集合,之后将此灰⾊对象放⼊“⿊⾊”集合。

    image-20240418121334641

  4. 重复第三步,直到“灰⾊”集合中⽆任何对象。

    image-20240418121418413

  5. 回收所有的⽩⾊标记表的对象,也就是回收垃圾。

    image-20240418121455894

以上便是三⾊标记法,不难看出,上⾯已经清楚地体现了三⾊的特性,但是这⾥可能会有很多并发流程也会被扫描,也就是说并发的 goroutine 的内存之间可能相互依赖,所以为了在 GC 过程中保证数据的安全,在开始三⾊标记之前会加上 STW,在扫描确定⿊⽩对象之后再放开 STW,但是很明显这样的 GC 扫描的性能实在是太低了。

存在的问题

上述的并发三色标记法从开始标记一直到确定⿊⽩对象这段过程中都是有 SWT 机制进行保护的,这无疑严重降低了程序的性能。那么假设没有 STW 机制进行保护,会出现什么问题吗?

如果不暂停程序,程序的逻辑会改变对象的引⽤关系,这种动作如果在标记阶段做了修改,会影响标记结果的正确性,接下来具体推演⼀下此过程。

1. 多标-浮动垃圾问题

流程如下:

假设 E 已经被标记过了(变成灰色了),此时 D 和 E 断开了引用,按理来说对象 E/F/G 应该被回收的,但是因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去,最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。

image-20240418130512106

2. 漏标-悬挂指针问题

流程如下:

当 GC 线程已经遍历到 E 变成灰色,D 变成黑色时,灰色 E 断开引用白色 G ,黑色 D 引用了白色 G,此时切回 GC 线程继续跑,因为 E 已经没有对 G 的引用了,所以不会将 G 放到灰色集合,尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。

最终导致的结果是:G 会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性。

image-20240418131404351

总结:

不管是多标-浮动垃圾问题还是漏标-悬挂指针问题,在没有 STW 机制的情况下,造成这两种问题的根本原因是:

  • 条件 1:⼀个⽩⾊对象被⿊⾊对象引⽤(⽩⾊被挂在⿊⾊下)。
  • 条件 2:灰⾊对象与它之间的可达关系的⽩⾊对象遭到破坏(灰⾊同时丢了该⽩⾊)。

为了防⽌这种现象的发⽣,最简单的⽅式就是 STW,直接禁⽌其他⽤户程序对对象引⽤关系的⼲扰,但是 STW 的过程有明显的资源浪费,对所有的⽤户程序都有很⼤影响。那么是否可以在保证对象不丢失的情况下合理地尽可能地提⾼ GC 效率,减少 STW 时间呢?答案是可以的,只要使⽤⼀种机制,尝试去破坏上⾯的两个必要条件就可以了。

屏障机制

强弱三色不变式

1. 强三⾊不变式

强三⾊不变⾊实际上是强制性地不允许⿊⾊对象引⽤⽩⾊对象,这样就不会出现⽩⾊对象被误删的情况。

image-20240418134002040

2. 弱三色不变式

⿊⾊对象可以引⽤⽩⾊对象,但是这个⽩⾊对象必须存在其他灰⾊对象对它的引⽤,或者可达它的链路上游存在灰⾊对象。这样实则是⿊⾊对象引⽤⽩⾊对象,⽩⾊对象处于⼀个危险被删除的状态,但是上游灰⾊对象的引⽤,可以保护该⽩⾊对象,使其安全。

image-20240418134153365

为了遵循上述两种⽅式,GC 算法演进到两种屏障⽅式,它们是插⼊写屏障和删除写屏障。

插入写屏障

插⼊写屏障的具体操作是,在 A 对象引⽤ B 对象的时候,B 对象被标记为灰⾊(将 B 挂在 A 下游,B 必须被标记为灰⾊)。插⼊写屏障实际上是满⾜强三⾊不变式(不存在⿊⾊对象引⽤⽩⾊对象的情况,因为⽩⾊会强制变成灰⾊)。下面是包含插入写屏障的场景流程:

  1. ⽬前还是假设程序初创建,堆栈空间的对象引用情况如下:

    image-20240418135526040

  2. 依然依据三⾊标记的流程,遍历 Root Set 根节点集合,⾮递归形式,只遍历⼀次,能够标记出第⼀层的灰⾊节点对象 1 和对象 4,同时这些灰⾊节点也被添加⾄灰⾊标记表中

    image-20240418135702180

  3. 按照三⾊标记法的顺序,接下来就遍历灰⾊标记表中的对象 1 和对象 4,将可达的对象从⽩⾊标记为灰⾊,并将对象 1 和对象 4 移至黑色标记表中。

    image-20240418135836932

  4. 由于并发的特性,此刻外界向已经标记为⿊⾊的对象 4 添加⽩⾊的对象 8,向已经标记为⿊⾊的对象 1 添加下游⽩⾊的对象 9。对象 1 是栈空间,根据插⼊屏障的特点,为了保证性能,栈空间创建对象不触发插⼊屏障,但是对象 4 在堆空间,此时对象 4 即将触发插⼊屏障机制。

    image-20240418140146018

  5. 由于插⼊写屏障的机制(⿊⾊对象添加⽩⾊对象,所以将⽩⾊对象改为灰⾊),所以当堆上的对象 4 添加对象 8 的时候,对象 8 将被标记为灰⾊,⽽对象 9 依然是⽩⾊。

    image-20240418140328258

  6. 之后就是正常的三⾊标记流程,继续循环上述的流程,直到没有灰⾊节点,⽬前得到的对象状态如图 2.25 所示,栈空间的对象 1、对象 2、对象 3 被标记为⿊⾊,堆空间上的对象 4、对象 7、对象 8 被标记为⿊⾊,⽽其他的对象 9、对象 5、对象 6 依然是⽩⾊对象。

    image-20240418140444876

  7. 这个时候插⼊屏障并不会⽴刻执⾏垃圾回收动作,⽽是会做⼀个额外的扫描,但是如果栈不添加,当全部三⾊标记扫描之后,栈上有可能依然存在⽩⾊对象被引⽤的情况,所以要对栈重新进⾏三⾊标记扫描,但这次为了对象不丢失,这次扫描要启动 STW 暂停,直到栈空间的三⾊标记结束。当全部内存对象的颜⾊只有⽩⾊和⿊⾊的时候,就会停⽌ STW。⼀般在栈空间 STW 的时间⼤约为 10~100ms。

    image-20240418140720547

  8. 在最后的扫描之后内存中将全部为⿊⾊对象。这样整体的基于插⼊屏障的三⾊标记回收机制的流程就介绍完了。

    image-20240418141114043

  9. 插⼊屏障的⽬的是保证⿊⾊对象插⼊的时候有灰⾊对象对其保护,或者将被插⼊的对象变为灰⾊,插⼊屏障实则是满⾜强三⾊不变式的⼀种表现,这样就不会出现被误删的⽩⾊对象了。

    image-20240418141318148

删除写屏障

删除屏障的具体操作是:一个即将被删除的对象,如果⾃身为灰⾊或者⽩⾊,则被标记为灰⾊。删除屏障实际上是满⾜弱三⾊不变式,⽬的是保护灰⾊对象到⽩⾊对象的路径不会断。

  1. ⽬前还是假设程序初创建,堆栈空间的对象引用情况如下:

    image-20240418154934720

  2. 依然依据三⾊标记的流程,遍历 Root Set 根节点集合,⾮递归形式,只遍历⼀次,能够标记出第⼀层的灰⾊节点对象 1 和对象 4,同时这些灰⾊节点也被添加⾄灰⾊标记表中。

    image-20240418155019756

  3. 如果此时灰⾊对象 1 删除⽩⾊对象 5,依照删除写屏障算法,被删除的对象将被标记为灰⾊,⽬的是保护对象 5 和下游对象。思考为什么需要保护,如果不将对象 5 标记为灰⾊会出现哪些意外问题?

    假如对象 1 已经删除了对象 5,对象 5 依旧是⽩⾊,那么由于整体流程没有加 STW 保护,极有可能在删除的过程中,同⼀时刻有⼀个已经被标记为⿊⾊的对象引⽤了这个对象 5,对象 5 依然是程序流程中需要依赖的合法内存对象,但是最终会按照⽩⾊对象被 GC 回收掉,因为⿊⾊的下游对象并不会被保护起来,将对象 5 标记成了灰⾊。

    image-20240418173009196

  4. 按照三⾊标记法的顺序,接下来遍历灰⾊标记表中的对象 1、对象 4 和对象 5,将它们可达的对象从⽩⾊标记为灰⾊,同时被遍历的灰⾊对象被标记为⿊⾊。然后继续循环上述流程进⾏三⾊标记,直到没有灰⾊节点,最终的状态如下:

    image-20240418173145642

  5. 最后,执⾏回收清除流程,将⽩⾊对象全部通过 GC 回收处理。

    image-20240418173332625

以上便是三⾊标记利⽤删除屏障的处理流程,删除屏障依旧可以满⾜并⾏状态下的垃圾回收动作,但是这种⽅式的回收精度较低,因为⼀个对象即使被删除了,最后⼀个指向它的指针也依旧可以“活”过这⼀轮,只有等到下⼀轮 GC 才会被清理掉。

三色标记法+混合写屏障

插⼊写屏障删除写屏障虽然都可以在⼀定程度上解决 STW 带来的⽆法并⾏处理的问题,但是也都有各⾃的短板:

  • 插⼊写屏障:结束时需要 STW 重新扫描栈,标记栈上引⽤的⽩⾊对象的存活。
  • 删除写屏障:回收精度低,GC 开始时 STW 扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

Go V1.8 版本引⼊了混合写屏障机制,避免了对栈重新扫描的过程,这也极⼤地减少了 STW 的时间,同时也结合了插⼊写屏障和删除写屏障两者的优点。总结概括混合写屏障的特点就是如下:

  1. GC 开始将上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需 STW);

  2. GC 期间,任何在上创建的新对象,均为黑色

  3. 中被删除的对象标记为灰色

  4. 中被添加的对象标记为灰色

注意: 屏障技术不在栈上应⽤,因为要保证栈的运⾏效率。混合写屏障是 GC 的⼀种屏障机制,所以只是当程序执⾏ GC 的时候,才会触发这种机制。

接下来模拟混合写屏障的详细过程:

  1. 初始的堆栈情况如下:

    image-20240418184223208

  2. 现在 GC 开始,按照上述混合写屏障的⼏个步骤,它的第⼀步就是扫描栈区,将可达对象全部标记为⿊⾊,所以扫描栈区结束的时候,对象 1、对象 2、对象 3 均可达,它们被标记成了⿊⾊,同时也被加⼊⿊⾊标记表中。

    image-20240418184404029

接下来就来分析混合写屏障的⼀些场景,下面会列出 4 种场景,这 4 种场景均是通过如上图所示已经扫描完栈空间且可达对象被标记为⿊⾊作为出发点。


场景1. 堆删除引⽤,成为栈下游

描述的是:对象被在堆当中被删除引⽤,成为栈当中某个对象的下游。

  1. 将⽩⾊的对象 7 添加到⿊⾊的对象 1 下游。这⾥需要注意的是,因为栈不启动写屏障机制,所以⽩⾊的对象 7 将直接挂在⿊⾊的对象 1 下⾯,并且对象 7 的颜⾊依然是⽩⾊。现在扫描到对象 4,此时对象 4 被标记成了灰⾊。

    image-20240418201728313

  2. 然后灰⾊的对象 4 删除⽩⾊的对象 7。这⾥因为对象 4 处在堆空间范围,所以会触发写屏障,被删除的对象 7 将被标记为灰⾊。

    image-20240418201907025

所以通过场景⼀的情况来看,对象 7 最终被挂在了对象 1 的下游。由于对象 7 是灰⾊的,所以不会被当作垃圾进⾏回收,这样就保护了起来。在场景⼀的混合写屏障中,也不会再次给栈空间的对象启动 STW,再重新扫描⼀遍。接下来的过程就依旧遵循混合写屏障的三⾊标记法逻辑进⾏处理,最终对象 4 和对象 7 均会被标记为⿊⾊,GC 最终会回收对象 5、对象 8 和对象 6。


场景2. 栈删除引⽤,成为栈下游

  1. 现在新建一个栈对象 9。根据混合写屏障的限定条件,任何在栈范围上新创建的内存对象均会被标记为⿊⾊。

    image-20240418202257789

  2. 然后对象 9 添加下游对象 3。因为对象 9 是栈范围空间,所以添加过程并不会触发写屏障,直接将对象 3 挂在对象 9 的下⾯即可。

    image-20240418202422853

  3. 最后就是对象 2 将删除下游对象 3。由于对象 2 属于栈范围内,所以依然不触发写屏障机制,对象 2 将直接将对象 3 从下游移除。

    image-20240418202518972

通过上述过程可以看到,在混合写屏障的机制中,⼀个对象从⼀个栈对象下游转移到另⼀个对象的下游,由于栈对象均为⿊⾊,所以不必启动写屏障和 STW 机制就能够保证对象的安全性,这也是混合写屏障的巧妙设计之处。


场景3. 堆删除引⽤,成为堆下游

  1. 在堆空间范围内有⼀个⿊⾊对象 10(不为⿊⾊也⽆所谓,因为对象 10 是堆空间可达对象,最终它会被标记为⿊⾊),并且只有对象 10 为⿊⾊的时候,才会有下游内存不安全的情况。

    image-20240418203023667

  2. 堆对象 10 添加下游引⽤⽩⾊的堆对象 7。由于对象 10 是在堆空间范围内,这⾥的写操作将触发屏障机制,根据混合写屏障的限定条件,被添加的对象将被标记为灰⾊,所以⽩⾊的对象 7 将被标记为灰⾊,这样同时也间接地保护了⽩⾊的对象 6。

    image-20240418203243529

  3. 最后就是灰⾊的堆对象 4 删除下游引⽤堆对象 7。由于对象 4 所在堆空间范围内,所以触发屏障机制,根据混合写屏障的限定条件,被删除的对象将被标记为灰⾊,所以将对象 7 标记为灰⾊(虽然对象 7 已经是灰⾊)。

    image-20240418203403472

通过上述⼏个过程,原本⽩⾊的对象 7 已经成功地从⼀个堆对象 4 的下⾯转移到⼀个⿊⾊的堆对象 10 下⾯,并且对象 7 及它的下游对象(对象 6)均被保护起来,⽽整体过程中也没有⽤到 STW 来耽误程序的运⾏。


场景4. 栈删除引⽤,成为堆下游

  1. 现在栈对象 1 删除栈对象 2 的引⽤。由于对象 1 属于栈空间范围,所以不触发写屏障机制,此时对象 1 将直接删除对象 2 及对象 2 所关联的全部下游对象。

    image-20240418204502709

  2. 接下来将堆对象 4 之前的下游⽩⾊对象 7 删除,并将堆对象 4 的新下游对象添加为栈对象 2。由于对象 4 在堆空间范围内,将触发写屏障机制,根据混合写屏障的限定条件,被删除的对象将被标记为灰⾊,新添加的对象也会被标记为灰⾊,所以对象 7 被标记为灰⾊对象,这样对象 7 的下游对象 6 就得到了保护。对象 2 是新添加的对象,那么对象 2 也将执⾏标记灰⾊的过程,这⾥由于对象 2 已经是⿊⾊,属于安全的对象,所以对象 2 将继续保持⿊⾊。

    image-20240418204931134

最终成功地改变了⼀个本来是被栈引⽤的对象 2 挂在了堆对象 4 的下游,⽽依然保持内存的依赖关系和安全状态。之后会通过⼏次循环遍历,对象 1、对象 4、对象 2、对象 3 均会被标记为⿊⾊,⽽对象 7 和对象 6 会在本轮 GC 中也被标记为⿊⾊。本轮 GC 最后回收的⽩⾊内存是对象 5 和对象 8。

但这⾥有个疑问,对象 7 和对象 6 已经和程序的 Root Set 断开了,为什么却没有被回收?这就是混合写屏障的延迟问题,在⼀定概率情况下,为了去掉 STW 会有⼀些内存延迟 1 个周期被回收。等到第⼆轮 GC,对象 7 和对象 6 如果没有外界添加,它们终将会成为⽩⾊垃圾内存⽽被回收。

GC 触发机制

全局变量

在垃圾收集中有一些比较重要的全局变量,在分析其过程之前,我们会先逐一介绍这些重要的变量,这些变量在垃圾收集的各个阶段中会反复出现,所以理解他们的功能是非常重要的,我们先介绍一些比较简单的变量:

  • runtime.gcphase是垃圾收集器当前处于的阶段,可能处于 _GCoff(清理垃圾阶段)、_GCmark(标记阶段)和 _GCmarktermination(标记中止阶段),Goroutine 在读取或者修改该阶段时需要保证原子性;
  • runtime.gcBlackenEnabled是一个布尔值,当垃圾收集处于标记阶段时,该变量会被置为 1,在这里辅助垃圾收集的用户程序和后台标记的任务可以将对象涂黑;
  • runtime.gcController实现了垃圾收集的调步算法,它能够决定触发并行垃圾收集的时间和待处理的工作;
  • runtime.gcpercent是触发垃圾收集的内存增长百分比,默认情况下为 100,即堆内存相比上次垃圾收集增长 100% 时应该触发 GC,并行的垃圾收集器会在到达该目标前完成垃圾收集;
  • runtime.writeBarrier是一个包含写屏障状态的结构体,其中的 enabled 字段表示写屏障的开启与关闭;
  • runtime.worldsema是全局的信号量,获取该信号量的线程有权利暂停当前应用程序;

触发时机

运行时会通过如下所示的 test 方法决定是否需要触发垃圾收集,当满足触发垃圾收集的基本条件时:允许垃圾收集程序没有崩溃并且没有处于垃圾收集循环,该方法会根据三种不同方式触发进行不同的检查:

go
// src\runtime\mgc.gofunc (t gcTrigger) test() bool {    if !memstats.enablegc || panicking.Load() != 0 || gcphase != _GCoff {        return false    }    switch t.kind {    case gcTriggerHeap:        trigger, _ := gcController.trigger()        return gcController.heapLive.Load() >= trigger    case gcTriggerTime:        if gcController.gcPercent.Load() < 0 {            return false        }        lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))        return lastgc != 0 && t.now-lastgc > forcegcperiod    case gcTriggerCycle:        return int32(t.n-work.cycles.Load()) > 0    }    return true}
  1. gcTriggerHeap:堆内存的分配达到控制器计算的触发堆大小;
  2. gcTriggerTime:如果一定时间内没有触发,就会触发新的循环,该触发条件由runtime.forcegcperiod变量控制,默认为 2 分钟;
  3. gcTriggerCycle:如果当前没有开启垃圾收集,则触发新的循环;