Golang为什么不用Java的gc模式

# Golang为什么不用Java的gc模式

为什么Go、Julia 和 Rust 等现代语言不需要像 Java C# 那样复杂的垃圾收集器?

为了解释原因,我们需要了解垃圾收集器是如何工作的,以及不同的语言如何以不同的方式分配内存。我们首先了解为什么 Java 特别需要如此复杂的垃圾收集器。

以下面几个主题为出发点来做相关介绍:

  • 为什么 Java 如此依赖快速 GC。介绍 Java 语言本身中对 GC 造成很大压力的一些设计选择。
  • 内存碎片以及它如何影响 GC 设计。为什么这对 Java 很重要,而对 Go 却没有那么重要。
  • 值类型以及它们如何改变 GC 。
  • 分代GC以及为什么 Go 不需要。
  • 逃逸分析——Go 如何用来减少 GC 压力的技巧。
  • 分代 垃圾收集器——在 Java 世界中很重要,但 Go 以某种方式避免了对它的需求。为什么?
  • Concurrent Garbage Collection — Go 如何通过使用多个线程运行并发垃圾收集器来解决许多 GC 挑战。为什么使用 Java 更难做到这一点。
  • 对 Go GC 的常见批评以及为什么批评背后的许多假设通常是有缺陷或完全错误的。
  • 为什么低延迟对 Java 也很重要

为什么 Java 比其他人更需要快速 GC

背景:Java 设计工作开始时。垃圾收集器风靡一时。研究看起来很有希望,Java 的设计者将赌注押在高级垃圾收集器上,这些垃圾收集器能够从根本上解决管理内存方面的所有挑战。

出于这个原因,Java 中的所有对象都设计为在堆上分配,但整数和浮点值等原始类型除外。在谈到内存分配时,我们一般会区分所谓的堆和栈。堆栈使用起来非常快,但空间有限,只能用于在函数调用的生命周期之后不需要存在的对象。它仅适用于局部变量。堆可用于所有对象。Java 基本上忽略了堆栈并选择在堆上分配所有内容,除了整数和浮点数等原语。每当您new Something()使用 Java 编写代码时,都会消耗堆上的内存。

然而,这种类型的内存管理在内存使用方面实际上是相当昂贵的。你会认为创建一个只有 32 位整数的对象只需要 4 个字节的内存。

但是,为了让垃圾收集器工作,Java 会存储一个标头,其中包含以下信息:

  • 类型 — 标识对象的类别或类型。
  • Lock — 用于同步语句。
  • 标记 - 在垃圾收集器的标记和扫描面期间使用。

该数据通常为 16 个字节。因此,标题数据与实际数据的比率为 4:1。Java 对象的 C++ 源代码定义为:OpenJDK Base Class

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

内存碎片

当 Java 分配一个对象数组时,它真正做的是创建一个引用数组,指向内存中某个其他位置的对象。这些对象最终可能分散在堆内存周围。这对性能不利,因为现代微处理器不读取单个数据字节。因为启动内存传输很慢,微处理器每次尝试访问一个特定的内存位置时总是读取一个大的连续内存块。

这块内存称为高速缓存行。CPU 有自己的高速内存,称为高速缓存。这比主存储器小得多。它用于存储最近访问的对象,因为这些对象很可能会再次被访问。如果主内存是碎片化的,这意味着高速缓存行将被碎片化,CPU 高速缓存将被大量无用数据填满。

Java如何克服内存碎片

为了解决这些主要缺点,Java 维护人员在高级垃圾收集器上投入了大量资金。这些做一些称为压缩的事情。压缩涉及在内存中移动对象并将它们收集到内存中的连续块中。这并不便宜。不仅将块从一个内存位置移动到另一个内存位置会消耗 CPU 周期,而且更新对这些对象的每个引用以指向新位置也会消耗 CPU 周期。

进行这些更新需要冻结所有线程。您不能在使用它们时更新参考。这通常会导致 Java 程序完全冻结数百毫秒,其中对象移动、引用更新和未使用的内存回收。

增加复杂性

为了减少这些长时间的停顿,Java 使用了所谓的分代垃圾收集器. 这些都是基于以下前提:

程序中分配的大多数值很快就会被使用,因此 GC 可以花更多时间查看最近分配的对象。

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

  • 旧对象——在 GC 的多次标记和清除操作中幸存下来的对象。每次标记和扫描都会更新生成计数器,以跟踪对象的年龄。
  • 年轻对象——这些对象的生成计数器较低。这意味着它们最近才被分配。

Java 更积极地调查最近分配的对象并检查它们是否应该被回收或移动。随着对象年龄的增长,它们会被移出年轻代区域。

所有这些自然会产生更多的复杂性。它需要更多的发展。

现代语言如何避免与 Java 相同的陷阱

现代语言不需要像 Java 和 C# 这样的复杂垃圾收集器。这是因为它们没有被设计成同样程度地依赖它们。

1
2
3
4
5
// Go: Make an an array of 15 000 Point objects in
type Point struct {
X, Y int
}
var points [15000]Point

在上面的 Go 代码示例中,我们分配了 15000 个Point对象。这只是一个单一的分配,产生一个单一的指针。在 Java 中,这需要 15 000 个单独的分配,每个分配都产生一个必须管理的单独引用。每个Point对象都有我之前写过的 16 字节头开销。在 Go、Julia 或 Rust 中,你都不会得到这个开销。这些对象通常是无标题的。

在 Java 中,GC 获得它必须跟踪和管理的 15000 个单独的对象。Go 只有 1 个要跟踪的对象。

值类型

下面的代码定义了一个矩形,其中一个MinMax点定义了它的范围。

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

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

image-20220912125744670

左边是 Java 风格的内存碎片。在 Go、C/C++、Julia 等中可能存在正确的连续内存块。

在将 Git 移植到 Java 时,缺少值类型会产生重大问题。没有值类型,很难获得良好的性能。正如 Shawn O. Pearce在 JGit 开发者邮件列表中所说

JGit 苦于没有一种有效的方式来表示 SHA-1。C 可以说unsigned char[20]并将其内联到容器的内存分配中。byte[20]Java 中的A将花费额外的16 字节内存,并且访问速度较慢,因为字节本身与容器对象位于不同的内存区域。我们尝试通过从 a 转换为 5 个整数来解决它byte[20],但这会花费我们的机器指令。

我们在那里谈论什么?在 Go 中,我可以做与 C/C++ 相同的事情并定义如下结构:

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

然后这些字节将成为一个内存块的一部分。Java 将创建一个指向内存中其他位置的指针。

Java 开发人员意识到他们搞砸了,并且您确实需要值类型才能获得良好的性能。您可以称该陈述为夸张,但随后您需要解释Project Valhalla。这是 Oracle 为提供 Java 值类型而带头的一项努力,他们阐明这样做的原因正是我在这里所说的。

值类型还不够

那么Project Valhalla会解决Java 的问题吗?并不真地。它只会使 Java 与 C# 处于同等地位。C# 在 Java 之后几年问世,并从那时起意识到垃圾收集器并不是每个人都认为的那样神奇。因此,他们添加了值类型。

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

1
2
3
// Go 指针用法var 
ptr *Point = &rect.Min // 将指向 Min 的指针存储在 ptr
*ptr = Point(2, 4) // 替换 rect.Min

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

1
2
3
4
// C# 指针用法不安全的 void foo() { 
Rect* ptr = &rect.Min;
*ptr = new Point(2, 4);
}

然而,C# 指针支持带有一些不适用于 Go 的警告:

  1. 使用点的代码必须标记为unsafe。这会创建安全性较低且更容易崩溃的代码。
  2. 在堆栈上分配的纯值类型(所有结构字段必须是值类型)。
  3. 在已关闭垃圾收集的固定范围内,使用 fixed 关键字。

因此,在 C# 中使用值类型的正常且安全的方法是复制它们,因为这不需要定义不安全或固定的代码区域。但是对于较大的值类型,这可能会产生性能问题。Go 没有这些问题。您可以在 Go 中创建指向垃圾收集器管理的对象的指针。您不需要像在 C# 中那样在 Go 中使用指针来隔离代码。

自定义辅助分配器

使用正确的指针,您可以做很多只有值类型时无法做到的事情。一个示例是创建辅助分配器。是使用 Go 泛型创建的 Arena 分配器的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Arena[T any] struct {
blocks Stack[*T]
}
func (arena *Arena[T]) Alloc() *T {
if arena.blocks.IsEmpty() {
var blocks [32]T // allocate 32 elements at a time
for i, _ := range blocks {
arena.blocks.Push(&blocks[i])
}
}
b, _ := arena.blocks.Top()
arena.blocks.Pop()
return b
}

为什么这些有用?如果您查看生成二叉树的算法的微基准测试,您通常会发现 Java 比 Go 具有很大优势。这是因为二叉树算法通常用于测试垃圾收集器分配对象的速度。Java 在这方面非常快,因为它使用了我们所说的凹凸指针。它只是增加一个指针,而 Go 将在内存中搜索合适的位置来分配对象。但是,使用 Arena 分配器,您也可以在 Go 中快速构建二叉树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import "golang.org/x/exp/constraints"
type Tree[K constraints.Ordered, V any] struct {
Root *TreeNode[K, V]
allocator Arena[TreeNode[K, V]]
}
func (tree *Tree[K, V]) NewNode(key K, value V) *TreeNode[K, V] {
n := tree.allocator.Alloc()
n.Key = key
n.Value = value
n.left = nil
n.right = nil
return n
}
func (tree *Tree[K, V]) Insert(key K, value V) {
n := tree.NewNode(key, value)
if tree.Root == nil {
tree.Root = n
} else {
tree.Root.Insert(n)
}
}

这就是为什么拥有真正的指针有好处的原因。没有它,您无法在连续的内存块中创建指向元素的指针。在该Alloc方法中,我们创建了一个由 32 个元素组成的连续块。然后,我们将指向该块中每个元素的指针存储在一个堆栈上,该堆栈包含一个可用于分配的块列表。

1
2
3
4
var blocks [32]T 
for i, _ := range blocks {
arena.blocks.Push(&blocks[i])
}

这只是可能的,因为我可以选择任意元素blocks[i]并获取指向该元素的指针&blocks[i]。Java 没有给你这种可能性。

Java GC 使用的Bump分配器与 Arena 分配器类似,您只需增加一个指针即可获取下一个值。除非您不必自己构建它。这可能看起来更聪明。但这会导致 Go 中避免的几个问题:

  1. 迟早您需要进行压缩,这涉及移动数据和修复指针。Arena 分配器不必这样做。
  2. 在多线程程序中,凹凸分配器需要锁(除非您使用线程本地存储)。这会扼杀它们的性能优势,因为锁会降低性能,或者线程本地存储会导致碎片,需要稍后进行压缩。

Go 的创建者之一 Ian Lance Taylor阐明了Bump分配器的问题

一般来说,使用一组每线程缓存分配内存可能会更有效,此时您已经失去了凹凸分配器的优势。所以我要断言,总的来说,有很多警告,今天为多线程程序使用压缩内存分配器并没有真正的优势。

逃逸分析

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 Escape 分析的局限性

Java 也确实逃脱了分析,但对其使用有更多限制。来自涵盖 HotSpot VM 的Java SE 16 Oracle 文档:

它不会***将***堆分配替换为未全局转义的对象的堆栈分配。

然而,Java 使用了一种称为标量替换的替代技巧,它避免了将对象放在堆栈上的需要。本质上它会爆炸和对象并将其原始成员放在堆栈上。请记住,Java 已经可以将原始值(例如intfloat)放在堆栈上。然而,正如Piotr Kołaczkowski在 2021 年发现的那样,在实践中,即使在非常微不足道的情况下,标量替换也不起作用。

相反,主要优点是避免锁定。如果您知道指针没有在函数外部使用,您还可以确定它不需要锁。

Go Escape分析的优势

然而,Go 使用逃逸分析来确定可以在堆栈上分配哪些对象。这显着减少了可以从分代 GC 中受益的短期对象的数量。请记住,分代 GC 的全部意义在于利用最近分配的对象存活时间短的事实。然而,Go 中的大多数对象可能会长期存在,因为短期对象很可能会被逃逸分析捕获。

与 Java 不同,这也适用于复杂对象。Java 通常只能成功地对字节数组等简单对象进行转义分析。即使是内置的ByteBuffer也不能使用标量替换在堆栈上分配。

分代 GC 与并发 GC 暂停

你可以读到很多关于垃圾收集器的专家声称,由于内存碎片,Go 比 Java 更有可能耗尽内存。争论是这样的:因为 Go 没有分代垃圾收集器,内存会随着时间的推移变得碎片化。当内存碎片化时,您将达到将新对象装入内存变得困难的地步。

但是,由于两个原因,此问题大大减少:

  1. Go 分配的小对象没有 Java 那么多。它可以将大型对象数组分配为单个内存块。
  2. 现代内存分配器,如 Google 的 TCMalloc 或 Intel 的 Scalable Malloc 不会对内存进行分段。

在设计 Java 时,内存碎片是内存分配器的一个大问题。人们不认为它可以解决。但早在 1998 年,Java 出现后不久,研究人员就开始解决这个问题。这是 Mark S. Johnstone 和 Paul R. Wilson 的论文

这大大加强了我们之前的结果,即内存碎片问题通常被误解,并且好的分配器策略可以为大多数程序提供良好的内存使用。

因此,为 Java 设计内存分配策略的许多假设根本不再适用

使用分代 GC 的 Java 策略旨在缩短垃圾收集周期。请记住,Java 必须停止一切来移动数据并修复指针。如果持续时间过长,这会降低性能和响应能力。使用分代 GC,每次缩短此时间时要检查的数据更少。

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

  1. 因为不需要移动内存,也不需要固定指针,所以在 GC 运行期间要做的工作更少。Go GC 只进行标记和扫描:它通过对象图查找应该释放的对象。
  2. 它同时运行。因此,一个单独的 GC 线程可以在不停止其他线程的情况下寻找要释放的对象。

为什么 Go 可以同时运行它的 GC 而不是 Java?因为 Go 不会修复任何指针或移动内存中的任何对象。因此,不存在尝试访问指向刚刚移动但该指针尚未更新的对象的指针的风险。由于某些并发线程正在运行,不再有任何引用的对象不会突然获得引用。因此,并行移除死对象是没有危险的。

这是怎么回事?假设你有 4 个线程在 Go 程序中工作。其中一个线程偶尔会在任意时间段T秒内完成总共 4 秒的 GC 工作。

现在想象一个带有 GC 的 Java 程序执行 GC 工作仅 2 秒。哪个程序挤出最多的性能?谁在T几秒钟内完成最多?听起来像 Java 程序,对吧?错误的!

Java 程序中的 4 个工作线程将所有工作停止 2 秒。T这意味着 2×4 = 8 秒的工作在间隔中丢失。因此,虽然 Go 停止的时间更长,但每次停止都会影响更少的工作,因为所有线程都没有停止。因此,缓慢的并发 GC 可能会胜过依赖于停止所有线程来完成其工作的更快的 GC。

如果垃圾的创建速度比 Go 清理它的速度快怎么办?

反对当前垃圾收集器的一个流行论点是,您可能会遇到一种情况,即活动工作线程产生垃圾的速度比垃圾收集器线程收集垃圾的速度要快。在 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 等现代语言一开始就简单地避免了产生这些问题,因此不再需要劳斯莱斯垃圾收集器。当您拥有值类型、转义分析、指针、多核处理器和现代分配器时,Java 设计背后的许多假设都将不复存在。它们不再适用。

假定的 GC 权衡不再适用

Mike Hearn 在 Medium 上有一个非常受欢迎的故事,他批评了有关 Go GC 的说法:现代垃圾收集。.

Hearn 的关键信息是在 GC 设计中总是存在权衡。他提出的观点是,因为 Go 的目标是低延迟收集,所以它们会受到许多其他指标的影响。这是一本有趣的读物,因为它涵盖了很多关于 GC 设计权衡的细节。

首先,我所说的低延迟是什么意思?与可能花费数百毫秒的各种 Java 收集器相比,Go GC 平均仅暂停大约 0.5 毫秒。

我从 Mike Hearn 的论点中看到的问题是,它们基于一个有缺陷的前提,即所有语言的内存访问模式都是相同的。正如我在本文中介绍的那样,这根本不是真的。Go 将产生更少的对象来由 GC 管理,并且它会使用逃逸分析及早清理大量对象。

旧技术天生就不好?

赫恩提出的论点表明,简单的收集在某种程度上天生就不好:

Stop-the-world (STW) 标记/扫描是本科计算机科学课程中最常教授的 GC 算法。在进行工作面试时,我有时会要求应聘者谈谈 GC,而且几乎总是,他们要么将 GC 视为一个黑匣子,对此一无所知,要么认为它现在仍在使用这种非常古老的技术。

是的,它可能已经过时了,但是这种技术允许您同时运行 GC,这是“现代”技术所不允许的。在我们拥有多核的现代硬件世界中,这一点更为重要。

Go 不是 C#

另一种说法:

由于 Go 是一种具有值类型的相对普通的命令式语言,它的内存访问模式可能与 C# 相当,其中分代假设肯定成立,因此 .NET 使用分代收集器。

情况并非如此。AC# 开发人员会尽量减少对较大值对象的使用,因为与指针相关的代码无法安全使用。我们必须假设 C# 开发人员更喜欢复制值类型而不是使用指针,因为这可以在 CLR 中安全地完成。这自然会带来更高的开销。

据我所知,C# 也没有利用逃逸分析来减少堆上短期对象的产生。其次,C# 并不擅长同时运行大量任务。正如 Pacer 所提到的,Go 可以利用它们的协程来加速并发收集。

为什么低延迟对 Java 也很重要

我们生活在一个充满 docker 容器和微服务的世界中。这意味着许多较小的程序相互通信并为彼此工作。想象一下工作需要通过几个服务。每当一条链中的这些服务中的一项出现重大暂停时,就会产生涟漪效应。它会导致所有其他进程停止工作。如果管道中的下一个服务正在等待一个忙于进行垃圾收集的服务,它就无法工作。

因此,延迟/吞吐量的权衡不再是 GC 设计中的权衡。当多个服务一起工作时,高延迟会导致吞吐量下降。Java 对高吞吐量和高延迟 GC 的偏好适用于单体应用程序世界。它不再适用于微服务世界。

这是 Mike Hearn 的论点的一个基本问题,即没有灵丹妙药,只有权衡取舍。它试图给人的印象是 Java 的权衡是同样有效的。但权衡必须适合我们生活的世界。

简而言之,我认为可以说围棋做出了许多明智的举动和战略选择。挥舞它,好像它只是任何人都可以做出的权衡一样,并没有削减它


Golang为什么不用Java的gc模式
https://blog.longpi1.com/2022/09/12/Golang为什么不用Java的gc模式/