Go 并不需要 Java 风格的 GC
本文首发于 https://robberphex.com/go-does-not-need-a-java-style-gc/?utm_source=infoq&utm_medium=head
像 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 字节的内存。
然而,为了让垃圾收集器能够工作,Java 存储了一个头部信息,包含:
类型/Type — 标识对象属于的类或它的类型。
锁/Lock — 用于同步语句。
标记/Mark — 标记和清除(mark and sweep)垃圾收集器使用。
这些数据通常为 16 字节。因此,头部信息与实际数据的比例是 4:1。Java 对象的 c++源代码定义为:OpenJDK基类:
内存碎片
接下来的问题是内存碎片。当 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 对象。这仅仅分配了一次内存,产生了一个指针。在 Java 中,这需要 15000 次内存分配,每次分配产生一个引用,这些应用也要单独管理起来。每个Point
对象都会有前面提到的 16 字节头部信息开销。而不管是在 Go 语言、Julia 还是 Rust 中,你都不会看到头部信息,对象通常是没有这些头部信息的。
在 Java 中,GC 追踪和管理 15000 独立的对象。Go 只需要追踪一个对象。
值类型
在除 Java 外的其他语言,基本上都支持值类型。下面的代码定义了一个矩形,用一个 Min 和 Max 点来定义它的范围。
这就变成了一个连续的内存块。在 Java 中,这将变成一个Rect
对象,它引用了两个单独的对象,Min
和Max
对象。因此在 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++一样的事情,并定义一个像这样的结构:
这些字节将位于一个完整的内存块中。而 Java 将创建一个指向其他地方的指针。
Java 开发人员意识到他们搞砸了,开发者确实需要值类型来获得良好的性能。你可以说这种说法比较夸张,但你需要解释一下Valhalla项目。这是 Oracle 为 Java 值类型所做的努力,这样做的原因正是我在这里所谈论的。
值类型是不够的
那么 Valhalla 项目能解决 Java 的问题吗?不是的。它仅仅是将 Java 带到了与 c#同等的高度上。c#比 Java 晚几年出现,并且意识到垃圾收集器并不像大家想象的那么神奇。因此,他们增加了值类型。
然而,在内存管理灵活性方面,这并没有使 c#/Java 与 Go、C/C++等语言处于同等地位。Java 不支持真正的指针。在 Go 中,我可以这样写:
就像在 C/C++中一样,你可以在 Go 中获取对象的地址或对象的字段,并将其存储在一个指针中。然后,您可以传递这个指针,并使用它来修改所指向的字段。这意味着您可以在 Go 中创建大的值对象,并将其作为函数指针传递,来优化性能。在 c#中情况要好一些,因为它对指针的支持有限。前面的 Go 例子可以用 c#写成:
然而 c#的指针支持伴随着一些不适用于 Go 的警告:
使用指针的代码必须标记为 unsafe。这会产生安全性较低且更容易崩溃的代码。
必须是在堆栈上分配的纯值类型(所有结构字段也必须是值类型)。
在 fixed 的范围内,fixed 关键字关闭了垃圾收集。
因此,在 c#中使用值类型的正常和安全的方法是复制它们,因为这不需要定义 unsafe 或 fixed 的代码域。但对于较大的值类型,这可能会产生性能问题。Go 就没有这些问题了。您可以在 Go 中创建指向由垃圾收集器管理的对象的指针。Go 语言中,不需要像在 c#中那样,将使用指针的代码单独标记出来。
自定义二次分配器
使用正确的指针,你可以做很多值类型做不到的事情。一个例子就是创建二级分配器。Chandra Sekar S 给出了一个例子:Go中的 Arena 分配。
为什么这些有用?如果你查看一些微基准测试,比如构造二叉树的算法,通常会发现 Java 比 Go 有很大的优势。这是因为构造二叉树算法通常用于测试垃圾收集器在分配对象时的速度。Java 在这方面非常快,因为它使用了我们所说的 bump 指针。它只是增加一个指针值,而 Go 将在内存中寻找一个合适的位置来分配对象。然而,使用 Arena 分配器,你也可以在 Go 中快速构建二叉树。
这就是为什么真正的指针会有好处。你不能在一个连续的内存块中创建一个指向元素的指针,如下所示:
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 在编译函数时都进行了逃逸分析。
逃逸分析包括查看在函数内部创建的指针,并确定该指针是否逃逸出了函数范围。
在第一个示例中,values
指向一个切片,这在本质上与指向数组的指针相同。它逃逸了是因为它被返回了。这意味着必须在堆上分配values
。
然而,在第二个例子中,指向values
的指针并不会离开nonEscapingPtr
函数。因此,可以在栈上分配values
,这个动作非常快速,并且代价也很小。逃逸分析本身只分析指针是否逃逸。
Java 逃逸分析的限制
Java 也做转义分析,但在使用上有更多的限制。从 Java SE 16 Oracle 文档覆盖热点虚拟机:
对于不进行全局转义的对象,它不会将堆分配替换为堆栈分配。
然而,Java 使用了另一种称为标量替换的技巧,它避免了将对象放在栈上的需要。本质上,它分解对象,并将其基本成员放在栈上。请记住,Java 已经可以在栈上放置诸如int
和float
等基本值。然而,正如Piotr Kołaczkowski在 2021 年发现的那样,在实践中,标量替换即使在非常微不足道的情况下也不起作用。
相反,标量替换的主要的优点是避免了锁。如果你知道一个指针不会在函数之外使用,你也可以确定它不需要锁。
Go 语言逃逸分析的优势
但是,Go 使用逃逸分析来确定哪些对象可以在堆栈上分配。这大大减少了寿命短的对象的数量,这些对象本来可以从分代 GC 中受益。但是要记住,分代 GC 的全部意义在于利用最近分配的对象生存时间很短这一事实。然而,Go 语言中的大多数对象可能会活得很长,因为生存时间短的对象很可能会被逃逸分析捕获。
与 Java 不同,在 Go 语言中,逃逸分析也适用于复杂对象。Java 通常只能成功地对字节数组等简单对象进行逃逸分析。即使是内置的 ByteBuffer 也不能使用标量替换在堆栈上进行分配。
现代语言不需要压缩 GC
您可以读到许多垃圾收集器方面的专家声称,由于内存碎片,Go 比 Java 更有可能耗尽内存。这个论点是这样的:因为 Go 没有压缩垃圾收集器,内存会随着时间的推移而碎片化。当内存被分割时,你将到达一个点,将一个新对象装入内存将变得困难。
然而,由于两个原因,这个问题大大减少了:
Go 不像 Java 那样分配那么多的小对象。它可以将大型对象数组作为单个内存块分配。
现代的内存分配器,如谷歌的 TCMalloc 或英特尔的 Scalable Malloc 不会对内存进行分段。
在设计 Java 的时候,内存碎片是内存分配器的一个大问题。人们不认为这个问题可以解决。但即使回到 1998 年,在 Java 问世后不久,研究人员就开始解决这个问题。下面是Mark S. Johnstone和Paul R. Wilson的一篇论文:
这实质上加强了我们之前的结果,这些结果表明,内存碎片问题通常被误解了,好的分配器策略可以为大多数程序提供良好的内存使用。
因此,设计 Java 内存分配策略时的许多假设都不再正确。
分代 GC vs 并发 GC 的暂停
使用分代 GC 的 Java 策略旨在使垃圾收集周期更短。要知道,为了移动数据和修复指针,Java 必须停止所有操作。如果停顿太久,将会降低程序的性能和响应能力。使用分代 GC,每次检查的数据更少,从而减少了检查时间。
然而,Go 用一些替代策略解决了同样的问题:
因为不需要移动内存,也不需要固定指针,所以在 GC 运行期间要做的工作会更少。Go GC 只做一个标记和清理:它在对象图中查找应该被释放的对象。
它并发运行。因此,单独的 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
评论