本文首发于 https://robberphex.com/go-does-not-need-a-java-style-gc/。

像Go、Julia和Rust这样的古代语言不须要像Java c#所应用的那样简单的垃圾收集器。但这是为什么呢?

咱们首先要理解垃圾收集器是如何工作的,以及各种语言分配内存的形式有什么不同。首先,咱们看看为什么Java须要如此简单的垃圾收集器。

本文将涵盖许多不同的垃圾收集器话题:

  • 为什么Java依赖疾速GC?我将介绍Java语言自身中的一些设计抉择,它们会给GC带来很大压力。
  • 内存碎片及其对GC设计的影响。为什么这对Java很重要,但对Go就不那么重要。
  • 值类型以及它们如何扭转GC。
  • 分代垃圾收集器,以及Go为什么不须要它。
  • 逃逸剖析 —— Go用来缩小GC压力的一个技巧。
  • 压缩垃圾收集器 —— 这在Java中很重要,然而Go却不须要它。为什么?
  • 并发垃圾收集 —— Go通过应用多线程运行并发垃圾收集器来解决许多GC挑战。为什么用Java更难做到这一点。
  • 对Go GC的常见批评,以及为什么这种批评背地的许多假如往往是有缺点的或齐全谬误的。

为什么Java比其余语言更须要疾速的GC

基本上,Java将内存治理齐全外包给它的垃圾收集器。事实证明,这是一个微小的谬误。然而,为了可能解释这一点,我须要介绍更多的细节。

让咱们从头说起。当初是1991年,Java的工作曾经开始。垃圾收集器当初很风行。相干的钻研看起来很有前途,Java的设计者们把赌注押在高级垃圾收集器上,它可能解决内存治理中的所有挑战。

因为这个起因,Java中的所有对象——除了整数和浮点值等根本类型——都被设计为在堆上调配。在探讨内存调配时,咱们通常会辨别所谓的堆和栈。

栈应用起来十分快,但空间无限,只能用于那些在函数调用的生命周期之内的对象。栈只实用于局部变量。

堆可用于所有对象。Java基本上疏忽了栈,抉择在堆上调配所有货色,除了整数和浮点等根本类型。无论何时,在Java中写下 new Something()耗费的都是堆上的内存。

然而,就内存应用而言,这种内存治理实际上相当低廉。你可能认为创立一个32位整数的对象只须要4字节的内存。

class Knight {   int health;}

然而,为了让垃圾收集器可能工作,Java存储了一个头部信息,蕴含:

  • 类型/Type — 标识对象属于的类或它的类型。
  • 锁/Lock — 用于同步语句。
  • 标记/Mark — 标记和革除(mark and sweep)垃圾收集器应用。

这些数据通常为16字节。因而,头部信息与理论数据的比例是4:1。Java对象的c++源代码定义为:OpenJDK基类:

class oopDesc {    volatile markOop  _mark;   // for mark and sweep    Klass*           _klass;   // the type}

内存碎片

接下来的问题是内存碎片。当Java调配一个对象数组时,它实际上是创立一个援用数组,这些援用指向内存中的其余对象。这些对象最终可能扩散在堆内存中。这对性能十分不利,因为古代微处理器不读取单个字节的数据。因为开始传输内存数据是比较慢的,每次CPU尝试拜访一个内存地址时,CPU会读取一块间断的内存。

这块间断的内存块被称为cache line 。CPU有本人的缓存,它的大小比内存小得多。CPU缓存用于存储最近拜访的对象,因为这些对象很可能再次被拜访。如果内存是碎片化的,这意味着cache line也会被碎片化,CPU缓存将被大量无用的数据填满。CPU缓存的命中率就会升高。

Java如何克服内存碎片

为了解决这些次要的毛病,Java维护者在高级垃圾收集器上投入了大量的资源。他们提出了压缩(compact)的概念,也就是说,把对象挪动到内存中相邻的块中。这个操作十分低廉,将内存数据从一个地位挪动到另一个地位会耗费CPU周期,更新指向这些对象的援用也会耗费CPU周期。

这些援用被应用的时候,垃圾收集器没法更新它们。所以更新这些援用须要暂停所有的线程。这通常会导致Java程序在挪动对象、更新援用和回收未应用内存的过程中呈现数百毫秒的齐全暂停。

减少复杂性

为了缩小这些长时间的暂停,Java应用了所谓的分代垃圾收集器(generational garbage collector)。这些都是基于以下前提:

在程序中调配的大多数对象很快就会被开释。因而,如果GC花更多工夫来解决最近调配的对象,那么应该会缩小GC的压力。

这就是为什么Java将它们调配的对象分成两组:

  • 老年对象——在GC的屡次标记和革除操作中幸存下来的对象。每次标记和扫描操作时,会更新一个分代计数器,以跟踪对象的“年龄”。
  • 年老对象——这些对象的“年龄”较小,也就是说他们是最近才调配进去的。

Java更踊跃地解决、扫描最近调配的对象,并查看它们是否应该被回收或挪动。随着对象“年龄”的增长,它们会被移出年老代区域。

所有这些优化会带来更多的复杂度,它须要更多的开发工作量。它须要领取更多的钱来雇佣更优良的开发者。

古代语言如何防止与Java雷同的缺点

古代语言不须要像Java和c#那样简单的垃圾收集器。这是在设计这些语言时,并没有像Java一样依赖垃圾回收器。

type Point struct {    X, Y int}var points [15000]Point

在下面的Go代码示例中,咱们调配了15000个Point对象。这仅仅调配了一次内存,产生了一个指针。在Java中,这须要15000次内存调配,每次调配产生一个援用,这些利用也要独自治理起来。每个Point对象都会有后面提到的16字节头部信息开销。而不论是在Go语言、Julia还是Rust中,你都不会看到头部信息,对象通常是没有这些头部信息的。

在Java中,GC追踪和治理15000独立的对象。Go只须要追踪一个对象。

值类型

在除Java外的其余语言,基本上都反对值类型。上面的代码定义了一个矩形,用一个Min和Max点来定义它的范畴。

type Rect struct {   Min, Max Point}

这就变成了一个间断的内存块。在Java中,这将变成一个Rect对象,它援用了两个独自的对象,MinMax对象。因而在Java中,一个Rect实例须要3次内存调配,但在Go、Rust、C/c++和Julia中只须要1次内存调配。

在将Git移植到Java时,短少值类型造成了重大的问题。如果没有值类型,就很难取得良好的性能。正如Shawn O. Pearce在JGit开发者邮件列表上所说:

JGit始终纠结于没有一种无效的形式来示意SHA-1。C只须要输出unsigned char[20]并将其内联到容器的内存调配中。Java中的byte[20]将额定耗费16个字节的内存,而且访问速度较慢,因为这10个字节和容器对象位于不相邻的内存区域。咱们试图通过将一个byte[20]转换为5个int来解决这个问题,但这须要消耗额定的CPU指令。

咱们在说什么?在Go语言中,我能够做和C/C++一样的事件,并定义一个像这样的构造:

type Sha1 struct {   data [20]byte}

这些字节将位于一个残缺的内存块中。而Java将创立一个指向其余中央的指针。

Java开发人员意识到他们搞砸了,开发者的确须要值类型来取得良好的性能。你能够说这种说法比拟夸大,但你须要解释一下Valhalla我的项目)。这是Oracle为Java值类型所做的致力,这样做的起因正是我在这里所议论的。

值类型是不够的

那么Valhalla我的项目能解决Java的问题吗?不是的。它仅仅是将Java带到了与c#等同的高度上。c#比Java晚几年呈现,并且意识到垃圾收集器并不像大家设想的那么神奇。因而,他们减少了值类型。

然而,在内存治理灵活性方面,这并没有使c#/Java与Go、C/C++等语言处于等同位置。Java不反对真正的指针。在Go中,我能够这样写:

var ptr *Point = &rect.Min // 把指向 Min 的指针存储到 ptr 中*ptr = Point(2, 4)         // 替换 rect.Min 对象

就像在C/C++中一样,你能够在Go中获取对象的地址或对象的字段,并将其存储在一个指针中。而后,您能够传递这个指针,并应用它来批改所指向的字段。这意味着您能够在Go中创立大的值对象,并将其作为函数指针传递,来优化性能。在c#中状况要好一些,因为它对指针的反对无限。后面的Go例子能够用c#写成:

unsafe void foo() {   ref var ptr = ref rect.Min;   ptr = new Point(2, 4);}

然而c#的指针反对随同着一些不适用于Go的正告:

  • 应用指针的代码必须标记为unsafe。这会产生安全性较低且更容易解体的代码。
  • 必须是在堆栈上调配的纯值类型(所有构造字段也必须是值类型)。
  • fixed的范畴内,fixed关键字敞开了垃圾收集。

因而,在c#中应用值类型的失常和平安的办法是复制它们,因为这不须要定义unsafe或fixed的代码域。但对于较大的值类型,这可能会产生性能问题。Go就没有这些问题了。您能够在Go中创立指向由垃圾收集器治理的对象的指针。Go语言中,不须要像在c#中那样,将应用指针的代码独自标记进去。

自定义二次分配器

应用正确的指针,你能够做很多值类型做不到的事件。一个例子就是创立二级分配器。Chandra Sekar S给出了一个例子:Go中的 Arena 调配。

type Arena []Nodefunc (arena *Arena) Alloc() *Node {    if len(*arena) == 0 {        *arena = make([]Node, 10000)    }    n := &(*arena)[len(*arena)-1]    *arena = (*arena)[:len(*arena)-1]    return n}

为什么这些有用?如果你查看一些微基准测试,比方结构二叉树的算法,通常会发现Java比Go有很大的劣势。这是因为结构二叉树算法通常用于测试垃圾收集器在调配对象时的速度。Java在这方面十分快,因为它应用了咱们所说的bump指针。它只是减少一个指针值,而Go将在内存中寻找一个适合的地位来调配对象。然而,应用Arena分配器,你也能够在Go中疾速构建二叉树。

func buildTree(item, depth int, arena *Arena) *Node {    n := arena.Alloc()    if depth <= 0 {        *n = Node{item, nil, nil}    } else {        *n = Node{              item,              buildTree(2*item-1, depth-1, arena),              buildTree(2*item, depth-1, arena),        }    }    return n}

这就是为什么真正的指针会有益处。你不能在一个间断的内存块中创立一个指向元素的指针,如下所示:

n := &(*arena)[len(*arena)-1]

Java Bump分配器的问题

Java GC应用的bump分配器与Arena分配器相似,您只需挪动一个指针就能获取下一个值。但开发者不须要手动指定应用Bump分配器。这可能看起来更智能。但它会导致一些在Go语言中没有的问题:

  • 或早或晚,内存都须要进行压缩(compact),这波及到挪动数据和修复指针。Arena分配器不须要这样做。
  • 在多线程程序中,bump分配器须要锁(除非你应用线程本地存储)。这抹杀了它们的性能劣势,要么是因为锁升高了性能,要么是因为线程本地存储将导致碎片化,这须要稍后进行压缩。

Ian Lance Taylor是Go的创建者之一,他解释了bump分配器的问题:

一般来说,应用一组每个线程缓存来分配内存可能会更有效率,而在这一点上,你曾经失去了bump分配器的劣势。因而,我要断言,通常状况下,只管有许多正告,但对多线程程序应用压缩内存分配器并没有真正的劣势。

分代GC和逃逸剖析

Java垃圾收集器有更多的工作要做,因为它调配了更多的对象。为什么?咱们刚刚讲过了。如果没有值对象和真正的指针,在调配大型数组或简单的数据结构时,它将总是以大量的对象告终。因而,它须要分代GC。

调配更少对象的需要对Go语言无利。但Go语言还有另一个技巧。Go和Java在编译函数时都进行了逃逸剖析。

逃逸剖析包含查看在函数外部创立的指针,并确定该指针是否逃逸出了函数范畴。

func escapingPtr() []int {   values := []int{4, 5, 10}   return values}fun nonEscapingPtr() int {    values = []int{4, 5, 10}    var total int = addUp(values)    return total}

在第一个示例中,values指向一个切片,这在实质上与指向数组的指针雷同。它逃逸了是因为它被返回了。这意味着必须在堆上调配values

然而,在第二个例子中,指向values的指针并不会来到nonEscapingPtr函数。因而,能够在栈上调配values,这个动作十分疾速,并且代价也很小。逃逸剖析自身只剖析指针是否逃逸。

Java逃逸剖析的限度

Java也做本义剖析,但在应用上有更多的限度。从Java SE 16 Oracle文档笼罩热点虚拟机:

对于不进行全局本义的对象,它不会将堆调配替换为堆栈调配。

然而,Java应用了另一种称为标量替换的技巧,它防止了将对象放在栈上的须要。实质上,它合成对象,并将其根本成员放在栈上。请记住,Java曾经能够在栈上搁置诸如intfloat等根本值。然而,正如Piotr Kołaczkowski在2021年发现的那样,在实践中,标量替换即便在十分微不足道的状况下也不起作用。

相同,标量替换的次要的长处是防止了锁。如果你晓得一个指针不会在函数之外应用,你也能够确定它不须要锁。

Go语言逃逸剖析的劣势

然而,Go应用逃逸剖析来确定哪些对象能够在堆栈上调配。这大大减少了寿命短的对象的数量,这些对象原本能够从分代GC中受害。然而要记住,分代GC的全副意义在于利用最近调配的对象生存工夫很短这一事实。然而,Go语言中的大多数对象可能会活得很长,因为生存工夫短的对象很可能会被逃逸剖析捕捉。

与Java不同,在Go语言中,逃逸剖析也实用于简单对象。Java通常只能胜利地对字节数组等简略对象进行逃逸剖析。即便是内置的ByteBuffer也不能应用标量替换在堆栈上进行调配。

古代语言不须要压缩GC

您能够读到许多垃圾收集器方面的专家宣称,因为内存碎片,Go比Java更有可能耗尽内存。这个论点是这样的:因为Go没有压缩垃圾收集器,内存会随着工夫的推移而碎片化。当内存被宰割时,你将达到一个点,将一个新对象装入内存将变得艰难。

然而,因为两个起因,这个问题大大减少了:

  1. Go不像Java那样调配那么多的小对象。它能够将大型对象数组作为单个内存块调配。
  2. 古代的内存分配器,如谷歌的 TCMalloc 或英特尔的 Scalable Malloc 不会对内存进行分段。

在设计Java的时候,内存碎片是内存分配器的一个大问题。人们不认为这个问题能够解决。但即便回到1998年,在Java问世后不久,钻研人员就开始解决这个问题。上面是Mark S. Johnstone和Paul R. Wilson的一篇论文:

这本质上增强了咱们之前的后果,这些结果表明,内存碎片问题通常被误会了,好的分配器策略能够为大多数程序提供良好的内存应用。

因而,设计Java内存调配策略时的许多假如都不再正确。

分代GC vs 并发GC的暂停

应用分代GC的Java策略旨在使垃圾收集周期更短。要晓得,为了挪动数据和修复指针,Java必须进行所有操作。如果进展太久,将会升高程序的性能和响应能力。应用分代GC,每次查看的数据更少,从而缩小了查看工夫。

然而,Go用一些代替策略解决了同样的问题:

  1. 因为不须要挪动内存,也不须要固定指针,所以在GC运行期间要做的工作会更少。Go GC只做一个标记和清理:它在对象图中查找应该被开释的对象。
  2. 它并发运行。因而,独自的GC线程能够在不进行其余线程的状况下寻找要开释的对象。

为什么Go能够并发运行GC而Java却不行?因为Go不会修复任何指针或挪动内存中的任何对象。因而,不存在尝试拜访一个对象的指针,而这个对象刚刚被挪动,但指针还没有更新这种危险。不再有任何援用的对象不会因为某个并发线程的运行而忽然取得援用。因而,平行挪动“曾经死亡”的对象没有任何危险。

这是怎么回事?假如你有4个线程在一个Go程序中工作。其中一个线程在任意工夫T秒内执行长期GC工作,工夫总计为4秒。

当初设想一下,一个Java程序的GC只做了2秒的GC工作。哪个程序挤出了最多的性能?谁在T秒内实现最多?听起来像Java程序,对吧?错了!

Java程序中的4个工作线程将进行所有线程2秒。这意味着 2×4 = 8秒的工作在T秒中失落。因而,尽管Go的进行工夫更长,但每次进行对程序工作的影响更小,因为所有线程都没有进行。因而,迟缓的并发GC的性能可能优于依赖于进行所有线程来执行其工作的较快GC。

如果垃圾产生的速度比清理它的速度还快怎么办?

拥护以后垃圾收集器的一个风行观点是,流动工作线程产生垃圾的速度可能比垃圾收集器线程收集垃圾的速度快。在Java世界中,这被称为“并发模式失败”。

在这种状况下,运行时别无选择,只能齐全进行程序并期待GC周期实现。因而,当Go宣称GC暂停工夫非常低时,这种说法只实用于GC有足够的CPU工夫和空间超过主程序的状况。

然而Go语言有一个聪慧的技巧来绕过Go GC巨匠Rick Hudson所形容的这个问题。Go应用的是所谓的“Pacer”。

如果需要的话,Pacer会在减速标记的同时升高调配速度。在一个较高的程度,Pacer进行了Goroutine,它做了大量的调配,并让它做标记。工作量与Goroutine的调配成比例。这放慢了垃圾收集器的速度,同时减慢了mutator的速度。

Goroutines有点像在线程池上复用的绿色线程。基本上,Go接管正在运行产生大量垃圾的工作负载的线程,并让它们帮忙GC清理这些垃圾。它会始终接管线程,直到GC的运行速度超过产生垃圾的协程。

简而言之

尽管高级垃圾收集器解决了Java中的理论问题,但古代语言,如Go和Julia,从一开始就防止了这些问题,因而不须要应用Rolls Royce垃圾收集器。当您有了值类型、本义剖析、指针、多核处理器和古代分配器时,Java设计背地的许多假如都被抛到了脑后。它们不再实用。

GC的Tradeoff不再实用

Mike Hearn在Medium上有一个十分受欢迎的故事,他批评了Go GC的说法:古代垃圾收集。

Hearn的要害信息是GC设计中总是存在衡量。他的观点是,因为Go的指标是低提早收集,他们将在许多其余指标上受到影响。这是一本乏味的读物,因为它涵盖了很多对于GC设计中的衡量的细节。

首先,低提早是什么意思?Go GC均匀只暂停0.5毫秒,而各种Java收集器可能要花费数百毫秒。

我认为Mike Hearn的论点的问题在于,它们基于一个有缺点的前提,即所有语言的内存拜访模式都是雷同的。正如我在本文中所提到的,基本不是这样的。Go生成的须要GC治理的对象会少得多,并且它会应用逃逸剖析提前清理掉很多对象。

老技术自身就是坏的?

Hearn的论点申明,简略的收集在某种程度上是不好的:

Stop-the-world (STW)标记/革除是本科生计算机科学课程中最罕用的GC算法。在做工作面试时,我有时会让应聘者议论一些对于GC的内容,但简直总是,他们要么将GC视为一个黑盒子,对它无所不知,要么认为它至今仍在应用这种十分古老的技术。

是的,它可能是旧的,然而这种技术容许并发地运行GC,这是“古代”的技术不容许的。在咱们领有多核的古代硬件世界中,这一点更重要。

Go 不是 C

另一个说法:

因为Go是一种具备值类型的绝对一般的命令式语言,它的内存拜访模式可能能够与C#相比拟,后者的分代假如当然成立,因而.NET应用分代收集器。

事实并非如此。C#开发人员会尽量减少大值对象的应用,因为不能平安地应用与指针相干的代码。咱们必须假如c#开发人员更喜爱复制值类型而不是应用指针,因为这能够在CLR中平安地实现。这天然会带来更高的开销。

据我所知,C#也没有利用逃逸剖析来缩小堆上的短生命周期对象的产生。其次,C#并不善于同时运行大量工作。Go能够利用它们的协程来同时减速收集,就像Pacer提到的那样。

内存压缩整顿

压缩:因为没有压缩,你的程序最终会把堆碎片化。我将在上面进一步探讨堆碎片。在缓存中参差地搁置货色也不会给您带来益处。

在这里,Mike Hearn对分配器的形容并不是最新的。TCMalloc等古代分配器基本上打消了这个问题。

程序吞吐量:因为GC必须为每个周期做大量工作,这从程序自身窃取CPU工夫,升高了它的速度。

当您有一个并发GC时,这并不实用。所有其余线程都能够在GC工作时持续运行——不像Java,它必须进行整个世界。

堆的开销

Hearn提出了“并发模式失败”的问题,假如Go GC会有跟不上垃圾生成器的速度的危险。

堆开销:因为通过标记/革除收集堆是十分慢的,你须要大量的闲暇空间来确保你不会遭逢“并发模式失败”。默认的堆开销是100%,它会使你的程序须要的内存翻倍。

我对这种说法持狐疑态度,因为我看到的许多事实世界的例子仿佛都倡议围棋程序应用更少的内存。更不用说,这疏忽了Pacer的存在,它会抓住Goroutines,产生大量垃圾,让他们清理。

为什么低提早对Java也很重要

咱们生存在一个Docker和微服务的世界。这意味着许多较小的程序互相通信和工作。设想一个申请要通过好几个服务。在一个链条,这些服务中如果有一个呈现重大进展,就会产生连锁反应。它会导致所有其余过程进行工作。如果管道中的下一个服务正在期待STW的垃圾收集,那么它将无奈工作。

因而,提早/吞吐量的衡量不再是GC设计中的衡量。当多个服务一起工作时,高提早将导致吞吐量降落。Java对高吞吐量和高提早GC的偏好实用于单块世界。它不再实用于微服务世界。

这是Mike Hearn观点的一个基本问题,他认为没有灵丹妙药,只有衡量取舍。它试图给人这样一种印象:Java的衡量是同样无效的。但衡量必须依据咱们所生存的世界进行调整。

简而言之,我认为Go语言曾经做出了许多聪慧的行动和策略抉择。如果这只是任何人都能够做的trade-off,那么省去它是不可取的。