Golang GC 原理

Go 1.5 以后(截止Golang v1.12)采用了非分代非紧缩写屏障三色标记的原理进行垃圾回收。

Golang GC 原理

  • 非分代:不像Java那样分为年轻代和年老代。
  • 非紧缩:在垃圾回收之后不会进行内存整理以清除内存碎片。
  • 写屏障:在并发标记的过程中,如果应用程序(mutator)修改了对象图,就可能出现标记遗漏的可能,写屏障就是为了处理标记遗漏的问题。

  • 三色:将GC中的对象按照搜索的情况分成三种:

  1. 黑色: 对象在这次GC中已标记,且这个对象包含的子对象也已标记
  2. 灰色: 对象在这次GC中已标记, 但这个对象包含的子对象未标记
  3. 白色: 对象在这次GC中未标记

GC 过程

Marking setup

为了打开写屏障,必须停止每个goroutine,让垃圾收集器观察并等待每个goroutine进行函数调用, 等待函数调用是为了保证goroutine停止时处于安全点。

1
2
3
4
5
6
7
8
// 如果 goroutine4 处于如下循环中,运行时间取决于 slice numbers 的大小
func add(numbers []int) int {
var v int
for _, n := range numbers {
v += n
}
return v
}

下面的代码中,由于for{}循环所在的goroutine 永远不会中断,导致始终无法进入STW阶段,资源浪费;Go 1.14 之后,此类goroutine 能被异步抢占,使得进入STW的时间不会超过抢占信号触发的周期,程序也不会因为仅仅等待一个goroutine的停止而停顿在进入STW之前的操作上。

1
2
3
4
5
6
7
8
9
func main() {
go func() {
for {
}
}()
time.Sleep(time.Milliecond)
runtime.GC()
println("done")
}

Marking

一旦写屏障打开,垃圾收集器就开始标记阶段,垃圾收集器所做的第一件事是占用25%CPU。

标记阶段需要标记在堆内存中仍然在使用中的值。首先检查所有现goroutine的堆栈,以找到堆内存的根指针。然后收集器必须从那些根指针遍历堆内存图,标记可以回收的内存。

当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。

Mark终止

关闭写屏障,执行各种清理任务(STW - optional)

Sweep (清理)

清理阶段用于回收标记阶段中标记出来的可回收内存。当应用程序goroutine尝试在堆内存中分配新内存时,会触发该操作,清理导致的延迟和吞吐量降低被分散到每次内存分配时。

阶段 说明 赋值器状态
SweepTermination 清扫终止阶段,为下一阶段的并发标记做准备工作,启动写屏障 STW
Mark 扫描标记阶段,与赋值器并发执行,写屏障开启 并发
MarkTermination 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 STW
GCoff 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 并发
GCoff 内存归还阶段,将需要回收的内存归还给操作系统,写屏障关闭 并发

清除阶段出现新对象:

清除阶段是扫描整个堆内存,可以知道当前清除到什么位置,创建的新对象判定下,如果新对象的指针位置已经被扫描过了,那么就不用作任何操作,不会被误清除,如果在当前扫描的位置的后面,把该对象的颜色标记为黑色,这样就不会被误清除了

触发时机

  • gcTriggerHeap: 当前分配的内存达到一定值(动态计算)就触发GC
  • gcTriggerTime: 当一定时间(2分钟)没有执行过GC就触发GC
  • gcTriggerCycle: 要求启动新一轮的GC, 已启动则跳过, 手动触发GC的runtime.GC()会使用这个条件

三色标记原理

  1. 初始状态下所有对象都是白色的。
  2. 接着开始扫描根对象; 将根对象引用的对象设为为灰色对象,接下来就开始分析灰色对象,没有引用其他对象的转入黑色,引用了其他对象的则转入黑色的同时还需要将引用的对象转为灰色,进行接下来的分析。
  3. 扫描灰色对象,直至没有引用其他对象,将灰色对象转入黑色。标记过程结束
  4. 最终,黑色的对象会被保留下来,白色对象会被回收掉。

为什么Golang没有实现分代和非紧缩

译自 Google 论坛(golang-nuts) Ian Lance Taylor 的回复:

忽略细节, 紧凑(compacting) GC 的基本优点是:

  • 避免碎片, 以及
  • 允许使用简单而有效的 bump allocator 内存分配器

但是,现代的内存分配算法, 像 Go 运行时使用的基于 tcmalloc 的方案基本上没有碎片问题。bump allocator 对于一个单线程程序是简单有效的,对于 Go 这样的多线程程序则需要锁。一般来说,可以使用一组线程预分配缓存的线程来分配内存提升效率,而在这一点上已经失去了 bump allocator 的优势。因此我断言,在一般情况下,对于多线程程序使用压缩内存分配器并没有什么真正的优势。我并不是说使用压缩分配器有什么问题,我只是说它不会比非压缩分配器带来任何大的优势。

现在我们来考虑一下分代 GC。分代 GC 的关键依赖于世代的假设:分配在一个程序中的大部分值很快变得不会用到,所以分代 GC 有一个优势就是可以花更多的时间查看最近分配的对象。这里 Go 不同于许多垃圾收集语言,因为许多对象是直接在程序栈(stack)上分配的。Go 编译器使用逃逸分析(escape analysis)来查找那些在编译时生命周期就已知的对象,将它们分配到栈(stack)而不是垃圾收集的内存中。 所以一般来说,在 Go 中,与其他语言相比,有很大比例的分代 GC 要找的很快不会用到的(quickly-unused)值不会分配在 GC 内存的首要位置。所以分代 GC 能给 Go 带来的优势相对于其他语言要小。

更微妙的是,大多数世代 GC 实现的隐含意义是减少垃圾收集带来的程序暂停的时间。暂停期间只看最年轻的一代,会让暂停时间很短。然而,Go 使用了一个并发垃圾收集器,并且在 Go 中程序暂停时间与年轻代或者任意代的大小无关。Go 基本上假设,在多线程程序中,通过在不同的核上并行运行 GC,不是为了最小化 GC 时间去暂停导致程序运行更长的时间, 而是总体上花更多的总 CPU 时间在 GC 上。

尽管如此,分代 GC 可能仍然可以为 Go 带来显著的价值,即减少并行 GC 时的工作量. 这是一个需要测试的假设. Go 当前的 GC 工作实际上正在密切关注一个相关但不同的假设:Go 程序可能倾向于按请求分配内存。这里有一个描述。这项工作正在进行中,现实情况是否有利还有待观察。

总结

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

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

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

参考