目录

GO语言的GC垃圾回收原理

GO语言的GC(垃圾回收)原理

Go语言(Golang)的垃圾回收(GC)机制经历了多个重要的发展阶段,每个阶段都在性能、停顿时间等方面进行了改进和优化,以下是各阶段的详细介绍:

一.GC简单介绍

什么是 GC(垃圾回收)

GC 即垃圾回收(Garbage Collection),是一种自动内存管理机制。在程序运行过程中,会不断地分配内存来存储各种数据,当这些数据不再被程序使用时,其所占用的内存就变成了“垃圾”。如果不及时回收这些内存,会导致内存泄漏,最终使程序耗尽系统资源而崩溃。垃圾回收机制的主要目的就是自动检测并回收这些不再使用的内存,让开发者无需手动管理内存的分配和释放,从而降低了编程的复杂度,提高了程序的稳定性和可维护性。 不同的垃圾回收算法有不同的方式来找到可回收对象,主要分为:引用计数法和可达性分析法(在这篇文章种介绍了这两篇方法: )。Go语言采用可达性分析法来找到可回收的对象。

GC Roots(根对象)是什么?

GC Roots(垃圾回收根对象)GC 在进行可达性分析(Reachability Analysis)时的起点 ,用来查找存活对象。 简单理解

  • GC 需要决定哪些对象存活、哪些对象应该被回收。
  • GC 不会扫描所有对象,而是从特定的根对象(GC Roots)出发,遍历整个可达对象图
  • 如果某个对象没有被 GC Roots 访问到,它就会被视为“垃圾”,并被回收

GC Roots 具体包含哪些内容?

GC Roots 主要包括以下几类:

1 栈上的局部变量
  • 函数调用栈中的变量 被视为 GC Roots,因为它们是程序当前执行的重要数据。
  • 这些变量通常存放在**线程栈(Thread Stack)**中。
  • 例如: func main() { a := &SomeStruct{} // 变量 a 作为局部变量存储在栈上 fmt.Println(a) // a 是 GC Roots,指向的对象不会被回收 }
2 全局变量和静态变量
  • 全局作用域中声明的变量 不会被 GC 轻易回收,因为它们可以在整个程序生命周期内访问。
  • 例如: var globalVar *int // 全局变量 func main() { num := 42 globalVar = # // globalVar 持有 num 的地址 }
  • globalVar 是 GC Roots,因此 num 也不会被回收。
3 进行中的 Goroutine
  • 所有活动的 Goroutine 及其栈上的变量都属于 GC Roots ,因为它们可能在后续执行过程中访问对象。
  • 例如: func worker(ch chan int) { val := <-ch // val 存在于 Goroutine 栈中 fmt.Println(val) } func main() { ch := make(chan int, 1) go worker(ch) // 启动 Goroutine ch <- 42 }
  • worker 函数中的 val 变量存活于 Goroutine 的栈中 ,因此它属于 GC Roots。
4 运行中的 JNI 引用
  • 在 Java、Go 等语言中,可能会与 C/C++ 代码进行交互(如 cgo)。
  • 任何被 C 代码持有的 Go 对象引用 ,在 GC 运行时也不会立即被回收。
5 内存管理系统中的特殊对象
  • Go 运行时维护的一些特殊对象,如反射(reflect)、内部缓存(比如map)、runtime 数据结构 等,都会被视为 GC Roots。
  • 例如: var cache = make(map[string]string) // cache 作为全局变量,不会被 GC 直接回收

GC Roots 是否等同于“扫描栈”?

不完全等同,但“扫描栈”是 GC Roots 识别的一部分!

  • GC Roots 本身不是目标 ,而是GC 进行可达性分析的起点 。GC 主要从 GC Roots 开始扫描对象引用栈上的局部变量 是 GC Roots 之一。
  • 不仅仅扫描栈,还包括全局变量、Goroutine、JNI 引用等
  • GC 主要关注堆上的对象 ,因为栈上的数据通常会随着函数调用的结束而自动释放,不需要 GC 处理。 示例: var globalVar *int // GC Roots func main() { localVar := 42 // 仅存于栈上 globalVar = &localVar // 使 globalVar 指向 localVar } // localVar 作用域结束,但由于 globalVar 仍然指向它,它不会被 GC 回收
  • globalVar 是 GC Roots,因此 localVar 即使超出作用域,也不会被 GC 立即回收
  • 如果globalVar = nil,那么 localVar 将在下一次 GC 时被回收

GC 最终回收的是栈上引用还是堆上的对象?

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

  1. 栈上的变量(引用)会随函数返回自动释放
  • Go 语言的栈管理是自动的,局部变量在函数执行完后自动出栈,不需要 GC 处理。
  • 例如: func foo() { a := 42 // a 存在于栈上 } // a 在函数结束后会自动销毁,不需要 GC 介入
  1. GC 主要负责管理堆上的对象
  • 只有在堆上分配的对象才需要 GC 处理 ,因为它们的生命周期不受函数作用域限制。
  • 例如: func foo() *int { a := new(int) // a 指向堆上的整数 *a = 42 return a // a 逃逸到堆上 }
  • a 指向的整数是在堆上分配 的,而 a 本身是栈上的引用
  • GC 只会回收堆上的整数 ,不会管 a,因为 a 会随函数结束自动销毁。

  • 示例:GC 仅回收堆上的对象 package main import “fmt” type Data struct { value int } func main() { var p *Data // 栈上的引用变量 p = &Data{value: 42} // 在堆上创建对象 fmt.Println(p.value) // 之后 p 设为 nil,原来的 Data 对象如果没有其他引用,就会被 GC 回收 p = nil }
  • p栈上的变量 ,它本身不会被 GC 处理。
  • Data{value: 42} 存储在堆上 ,当 p = nil 并且没有其他引用后,GC 就会回收这个对象。

二.Go的GC历程和细节

1 标记清除(Mark and Sweep)阶段(Go 1.0 - Go 1.3)

  • 设计原理
  • 标记阶段 :从根对象(如全局变量、栈上的变量等)开始,递归遍历所有可达对象,并将这些对象标记为存活。这个过程需要暂停程序的执行(Stop The World,简称 STW),因为在标记过程中如果程序继续修改对象的引用关系,会导致标记结果不准确。
  • 清除阶段 :遍历整个堆内存,将未标记的对象标记为可回收,并释放其占用的内存空间。这个阶段可以在程序继续执行的情况下进行,不会造成 STW。
  • 存在的问题标记阶段的 STW 会导致程序出现明显的停顿,尤其是在堆内存较大、对象较多的情况下,停顿时间会很长,影响程序的响应性能

2 三色标记法(Go 1.5)(核心)

三色标记设计(最初)
  • 三色抽象 :将对象分为白色、灰色和黑色三种颜色。白色表示对象未被访问过,灰色表示对象已被访问但它的子对象还未全部访问,黑色表示对象及其子对象都已被访问。
  • 标记过程 :初始时所有对象都是白色,从根对象开始遍历,将根对象及其直接引用的对象标记为灰色。然后不断从灰色对象集合中取出对象,将其标记为黑色,并将其引用的白色对象标记为灰色,直到灰色对象集合为空。此时,白色对象即为不可达对象,可以被回收。
  • 第一步:初始时,所有的对象标记为白色。注意,这里从栈上引出的对象最终是指向堆上的对象!https://i-blog.csdnimg.cn/direct/5981fad9fc30437d932f4f5876ce4bc4.png
  • 第二步:从根对象开始遍历,将根对象及其直接引用的对象标记为灰色。https://i-blog.csdnimg.cn/direct/f36c4e6fc70148ec86e00fb358db079c.png
  • 第三步:不断从灰色对象集合中取出对象,将其标记为黑色,并将其引用的白色对象标记为灰色。https://i-blog.csdnimg.cn/direct/acbeee6bf2df4bc7ae9d47ba980860e3.png
  • 第四步:重复第三步,直到灰色对象集合为空。此时的白色集合就是本次要回收的对象。https://i-blog.csdnimg.cn/direct/ac732fb304894cf1960c963029ac24a9.png
三色标记存在的问题
  • 问题1-黑色对象引用白色对象 :由于三色标记法在堆区不存在STW,在线程并发的情况下,可能存在黑色对某个白色对象的引用。例如下图中:在某个时刻,对象4断开了对对象3的引用,同时,对象5引用了对象4。由于对象5已经在黑色集合,而对象3无法被扫描到灰色集合中。那么,对象4就会被当作垃圾回收。从而产生了误删的情况。 https://i-blog.csdnimg.cn/direct/a043288253f64371a04a0c6002f76c9f.png
  • 问题2-灰色对象丢失下游的引用对象 :下图中,对象5在引用对象3的时候,此时对象2恰好删除对对象3的引用。导致对象3不能被标记为灰色。 https://i-blog.csdnimg.cn/direct/0d972172f80840e49a6bc9f28d106e80.png 当上述两个问题同时发生时,就会发生对象丢失的情况。最简单的解决办法就是加入STW,但是加入STW会带来GC的效率降低。那么如何进行优化呢?Go- GC定义了强弱三色不变式的条件来解决上述两个问题。
强/弱三色不变式
  • 强三色不变式 :不允许黑色对象引用白色对象。解决问题1。
  • 弱三色不变式 :黑色对象可以引用白色对象,但是白色对象必须存在被灰色对象引用,或者白色对象的上游存在可达它的灰色对象。解决问题2。 那么具体如何实现呢?Golang引入了插入屏障机制和删除屏障机制来保证强弱三色不变式的条件成立。
插入写屏障、删除屏障机制
  • 插入写屏障
  • 原理 :插入屏障是在新的引用关系建立时触发的一种机制。当一个黑色对象引用一个白色对象时,插入屏障会将这个白色对象标记为灰色,确保这个白色对象不会因为在并发标记过程中被遗漏而被错误地回收。
  • 过程介绍 :如下图,对象5引用了对象4,对象4被标记为灰色。在之后会从灰色变为黑色。此时,栈空间将其gc root的所有对象进行STW保护,从而删除白色对象。 https://i-blog.csdnimg.cn/direct/5476be111aa646d98d8fc26e0e4b6329.png
  • 不足 :在一次完整的遍历结束以后,栈空间需要一次STW来清除白色对象,大约10-100ms,造成效率低下。
  • 删除写屏障
  • 原理:删除屏障是在引用关系被删除时触发的机制。当一个灰色对象或黑色对象删除对一个白色对象的引用时,删除屏障会将这个白色对象标记为灰色,保证这个白色对象在后续的标记过程中仍然会被处理,避免被错误地回收。
  • 过程介绍:如下图,对象1删除引用对象2,对象5删除引用对象6,被删除的对象均会被标记为灰色。以保证下一次能够从灰色集合中标记为黑色。 https://i-blog.csdnimg.cn/direct/30b80a4761b1489c9eb32a6cbac28be7.png
  • 不足 :在一次完整的遍历结束以后,本来应该在本次GC后删除的对象,只能等到下一次GC扫描的时候才会被标记为白色从而被清除。
  • 改进之处 :在 Go 1.8 中引入了混合写屏障,它结合了插入写屏障和删除写屏障的优点。

3 混合写屏障(Go 1.8)(核心)

  • 混合写屏障机制
  • 主要规则 :(1)从在栈上的gcroot出发,能可达的对象在初始扫描时全部标记为黑色,并且创建的新对象均为黑色。(2)堆上的对象按照插入屏障、删除屏障来进行。
  • 流程 :首先,GC优先扫描栈,从栈可达的对象(1,2,3)均被标记为黑色,此时,栈上不开启插入写屏障。这就意味着,栈上任何新增和删除的对象都不会开启屏障,即保持新增黑色,删除不变的思想。然后,GC扫描堆上,开始将对象5标记为灰色,之后的流程遵循三色标记,并且堆上开启屏障。这就意味着,任何从堆上进行的添加和删除操作均会开启屏障,需要将其下游的对象进行灰色标记处理。 https://i-blog.csdnimg.cn/direct/98c203aab5534815badc4a79e2b6400d.png
  • 总结 :在 Golang 里,混合写屏障机制遵循弱三色不变式,巧妙融合了删除写屏障与插入写屏障的优势。在垃圾回收过程中,它只需在开始阶段并发地扫描各个 goroutine 的栈,将栈上对象标记为黑色,并且能保证其一直维持黑色状态。这一扫描过程无需暂停程序(即无需 STW)。由于栈在完成扫描后始终保持黑色,所以在标记操作结束时,就无需再次对栈进行重新扫描(re - scan)。如此一来,大大减少了因 STW 而导致的程序停顿时间,有效提升了程序的并发性能和整体执行效率。

4 并发标记和清理优化(Go 1.9 及以后)

Go 1.9 在垃圾回收(GC)方面进行了一系列优化,主要围绕减少停顿时间、提升并发性能和增强内存管理效率等方面展开,以下是详细介绍:

  • (1)减少 STW 时间
  • 混合写屏障的进一步优化
  • Go 1.8 引入了混合写屏障机制,Go 1.9 对其进行了进一步的调整和优化。混合写屏障结合了插入写屏障和删除写屏障的优点,减少了标记阶段的 STW 时间。在 Go 1.9 中,对写屏障的逻辑进行了更精细的控制,减少了不必要的标记操作,从而降低了写屏障带来的性能开销,进一步缩短了 STW 时间。
  • 例如,在并发标记阶段,对堆上对象引用关系的修改处理更加高效,能够更准确地标记对象,避免了一些误标记和重复标记的情况。
  • 优化栈扫描
  • Go 1.9 改进了栈扫描的算法和策略。栈扫描是 GC 过程中的一个重要环节,因为栈上的变量可能引用堆上的对象。在 Go 1.9 中,采用了更高效的栈扫描方式,减少了栈扫描的时间。
  • 比如,通过对栈上对象的快速分类和过滤,只扫描那些可能包含堆对象引用的栈帧,避免了对整个栈的无差别扫描,从而提高了扫描效率,减少了 STW 时间。
  • (2)提升并发性能
  • 细粒度的并发控制
  • Go 1.9 采用了更细粒度的并发控制机制,允许 GC 与程序在更多的场景下并发执行。在之前的版本中,部分操作可能需要暂停程序来保证 GC 的正确性,但在 Go 1.9 中,通过优化并发控制逻辑,将这些操作拆分成更小的任务,使得这些任务可以与程序并发执行,减少了对程序的阻塞。
  • 例如,在标记阶段,对不同区域的内存标记可以并行进行,提高了标记的并发度,从而提升了整个 GC 过程的并发性能。
  • 减少锁竞争
  • 在 GC 过程中,锁的使用会影响并发性能。Go 1.9 对锁的使用进行了优化,减少了锁竞争的情况。通过使用更细粒度的锁或者无锁算法,降低了不同 goroutine 之间对锁的争用,提高了并发性能。
  • 比如,在内存分配和回收的过程中,采用了更高效的锁机制,使得多个 goroutine 可以更高效地同时进行内存操作,减少了等待锁的时间。
  • (2)增强内存管理效率
  • 优化内存分配器
  • Go 1.9 对内存分配器进行了优化,提高了内存分配和回收的效率。内存分配器是 GC 系统中的重要组成部分,它的性能直接影响到程序的整体性能。
  • 例如,采用了更智能的内存分配策略,根据对象的大小和使用频率进行分类管理,减少了内存碎片的产生,提高了内存的利用率。
  • 改进内存清理策略
  • 在 GC 的清扫阶段,Go 1.9 改进了内存清理策略。通过优化清扫算法和数据结构,提高了清扫的效率,减少了清扫过程中的性能开销。
  • 比如,采用了更高效的空闲内存块合并算法,将相邻的空闲内存块合并成更大的块,方便后续的内存分配,提高了内存管理的效率。