大家好,我是 Okada(@ocadaruma),LINE 广告平台团队的成员。我对 Go 的 GC (垃圾收集器)有点感兴趣,甚至还促使我专门写一篇关于它的博客。Go 是一门由 Google 开发,并且支持垃圾收集的编程语言。Go 通过管道支持并发。很多的公司,包括 Google,都在使用 Go,LINE 也用 Go 来开发工具和服务。
Go GC
用 Go,你可以很容易地创建出低延时的应用。Go GC 似乎比其他语言的运行时要简单得多。对于 Go 1.10 版本,它的垃圾收集器是 Concurrent Mask & Sweep (CMS) ,它不是压缩的,也不是分代的。这一点跟 JVM 不同。
- 它是一个,并行标记,用一个写屏障(写的时候阻塞)的清理(程序)。它是非分代,非压缩的。 – mgc.go
下面是 Java GC 和 Go GC 的对比。相比于 Java ,Go GC 对于我来说看起来有点简单,所以我决定深入进去,看下 Go GC 是怎么工作的。
Java(Java8 HotSpot VM) | Go | |
---|---|---|
Collector | Several collectors (Serial, Parallel, CMS, G1) | CMS |
Compaction | Compacts | Does not compact |
Generational GC | Generational GC | Non-generational GC |
Tuning parameters | Depends on the collector. Multiple parameters available. | GOGC only |
压缩(Compaction)
垃圾收集可以选择不迁移或者迁移(堆上的对象)。
不迁移的 GC(Non-moving GC)
不迁移的垃圾收集不会在堆中给对象重新分配内存。CMS,Go 使用的收集器,就是非迁移的。一般来说,如果你在非迁移的垃圾收集器中,重复地进行内存分配跟释放,最终将导致堆碎片,从而降低分配(堆内存)的性能。但,当然,这也取决于你的内存分配器如何实现。
迁移的 GC(moving GC)
移动垃圾收集器将活动对象移动到堆的末尾来压缩堆。移动垃圾收集器的一个实例是拷贝 GC(Copying GC),它在 HotSpot VM 中使用。
压缩具有如下优点:
- 避免碎片
- 依靠于压缩的分配,可以实现一个高性能的内存分配器(因为所有对象都位于堆的末尾,所以我们可以在右边,最后的位置,增加新的内存。)
为什么 Go 不选择压缩的方式(Why Go did not opt for compaction)
来自 Google 的 Rick Hudson,在国际内存管理研讨会(ISMM)上,在他的 keynote 中分享到,Getting To Go。
- 在 2014 年,他们最初计划做一个任意读的并行拷贝 GC。
- 由于没有时间 - 彼时,他们正在将用 C 写的运行时改成用 Go 实现(对运行时的修改) - 他们决定选择 CMS。
- 他们采用了基于 TCMalloc 的内存分配器,解决碎片和优化(内存)分配的问题。
了解更多 Go 内存分配的内容,请看运行时的评论。
分代的 GC(Generational GC)
分代 GC 的目的是通过将堆中对象除以它的年龄(他们从 GC 中存活的次数)来优化 GC,从而产生分代。分代假说指出,在许多应用中,新事物大多年轻。基于该假设,通过以下策略来 (优化)GC,也就是说,我们可以取消多次对旧对象的扫描。
- 从年轻的空间(Minor GC)中更频繁地收集垃圾。
- 可以将它们,在空间中已经存活了几个 GC 周期的旧对象,重新放置在不经常收集垃圾的空间(Major GC)中
Java8 HotSpot Vm 的所有收集器都实现了分代 GC。
写入屏障(Write barrier)
分代 GC 的缺点是,即使垃圾收集没有运行,对于应用程序也有开销。我们来看一个 Minor GC 的例子。
如果我们仅检查 root 用于指向 Minor 的指针,然后收集无法访问的对象,那么旧对象中引用新对象(如图中的 obj2)会被意外地收集。但是,如果我们检查整个堆,包括旧对象以避免收集 Minor 对象(时产生的问题),那么对于分代 GC 来说就没有意义。因此,添加一个进程,以便在替换或重写引用时将旧对象的引用记录保存到新对象中。我们将此额外流程称为写入屏障。使用分代 GC 可能有更多好处,可以弥补这个缺点(写入屏障开销)。
为什么不使用分代 GC?(Why not generational GC?)
正如我们之前看到的,分代垃圾收集器需要一个写屏障来记录代之间的指针。回到 Rick Hudson 的主题演讲,Getting To Go,我们可以看到他们确实考虑过分代 GC,但由于写屏障开销而放弃了它。
1 | 写屏障速度很快,但简单来说,它还不够快。 |
使用 Go,编译器的逃逸分析非常出色,如果需要,程序员可以控制到,不在堆上分配对象,因此短期对象通常分配在栈中而不是在堆中;这意味着不需要 GC。总的来说,你从分代 GC 得到的(好处)比其他(语言)运行时少。有一些用 Go 语言编写的库,跟速度一样出名的是,这些库恰好也是零内存分配。尽管如此,我们仍然有消耗,在每次 GC 循环中多次扫描长寿命的对象。来自 Google 的 Ian Lance Taylor 已经在 golang-nuts 中提到了这一点,为什么垃圾收集器不实现分代 GC 功能?
- 这是个很好的问题。Go 当前的 GC 显然做了一些额外的工作,但它也跟其他的工作并行执行,所以在具有备用 CPU 的系统上,Go 正在作出合理的选择。请看 https://golang.org/issue/17969
结束语(Closing notes)
通过研究 Go 垃圾收集器,我能够理解 Go GC 当前结构的背景以及它如何克服它的弱点。Go 发展得非常快。如果你对 Go 感兴趣,最好继续留意它(当我写这篇文章时,2018 年 8 月,Go 发布了它的 1.11 版本)。
via: https://engineering.linecorp.com/en/blog/detail/342
作者:Haruki Okada
译者:gogeof
校对:polaris1119