1 GC 的基本概念

1.1 GC 的作用

GC:Garbage Collection,垃圾回收,是一种针对堆内存的自动内存管理机制,区别于 C++ 一类语言的手动申请和释放的过程。

当程序中的某一段内存不再被需要,GC 自动将其回收,这段内存空间留作其他代码申请或返还给操作系统。

一方面,在 GC 的帮助下,无需手动对内存进行申请和释放,编码过程更简单;另一方面,GC 的运行过程对外几乎不可见、不可控,仅在特殊情况下调用相关 API 才能受代码控制。

1.2 概念

  • 赋值器(Mutator):即用户态的程序代码。在 GC 的视角里,代码负责创建对象、修改对象间的引用关系等。
  • 回收器(Collector):负责执行垃圾回收的代码,工作原理不同。
  • 根对象(Root):回收器检查内存的起点。包括全局变量、虚拟栈、寄存器等。

1.3 常见的 GC 分类

  • 追踪式:从根对象开始,检查对象间的引用关系,标记被检查到的内存,扫描完保留所有被标记对象,剩余的为可回收对象(不一定被回收)。
  • 引用计数式:每个对象包含一个被引用的计数器,当计数器归零证明使用完毕,进行回收。

Go 语言使用追踪式 GC 进行垃圾回收。追踪式 GC 的细分如下:

  • 标记清扫:从根对象出发,便利与之关联的内存,确定需要保留和清扫的内存。
  • 标记整理:不删除,而是将对象整理到一块连续的内存上。
  • 增量式:标记和清理工作分批次执行,每次针对很小的部分,降低 GC 工作过程中的实感。
  • 增量整理:在增量式与整理标记方法结合进行。
  • 分代式:根据对象的作用域分为年轻代、老年代、永久代,倾向于回收年轻代的对象。

Go 语言选择非整理、非分代、与用户态并发的清理策略,采用增量式三色标记清扫算法

  • Go 内存分配算法基于 tcmalloc,基本解决了碎片问题,整理内存收效不大。

    tcmalloc:Google 开发的内存分配器,主要用于对抗内存碎片,详见 Tcmalloc

  • 分代式倾向于检查新创建的年轻代对象,但 Go 的编译器通过逃逸分析,将大部分新生对象在栈中创建,会随着栈被一起回收;长期存在的对象才会在堆中创建,由 GC 负责检查和回收。因此分代式策略针对 Go 语言优势同样不明显。

    逃逸分析:决定一个对象存放在堆中还是栈中,可以减小 GC 检查的压力,降低内存碎片的产生。

    逃逸:本应位于栈中的内存由于被外部引用等情况被分配到堆中。

1.4 STW

STW:Stop/Start The World,通常指代从 StopStart 之间的一段时间间隔。

STW 的意义:需要使代码在垃圾回收过程中停止运行,以确保标记过程中已检查的对象不会被修改,导致结果错误。

  1. Stop:停止程序的运行,所有对象的引用关系保持不变。
  2. 标记:从根节点开始,标记所有可达对象。
  3. Start:回收未被标记的对象,程序继续运行。

在现代 GC 中,全程 STW 的停下时间是不可忍受的,Go 团队设计了三色标记算法,以确保 GC 与用户程序互不干扰,并发进行。

1.5 三色标记算法

三种对象的类型:

  • 白色对象(可能被回收):从根对象开始检查,无法达到的对象。回收开始时,所有对象默认为白色对象;回收结束后,没有被检查的对象保持白色,可能会被回收。
  • 灰色对象:自身已经被检查到,但其中的指针还未被检查,可能指向白色对象。中间态,最终都会变成黑色对象
  • 黑色对象:自身和所有指针均已被扫描。不直接指向白色对象。
gif1

这样的设计同样存在问题,假设黑色对象被检查后再指向白色对象,那么其不会变回灰色对象,白色对象也不会因此最终变成黑色对象。

error15-43-10-3-23

在无法解决这个问题的情况下,Go 团队在 1.3 之前采用传统的标记清除法,需要 STW 停止整个程序来实现正确清除。

1.6 写屏障

在 Go v1.5 之后,为了在不使用 STW 的同时保证正确性,正确地使用三色标记算法,我们分析发生错误的两个条件:

  1. 白色对象被黑色对象引用。
  2. 灰色对象与白色对象之间的可达性关系被破坏。

只有当二者同时成立,才有可能发生错误,故 Go 团队相应地提出了两种方法:

  • 强三色不变式:不允许黑色对象引用白色对象。
  • 弱三色不变式:黑色对象可以引用白色对象,但白色对象一定可以被灰色对象直接或间接引用
Screenshot 2023-03-10 at 20.40.48
强三色不变式                                   弱三色不变式                               

针对以上两种不变式机制,Go 团队设计了对应的实现机制:插入写屏障和删除写屏障。

1.6.1 插入写屏障

针对强三色不变式,当一个对象引用另外一个对象时,将被引用对象标记为灰色。

  1. 当一个对象引用另外一个对象,直接将被引用对象设为灰色对象。
  2. 此时不管引用对象为黑色还是灰色,都不影响被引用对象不会被判为白色回收。
  3. 由于栈内存没有配备写屏障,为了保证正确,对栈空间实行 STW 保护,程序停止。
  4. 重新扫描栈空间,防止误删。
error16-36-10-3-23

缺点:需要对栈进行 STW 保护,重新扫描一遍。

1.6.2 删除写屏障

针对弱三色不变式,在删除引用时,如果被引用对象为白色,那么被标记为灰色,保证白色对象能够永远被指向自己的引用保护。

  1. 在一个程序中,被引用对象最终一定可以称为黑色对象。
  2. 在 GC 开始之前,扫描所有对象,保证所有可达的对象都被“保护”。
  3. 当删除一个引用时,如果被引用对象为白色,则将其设为灰色。
  4. 保证了原来可以被灰色对象“保护”的对象在删除引用后依然被“保护”。
error16-44-10-3-23

缺点:回收精度较低。从未在 Go 语言中使用。

1.6.3 混合写屏障

针对栈的操作:能够保证其所有可达的对象都不会被回收,无需进行 STW 保护。

  1. GC 刚开始时,将栈上所有可达对象设为黑色对象。
  2. GC 运行过程中,栈上所有新创建的对象都自动设为黑色对象。

针对堆的操作:

  1. 堆上被删除引用的对象设为灰色对象。
  2. 堆上新添加的对象设为灰色对象。
error17-11-10-3-23

2 GC 的使用

2.1 GC 的工作流程

Go 语言采用的是是标记清扫机制 GC,广义来说分为 Mark 和 Sweep 两个执行阶段

2.1.1 Mark

  1. Mark Setup:Goroutine 执行特定的函数调用,暂停运行,程序进入 STW 状态,开启写屏障,回收器启动。
  2. Marking:回收器指派 25% 的 CPU 可用资源进行标记工作,如有需要还可能指派更多的协程作为 Mark Assist 进行标记。程序恢复工作,二者并发执行
  3. Mark Termination:Goroutine 再次暂停,进入 STW 状态,关闭写屏障,标记结束。

2.1.2 Sweep

当 Goroutine 尝试在堆中申请新内存时,回收器未被标记的内存返还给堆,重新分配。

2.2 GC 的触发时机

  • 主动触发:通过调用 API runtime.GC 来触发 GC,阻塞式地等待 GC 运行完毕。
  • 被动触发
    • 系统监控:当超过两分钟没有产生任何 GC 时,强制触发 GC。
    • 步调算法:设置下一次堆内存的增长率,达到以后自动触发 GC。核心思想是控制内存增长的比例。

2.3 GC API 的使用

  • runtime.GC:手动触发 GC。
  • runtime.ReadMemStats:读取内存相关的统计信息,其中包含部分 GC 相关的统计信息
  • debug.FreeOSMemory:手动强制将回收的内存返还给操作系统,事实上,即使不调用也会在后台返还
  • debug.ReadGCStats:读取 GC 相关的统计信息。
  • debug.SetGCPercent:设置 GO GC 步调算法的变量,即堆的增量率。

2.4 观察 GC

  • GODEBUG=gctrace=1 输出 debug 相关的环境变量信息。
  • go tool trace:统计获取的信息并将其可视化。
  • debug.ReadGCStats:直接在命令行输出统计信息。
  • runtime.ReadMemStats:运行内存相关的 API 实时监控

3 GC 的优化问题

3.1 GC 关注的指标

  • CPU 利用率:主要关注回收算法对于程序性能的影响,通过计算回收占用的 CPU 时间与其它 CPU 时间的比例来描述的。
  • GC 停顿时间:回收器造成的 STW 时长。目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。
  • GC 停顿频率:回收器造成的停顿频率。目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。
  • GC 可扩展性:当堆内存变大时,垃圾回收器的性能如何。

3.2 内存泄漏

内存泄漏的表现:

  • 预期的能很快被释放的内存由于附着在了长期存活的内存上
  • 生命期意外地被延长,导致预计能够立即回收的内存长时间不能回收。

有几种常见的形式:

  • 根对象引用:全局变量等将某个临时变量附着在其上,导致无法被回收。
  • goroutine 泄漏:运行中的 goroutine 需要一部分内存来存放其上下文,这部分内存不会被回收。因此如果不及时结束 goroutine 并复用这段内存,会导致内存泄漏。
  • Channel 泄漏:channel 会连接两个不同的 goroutine,如果一个 goroutine 尝试向没有接收方的 channel 发送消息,则会被永久休眠,整个栈和上下文都无法被回收。

3.3 内存分配与标记清除

  1. 当 GC 触发后,会首先进入并发标记的阶段。
  2. 并发标记会设置一个标志,并在 mallocgc 调用时进行检查。
  3. 当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。