Go并不需要Java风格的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字节的内存。

1
2
3
class Knight {
int health;
}

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

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

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

1
2
3
4
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一样依赖垃圾回收器。

Go语言:创建一个包含15000个Point对象的数组
1
2
3
4
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点来定义它的范围。

1
2
3
type Rect struct {
Min, Max Point
}

这就变成了一个连续的内存块。在Java中,这将变成一个Rect对象,它引用了两个单独的对象,MinMax对象。因此在Java中,一个Rect实例需要3次内存分配,但在Go、Rust、C/c++和Julia中只需要1次内存分配。

左边是Java风格的内存碎片。在Go, C/C++, Julia等程序中,在右边的连续内存块上。

在将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++一样的事情,并定义一个像这样的结构:

1
2
3
type Sha1 struct {
data [20]byte
}

这些字节将位于一个完整的内存块中。而Java将创建一个指向其他地方的指针。

Java开发人员意识到他们搞砸了,开发者确实需要值类型来获得良好的性能。你可以说这种说法比较夸张,但你需要解释一下Valhalla项目。这是Oracle为Java值类型所做的努力,这样做的原因正是我在这里所谈论的。

值类型是不够的

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

然而,在内存管理灵活性方面,这并没有使c#/Java与Go、C/C++等语言处于同等地位。Java不支持真正的指针。在Go中,我可以这样写:

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

就像在C/C++中一样,你可以在Go中获取对象的地址或对象的字段,并将其存储在一个指针中。然后,您可以传递这个指针,并使用它来修改所指向的字段。这意味着您可以在Go中创建大的值对象,并将其作为函数指针传递,来优化性能。在c#中情况要好一些,因为它对指针的支持有限。前面的Go例子可以用c#写成:

C#指针用法
1
2
3
4
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 分配

1
2
3
4
5
6
7
8
9
10
11
type Arena []Node

func (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中快速构建二叉树。

1
2
3
4
5
6
7
8
9
10
11
12
13
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在编译函数时都进行了逃逸分析。

逃逸分析包括查看在函数内部创建的指针,并确定该指针是否逃逸出了函数范围。

1
2
3
4
5
6
7
8
9
10
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,那么省去它是不可取的。


本文翻译自 https://itnext.io/go-does-not-need-a-java-style-gc-ac99b8d26c60

作者

Robert Lu

发布于

2021-11-30

许可协议

评论