golang垃圾回收

Golang
446
0
0
2022-11-16

0.1、索引

waterflow.link/articles/1664943418...

文中提到的垃圾回收算法是基于go1.16之后的,让我们直接进入正题吧。

1、什么时候需要垃圾回收?

https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8b33a7f.jpg

Go 更喜欢在堆栈上分配内存,因此大多数内存分配最终都会在栈上。 这意味着 Go 每个 goroutine 都有一个堆栈,并且在可能的情况下,Go 会将变量分配给这个堆栈。 Go 编译器试图通过执行逃逸分析来查看对象是否被外部变量引用。 如果编译器可以确定一个变量的生命周期,它将被分配到一个堆栈中。 但是,如果变量的生命周期不明确,它将在堆上分配。 通常,如果 Go 程序有一个指向对象的指针,则该对象存储在堆上。 看看这个示例代码:

type myStruct struct {
  value int
}
var testStruct = myStruct{value: 0}
func addTwoNumbers(a int, b int) int {
  return a + b
}
func myFunction() {
  testVar1 := 123
  testVar2 := 456
  testStruct.value = addTwoNumbers(testVar1, testVar2)
}
func someOtherFunction() {
  myFunction()
}

我们假设这是一个正在运行的程序的一部分,因为如果这是整个程序,Go 编译器会通过将变量分配到堆栈中来优化它。 程序运行时:

  1. testStruct 被定义并放置在堆上的一个可用内存块中。
  2. myFunction 在函数执行时被执行并分配一个栈。 testVar1 和 testVar2 都存储在此堆栈中。
  3. 当 addTwoNumbers 被调用时,一个新的栈帧被压入栈中,并带有两个函数参数。
  4. 当 addTwoNumbers 完成执行时,它的结果返回给 myFunction 并且 addTwoNumbers 的堆栈帧从堆栈中弹出,因为它不再需要了。
  5. 指向 testStruct 的指针被定为到包含它的堆上的位置,并且值字段被更新。
  6. myFunction 退出并且为其创建的堆栈被清理。 testStruct 的值会一直保留在堆上,直到发生垃圾回收。

testStruct 现在在堆上并且没有分析,Go 运行时不知道是否仍然需要它。 为此,Go 依赖于垃圾回收器。 垃圾回收器有两个关键部分,一个 mutator 和一个回收器。 回收器执行垃圾收集逻辑并找到应该释放其内存的对象。 mutator 执行应用程序代码并将新对象分配给堆。 它还会在程序运行时更新堆上的现有对象,其中包括在不再需要某些对象时使其无法访问。

https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8c0d677.jpg

2、垃圾回收器的实现

Go 的垃圾收集器是一个非分代并发三色标记清除垃圾回收器。 让我们分解一下这些术语。

什么是分代:

由于“复制”算法对于存活时间长,大容量的储存对象需要耗费更多的移动时间,和存在储存对象的存活时间的差异。需要程序将所拥有的内存空间分成若干分区,并标记为年轻代空间和年老代空间。程序运行所需的存储对象会先存放在年轻代分区,年轻代分区会较为频密进行较为激进垃圾回收行为,每次回收完成幸存的存储对象内的寿命计数器加一。当年轻代分区存储对象的寿命计数器达到一定阈值或存储对象的占用空间超过一定阈值时,则被移动到年老代空间,年老代空间会较少运行垃圾回收行为。一般情况下,还有永久代的空间,用于涉及程序整个运行生命周期的对象存储,例如运行代码、数据常量等,该空间通常不进行垃圾回收的操作。 通过分代,存活在局限域,小容量,寿命短的存储对象会被快速回收;存活在全局域,大容量,寿命长的存储对象就较少被回收行为处理干扰。——维基百科

分代垃圾回收器专注于最近分配的对象。 但是,如前所述,编译器优化允许 Go 编译器将具有已知生命周期的对象分配给堆栈。 这意味着更少的对象将在堆上,因此更少的对象将被垃圾回收。 这意味着在 Go 中不需要分代垃圾回收器。 因此,Go 使用了非分代垃圾回收器。 并发意味着回收器与 mutator 线程同时运行。 因此,Go 使用非分代并发垃圾回收器。 标记和清除是垃圾回收器的类型,三色是用于实现它的算法。

Go 通过几个步骤实现了这一点:

1、开启写屏障

Go 通过一个名为 stop the world 的进程让所有 goroutine 到达垃圾回收安全点。 这会暂时停止程序运行并打开写屏障以维护堆上的数据完整性。 这通过允许 goroutine 和回收器同时运行来实现并发。

https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8c4b351.jpg

想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的一种:

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径;
  • https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8ce6df9.jpg

一旦所有的 goroutine 都打开了写屏障,Go 运行时就会starts the world并让workers执行垃圾回收工作。

2、标记阶段

标记是通过使用三色算法实现的。 当标记开始时,根对象是灰色的,其他对象都是白色的。 根是所有其他堆对象的源对象,并作为运行程序的一部分被实例化。 垃圾回收器通过扫描堆栈、全局变量和堆指针开始标记以了解正在使用的内容。 扫描堆栈时,workers 停止 goroutine 并通过从根向下遍历将所有找到的对象标记为灰色。 扫描完成恢复 goroutine。

三色标记的工作原理:

  1. 从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
  2. 将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
  3. 重复上述两个步骤直到对象图中不存在灰色对象;

下图是准备标记:

https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8d3bc96.jpg

下图为当有新对象生成时,因为开启了写屏障,会直接标记为黑色

https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8dba55a.jpg

下图为根对象可达的对象都标记为黑色

https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8e12f57.jpg

3、清理阶段

然后将灰色对象排入队列以变为黑色,这表明它们仍在使用中。 一旦所有灰色物体都变成黑色,回收器将再次stop the world并清理所有不再需要的白色节点。 然后应用程序现在可以继续运行,直到它需要再次清理更多内存。

下图为STW然后清理白色对象

https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8e5b270.jpg

下图为清理之后,恢复程序运行

https://www.leyeah.com/upload/cms-images/2022/11/16/63747c8ec163d.jpg

一旦程序分配了与正在使用的内存成比例的额外内存,此过程将再次启动。 GOGC 环境变量决定了这一点,默认设置为 100。 Go 源代码将其描述为:

如果 GOGC=100 并且我们使用 4M,我们将在达到 8M 时再次进行 GC(此标记在 next_gc 变量中跟踪)。 这使 GC 成本与分配成本成线性比例。 调整 GOGC 只会改变线性常数(以及使用的额外内存量)。