共计 9095 个字符,预计需要花费 23 分钟才能阅读完成。
本文首发于 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
对象,它援用了两个独自的对象,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++ 一样的事件,并定义一个像这样的构造:
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 []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 中疾速构建二叉树。
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 曾经能够在栈上搁置诸如 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,那么省去它是不可取的。