Go 垃圾回收(GC)

71 阅读13分钟

GC

基本概念

垃圾回收

一种内存管理策略,由垃圾收集器以类似守护协程的方式在后台运作,按照既定的策略为用户回收那些不再被使用的对象,释放对应的内存空间。

时停(STW, Stop The World)

在GC的过程中,Runtime或多或少地需要暂停所有运行中的CPU指令,避免回收到目前正在使用的内存,或者说产生悬挂指针(指针指向已被回收的地址), 虽然有各种机制减少STW的次数和时间,但目前还不能完全避免STW的产生。

STW是影响程序性能的罪魁祸首,目前度量GC优劣的很重要的标准就是STW对程序性能的影响,该影响也有很多度量方式,例如STW总时间最小,最大单次STW时间最少等。

GC Roots(根对象)

根对象是GC在进行可达性分析时的起点,用来查找存货对象。

GC Roots包含哪些类型呢:

  1. 栈上的局部变量
  2. 全局变量和静态变量
  3. 进行中的goroutine(所有活动的 goroutine 及其栈上的变量)
  4. 运行时维护的特殊对象(反射、内部缓存(map、slice)、runtime数据结构)

一些结论

  1. GC会扫描栈内存但是不会管理栈内存

    扫描:扫描的目的是为了在标记阶段找出所有根对象的引用,以确定堆中的存活对象(堆中被栈引用的对象)

    管理:栈内存的生命周期由运行时自动管理(创建时分配栈,回收时自动回收栈)

  2. GC最终回收的是堆上的对象而不是栈上的引用

    栈上的变量(引用)会随函数返回自动释放

    只有在堆上分配的对象才需要 GC 处理,因为它们的生命周期不受函数作用域限制

  3. ...

Go GC 的发展历程

Go 的垃圾回收机制是由简单到复杂逐步发展的,我们以Go 的版本更新顺序来介绍Go 使用的垃圾回收机制。

v1.0 - v1.3 - 标记清除(Mark-Sweep)

v1.5 - 并发三色标记法 + 插入写屏障

v1.8 - 并发三色标记法 + 混合写屏障机制

标记-清除

Go v1.3 之前使用的垃圾回收算法。

步骤:

标记:从根对象(全局变量、goroutine栈、寄存器)开始,递归遍历所有可达对象,并将这些对象标记为存活;

清除:遍历整个堆内存,将未标记的的对象记为可回收,并释放其占用的内存空间;

问题:

整个 GC 阶段都会执行 STW, 会导致程序出现明显的停顿,尤其是在堆内存较大、对象较多的情况下,停顿时间会很长,影响程序的响应性能。

D-GC-清除-标记算法的SWT图.png

并发三色标记法+插入写屏障机制

Go v1.5 引入了并发三色标记法来处理垃圾回收,允许用户协程和后台GC协程并发运行。

并发的过程中可能会遇到一些问题,就是已经被标记好的对象可能会重新引用新的对象,导致这部分新引用的对象被误删或漏删的情况发生;所以可能需要 STW 来避免这些问题发生。

三色标记法

颜色标记的意义:

  • 黑色:表示对象已经被扫描,并且所有引用的对象也已经被标记,黑色对象不会被回收。
  • 灰色:表示对象已经被发现并且被标记,但其引用的对象还没有被扫描,灰色对象需要进一步处理。
  • 白色:表示对象还没有被标记,如果在垃圾回收结束时,对象仍然是白色的,那么它将被回收。

标记的流程: D-GC-三色标记法流程图.png

  1. 初始化:
    • 首先所有的对象都被标记为“白色”
    • 从根对象开始非递归标记(每次只找到下一层引用),并将下一层引用到的对象标记为灰色
  2. 标记
    • 遍历灰色对象列表,将每一个对象都标记成“黑色”
    • 遍历这些灰色对象的所有引用,如果被引用的对象是白色的,则将其标记为灰色
    • 重复以上步骤,直到灰色对象列表为空即可
  3. 清除
    • 遍历所有对象:
    • 如果对象是“白色”的,说明是不可达对象,需要被回收
    • 如果对象是“黑色”的,说明是可达对象,保留不动

可能存在的问题:

如果三色标记法不使用 STW 就可能会出现对象丢失的问题,需要同时满足两个条件:

  1. 一个白色对象被黑色对象引用
  2. 灰色对象丢失下游的引用的白色对象

避免同时发生上面两种情况除了使用STW还有一种方法,使用屏障机制去破坏上面两个必要条件即可;

屏障机制

强-弱三色不变式

强三色不变式:不允许黑色对象引用白色对象(直接破坏黑色引用白色对象)。

弱三色不变式:黑色对象可以引用白色对象,但白色对象上游必须有灰色对象(保障白色对象还有机会被 GC 扫描标记)。

插入写屏障

实现了强三色不变式规则,保证当一个黑色对象指向一个白色对象前,触发机制将这个白色对象置为灰色,再建立引用。

删除写屏障

实现了弱三色不变式规则,保证当一个白色对象即将被上游删除引用前,会触发机制将其置灰,之后再删除上游指向其的引用。

一个特点

屏障机制只对堆上的对象有用,栈上对象不使用屏障机制。

为什么栈内存不能使用屏障机制?

  1. 栈内存管理简单:栈内存的管理相对简单,不需要复杂的垃圾回收机制。栈上的变量在函数返回时自动回收,不需要像堆内存那样进行标记和清除。
  2. 栈内存分配和回收频繁:栈上的对象通常是局部变量,生命周期较短,并且在函数调用和返回时会频繁地分配和释放。这使得栈上的对象变化非常频繁和复杂,难以通过屏障机制来精确跟踪。
  3. 性能开销:屏障机制会增加额外的性能开销,尤其是在高频率的读写操作中。栈内存的访问频繁,使用屏障机制会显著降低性能。
  4. 内存一致性:栈内存的生命周期短且结构简单,不太可能出现内存一致性问题。堆内存中的对象引用变化较多,因此需要屏障机制来确保一致性。
具体流程
  1. 标记开始
    • 暂停所有 goroutine (STW)
    • 扫描根对象(goroutine栈、全局变量、寄存器)
    • 启用插入写屏障机制
  2. 并发标记
    • 从灰色对象出发,逐层标记可达对象
    • 若用户程序有并发修改堆引用,则触发插入写屏障机制(黑色对象引用白色对象,会先将白色对象置为灰色再建立引用)
    • 重复执行,直到没有灰色对象为止
  3. 再次标记
    • 一次三色标记结束后,再进行一次 STW 操作,并重新扫描上的对象
    • 防止因并发执行时,有栈上的黑色对象重新引用了新的对象而导致的误删
    • 栈上的对象不使用屏障机制
  4. 并发清除
    • 此阶段无需 STW
    • 后台GC线程回收白色对象(不可达对象)
    • 用户线程可继续执行

优点:

  1. 显著减少STW时间(1.5版本之前需要全程STW)
  2. 引入了简单安全的插入写屏障机制
  3. 引入并发处理,充分利用多核CPU性能

缺点:

  1. 仍需第二次STW扫描栈上的内存块

使用 并发三色标记法+插入写屏障机制 的GC机制且不引用删除写屏障机制也能确保对象不丢失的原因是:

  1. 在堆中会有插入写屏障机制保证强三色不变性
  2. 至于栈中的情况,会在并发标记结束后重新STW并扫描一遍
  3. 以上两步保证了不会出现对象被误删漏删的情况。

并发三色标记法+删除写屏障机制

基本思路一致,特点是使用删除写屏障机制,在删除一个白色对象引用块时会将被删除的这个对象置为灰色,但是这个灰色的对象及其下游内存块需要等下次GC时才会被清理掉;

这个方法降低了GC回收的精度,产生很多的冗余扫描成本。

并发三色标记法+混合写屏障机制

插入写屏障和删除写屏障的缺点:

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

所以在 Go v1.8 中引入了混合写屏障机制,结合这两个的优点,同时避免了这两个缺点;

具体规则:

  1. GC开始时,会将栈上的可达对象全部标记为黑色(之后不再进行第二次重复扫描)。
  2. GC 期间,任何在栈上新创建的对象,均为黑色。
  3. 堆上被删除的对象被标记为灰色(删除写屏障)。
  4. 堆上新引用的对象被标记为灰色(插入写屏障)。

可以看到这个规则避免了二次STW重新扫描栈带来的性能影响;虽然引入删除写屏障会降低回收的精度,但是利大于弊。

具体流程:

标记准备阶段:

启动 STW,内存状态到达一致性,扫描栈内存、全局变量、寄存器等根对象,开启混合写屏障机制;

混合写屏障机制的作用:记录对象引用的修改,混合写屏障机制在开启后,任何新的对象引用修改都会被记录下来;

在扫描goroutine栈时会将所有可达对象标记为黑色,且后续有新的引用对象也会标记成黑色;

当某个 Goroutine 被扫描完成,会立即恢复该 Goroutine 的执行,分批扫描,分批启动;

并发标记阶段:

此阶段垃圾回收器和应用程序并行运行;

递归处理灰色对象,并将它们本身标记为黑色。这个过程一直持续到所有灰色对象都被处理完毕。

在并发标记阶段期间,当对象引用被修改时,混合写屏障会记录这些修改,将新引用的对象标记为灰色(堆上被删除的对象标记为灰色,堆上新添加的对象标记为灰色),将新引用的对象标记为灰色,以便在标记过程中处理。

标记完成阶段:

当垃圾回收器确定所有的灰色对象都已经被处理完毕时,会触发标记完成 STW;

暂停所有goroutine,目的是为了在标记完成阶段有并发的新的引用产生,导致状态的不一致性;

处理剩余的写屏障记录,垃圾回收器需要处理所有剩余的写屏障记录,屏障记录和标记是并发的,因此存在处理延迟,可能存在部分屏障记录没有被处理,因此在标记完成 STW 事件阶段,需要特别处理所有剩余的屏障记录,以确保内存状态的一致性和正确性;确保所有引用修改都能被正确标记,处理剩余的写屏障记录,可以确保在并发标记阶段发生的所有引用修改都能被正确记录和处理。

关闭混合写屏障机制;

恢复所有goroutine;

注意这个阶段优化了Re-scan;

并发清除阶段:

并发清除阶段是垃圾回收过程中的最后一个主要阶段,在此阶段,垃圾回收器会清除所有没有被标记的对象。这一阶段的主要目的是释放内存,以便供应用程序重新使用。

  1. 开始并发清除阶段

在并发清除阶段,垃圾回收器和应用程序并行运行。应用程序继续执行其正常操作,而垃圾回收器在后台执行清除操作。

  1. 清除未标记的对象

遍历堆内存:垃圾回收器会遍历整个堆内存,查找所有未被标记的白色对象。白色对象:在标记阶段结束时,任何未被标记为黑色或灰色的对象都是白色对象,这些对象被认为是不可达的,需要被清除。

释放内存:垃圾回收器会释放所有未被标记的白色对象的内存。内存释放:释放内存可以使这些内存区域重新可用,以便供应用程序分配新的对象。

  1. 更新内存管理数据结构

更新空闲列表:在清除未标记的对象后,垃圾回收器会更新内存管理的数据结构,如空闲列表,以反映新的内存状态。空闲列表:空闲列表是一个数据结构,用于管理可用的内存块。更新空闲列表可以确保新的内存分配请求能够正确地使用已释放的内存。

  1. 并发清除阶段的结束

清除完成:当垃圾回收器遍历完所有堆内存并清除所有未标记的对象后,并发清除阶段结束。

准备下一次垃圾回收:清除阶段完成后,垃圾回收器会准备下一次垃圾回收的必要数据和状态。并发清除阶段结束后,通常不会有专门的 STW 事件。然而,垃圾回收器可能会执行一些小的、短暂的暂停操作来进行必要的状态更新和清理工作,但这些操作通常非常快,不会对应用程序的性能产生显著影响。

GC 触发条件

  1. GC 的时机可以通过一个环境变量 GOGC 来控制,默认是 100 ,即增长 100% 的堆内存才会触发 GC。
  2. 默认每 2min 未产生GC时,Go的守护协程 sysmon 会强制触发 GC。
  3. 当 Go 程序分配的内存增长超过阈值时,会触发 GC。

GC 优化经验

GOGC参数...