Golang的垃圾回收机制

Tue, Feb 22, 2022 阅读时间 1 分钟

GC

编程语言的内存管理方式就两种:自动和手动。像C/C++、Rust这种极致追求性能的语言就是要程序员手动进行内存空间的使用和释放。而像Java、Go等语言就是使用自动的方式进行内存管理,通过内存分配器就行内存分配,通过垃圾收集器进行垃圾回收。

GC,全称 Garbage Collection,即垃圾回收,就是一种自动内存管理的机制。通过垃圾回收程序员可以专注于写程序,不用考虑内存的释放问题。

常见的GC方式

主要包括两种:

  • 引用计数GC

    每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,实时维护应用计数导致开销很大,在追求高性能时通常不被应用。优点就是对象可以及时的被回收,Python、Objective-C 等均为引用计数式 GC,Python的话是引用计数和隔代回收相结合的方式。

  • 追踪式GC

    从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。这种方式解决了引用计数的缺点,缺点就是需要STW。Go、 Java、V8 对 JavaScript 的实现等均为追踪式 GC。

STW

Stop the world 或者 Start the world,在GC的一些阶段需要停止所有的 mutator,以确定当前的引用关系,这就导致程序运行过程中会突然停一下,然后再继续运行,所有有GC的语言都无法避免这个问题,也是Go、Java这种语言无法应用于操作系统等极致追求性能的场景下的原因。减少STW的时间,也是所有GC算法要重点优化的对象。Go语言早期版本STW的时间长达几百毫秒,现在已经优化到了半毫秒以下。

根对象

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  • 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  • 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
  • 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

根对象都是 mutator 不需要其他对象就可以直接访问到的对象,并且通过根对象可以追踪到其他所有存活的对象

Go的GC实现

三色标记法

Go的GC经过了多个版本,不断优化,从1.5版本开始使用三色标记法才算稳定。

三色标记法,属于追踪式GC。整个过程分为两个阶段:标记(Mark)和清除(Sweep),所以也叫做Mark-Sweep垃圾回收算法。

三色抽象规定了三种不同类型的对象,并用不同的颜色表示:

  • 白色对象:潜在的垃圾,可能会被垃圾收集器回收。未被回收期访问到的在回收开始阶段,所有对象均为白色。
  • 灰色对象:活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象。
  • 黑色对象:活跃的对象,包括不存在任何引用外部指针的对象,和从根对象可达的对象,垃圾收集器不会扫描黑色对象的子对象。

三色标记法优化了标记清除法在执行时要长时间STW的问题。初始时将所有内存标记为白色。

然后将根对象集合加入待扫描队列中(即变成灰色)。

然后进入一个根对象,并将根对象变成黑色,然后开启多个goroutine并发扫描这个根对象的子对象,把子对象都变成灰色,依次类推,逐层寻找,直到尽头,把所有根对象和根对象可达的对象都置为黑色。

然后剩下的所有根对象和根对象的子对象都无法到达的白色对象,就是可以回收的垃圾,如下图的 s2。

对象的颜色实现方式是,在每个span中有一个名叫 gcmarkBits 的位图属性,这个属性用于设置对象的颜色。

写屏障

在用三色标记法进行垃圾回收的过程中主要有四个阶段,其中,标记(Mark)和清扫(Sweep)过程都是并发执行的,但是标记阶段的前后,需要一小点时间STW,来做GC的准备工作和栈的重扫re-scan(后续会详细说明)。

并发垃圾回收,在Mark阶段,需要在程序执行对象在动态变化时,去做标记,所以要想保证标记的正确性,需要识别出那些增量的对象,三色标记法通过达成两个三色不变性(达成其一即可)来实现:

  • 强三色不变性:黑色对象不会指向白色对象,只会指向黑色对象,或者灰色对象。因为黑色对象是不能被回收的,它指向的对象也不能被回收。
  • 弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径。即可能黑色对象指向了白色对象,但是一定不是唯一一个指向该白色对象的对象,一定还要有其他灰色对象(或者灰色对象的下游对象)指向了这个白色对象,也就是说此时正在灰色对象的扫描过程中,一段时间后,这个白色对象也会变成灰色和黑色。

两个法则其实都是对黑色对象指向白色对象的约束。下面这个例子,就出现了黑色对象指向了白色对象的情景:

上图中,一开始灰色对象B指向了白色对象C,然后黑色对象A也指向了白色对象C,然后由于某些原因,B和C之间的关系断开了,则就形成了黑色对象A指向了白色对象C的场景。此时因为C还是白色,按照规则他就会被垃圾收集器回收,这就出现了对象的丢失,这显然是不合理的。

所以为了避免这个问题,就需要进行STW,让程序暂停,防止用户进程对对象之间引用关系的干扰。但是STW对用户程序有很大影响,为了尽可能地提高GC的效率,减少STW的时间,于是就有了写屏障技术。

Go的写屏障,将会拦截掉白色指针插入黑色对象的操作,并标记这个白色对象为灰色状态,这样就不存在黑色对象引用白色对象的情况了,满足强三色不变性。如下图所示,C对象将会变成灰色对象,然后再将指针插入到A:

由于栈上的操作很密集,我们应用程序大部分时候都在操作栈,如果在栈上也进行写屏障,那么性能下降很厉害,Go的做法就是,写屏障只在堆上执行,栈中不使用写屏障。那么这样就会导致栈中会出现黑色对象指向白色对象的情形,对于这个问题的解决方法是re-scan,即重扫,重扫一遍所有对象之后,这个白色对象就变成黑色对象了,这个过程需要STW,以防止还有其他对象的变更。重扫导致的耗时可能在10~100ms之间,这也是 go1.5 版本的一个缺陷。

同时,GC的准备阶段,初始化GC任务,包括开启写屏障、开启辅助GC和统计根对象列表的过程也需要STW一小段时间。

删屏障

删除屏障也是用来拦截写操作的,但是和写屏障不同,它不是通过给白色节点变色实现的,而是通过保护灰色节点指向白色节点的指针来实现的。还是上面那个例子,在删除指针 e 时将对象c标记为灰色,这样C下游的对象也会最终变成黑色,这种方式达成了弱三色不变性。这种方式的回收精度较低,因为一个对象即使是被删除了最后一个指向它的指针,它也还能活过这一轮GC(因为被标记成灰色了),在下一轮才能被清理。

混合写屏障

写屏障和删屏障一个是在白色对象插入黑色对象时保护白色对象,一个是白色对象删除和灰色对象的连接时保护白色对昂,两种方式各有优缺点:

  • 对于写屏障,在标记开始时无需STW,可以直接开始并发执行,但是结束时需要STW来重新re-scan栈。
  • 对于删屏障,则需要在一开始时进行STW扫描堆栈进行初始的快照,这样才能知道哪个白色对象和灰色对象断连了,而结束时才无需STW。

Go1.8 版本使用了混合写屏障和删屏障,同时允许黑色对象引用白色对象时将白色变灰,和在白色对象删除和灰色对象的联系时将白色对象变灰。

同样的,两种写屏障和删屏障都是作用在堆上的,没有作用在栈上,栈中的对象在开始进行标记时,都会变成黑色并在整个GC阶段一直保持,这样在标记结束后,由于栈中都是黑色,就不用再re-scan了。同时在GC标记时,堆上所有新创建的对象都标记成黑色,以防黑色的栈对象指向了堆上新创建的白色对象,出现错误删除。

Go1.8通过混合写屏障解决了go1.5版本re-sacn导致的STW几十毫秒的问题。

清扫过程

清扫时会删除掉所有白色对象,这个删除并不是把对应内存地址上都置为0,而是通过一个span上的标志位 allocBits,这个位表示这个span是否被分配,1表示被分配,0表示未使用。所以清扫过程就是把span上的这个标志位恢复即可。

清扫过程实际上是在发生在两种情况下:

  1. Go会启动一个后台goroutine叫做bgsweep(),它是定期休眠状态,一旦开始清扫,就会挨个mspan去清扫。
  2. 当申请内存时lazy触发。当goroutine需要在mheap上分配新内存时,就会触发该操作,这种清理导致的延迟会分散到每次的内存分配中。

GC的触发时机

一、内存分配量到达阈值时会触发GC

每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC。

阀值 = 上次GC内存分配量 * 内存增长率

内存增长率由环境变量 GOGC 控制,默认为100,即每当内存扩大一倍时启动GC。

二、定期触发GC

默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod 变量中被声明:

// forcegcperiod is the maximum time in nanoseconds between garbage
// collections. If we go this long without a garbage collection, one
// is forced to run.
//
// This is a variable for testing purposes. It normally doesn't change.
var forcegcperiod int64 = 2 * 60 * 1e9

三、手动触发

程序代码中也可以使用 runtime.GC()来手动触发一次GC。这主要用于GC性能测试和统计。


GC的过程还在随着版本不断优化,这篇文章只是大致做一个介绍,如果有空我会再仔细研究研究。