Go GC初探

Go目前的GC方案是三色标记法+混合写屏障。

Golang的垃圾回收(GC)算法使用的是无无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。原因在于:

  • 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于tcmalloc,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于tcmalloc的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。

  • 分代GC依赖分代假设,即GC将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。

  • Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代GC回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当goroutine死亡后栈也会被直接回收,不需要GC的参与,进而分代假设并没有带来直接优势。

  • Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

三色标记法

  1. 从GC root中开始扫描,与GC root直接相连的对象标记为灰色对象,也是待处理的set。
  2. 扫描所有待处理的灰色对象,将直接相邻的对象设置为灰色,放入待处理set,并将当前对象设置为黑色。
  3. 重复第二步,直到没有灰色对象了。此时所有白色对象都是垃圾,需要被清理。

上述方案的问题

如果三色标记过程中对象的引用关系没有发生变化,不会有问题,一旦发生变化就会出现问题。举两个例子:

场景一:已经是黑色的对象新增了一个对象的引用(白色)。因为黑色已经被扫描过了,所以该白色对象最终会当成垃圾回收掉。 场景二:一个灰色对象删除了某个白色对象的引用,而且该下游白色对象不被其他的灰色或者白色对象引用了。则该对象也失去了被扫描的机会,只会成为垃圾。

上面的两个场景其实就是三色标记法出问题的两个必要条件:

  1. 黑色对象A突然持有了白色对象B的引用(A突然持有了B)
  2. 任意其他的灰色对象C到白色对象B的引用路径都被破坏了(C突然不持有B了)

条件2是指,三色标记法的可达性分析扫描不到B了。条件1是指,B确实有被引用。满足这两个条件,肯定就是有问题的。

如果想解决上面的问题,要么引入stw来保护三色标记时引用关系不改变,要么就要引入屏障。

插入屏障

对象A引用对象B时,强制将B染成灰色,破坏条件1。

劣势:只适合用于堆中对象,栈中对象因为压栈出栈频繁,屏障技术的引入的性能损耗太大了。为了保证栈中的垃圾正确被清理,三色标记结束还是要有stw来清理栈中的垃圾。(这也是删除屏障的问题)

删除屏障

C删除B的引用时,将B染成灰色,破坏条件2

劣势:这种策略过于保守,C不再持有B后,B可能真的是垃圾了,但是删除屏障将B保护起来了,在下一轮再进行垃圾清理。

Go所使用的混合屏障

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

简要总结:

  • 堆栈分治,堆使用混合屏障,栈不启动屏障(举例:栈对象A引用堆对象B时,不会将B标灰),只标记成黑色。
  • 堆使用混合屏障,保证增加和删除都有所保护。

逃逸分析

ChatGPT-4

逃逸分析(Escape Analysis)是编译器决定数据应该分配在栈还是堆的一种技术。如果一个对象只在局部使用,并且没有对它的引用逃逸到其他函数或者协程,那么这个对象就可以在栈上分配内存,当函数返回时,这个对象就会被销毁,无需垃圾收集器的参与。如果一个对象在函数返回后仍然需要被其他函数或者协程引用,那么这个对象就需要在堆上分配内存,由垃圾收集器管理。

Golang的编译器使用逃逸分析来确定变量的存储位置。Go的编译器通过静态代码分析确定变量是应该存储在栈还是堆上。如果变量只在函数内部使用并且不会被函数外部引用,那么这个变量就会存储在栈上。如果变量可能被函数外部引用,那么这个变量就会存储在堆上。

你可以在编译Go程序时加上-gcflags '-m'参数,来查看编译器的逃逸分析信息。例如:go build -gcflags '-m' main.go

注意,这个逃逸分析并不是百分之百准确的,因为涉及到了静态代码分析,而代码的运行情况可能会比静态分析要复杂。但是,在很多情况下,这个逃逸分析已经足够好,能够帮助编译器做出有效的内存管理决策。

参考文档