Golang GC 原理

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

Golang GC 原理

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

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

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

触发时机

  • 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 程序可能倾向于按请求分配内存。这里有一个描述。这项工作正在进行中,现实情况是否有利还有待观察。