为什么Go不再需要Java风格的GC?- itnext


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 是一种基本上将内存管理完全外包给其垃圾收集器的语言。结果证明这是一个很大的错误。
为了解决这些主要缺点,Java 维护人员已经在高级垃圾收集器上投入了大量资金。这些做一些称为压缩的事情。压缩涉及在内存中移动对象并将它们收集到内存中的连续块中。这并不便宜。将块从一个内存位置移动到另一个内存位置不仅会消耗 CPU 周期,而且更新对这些对象的每个引用以指向新位置也会消耗 CPU 周期。
执行这些更新需要冻结所有线程。您无法在使用引用时更新引用。这通常会导致 Java 程序完全冻结数百毫秒,其中对象移动、引用更新和未使用的内存被回收。
 
现代语言如何避免与 Java 相同的陷阱?
现代语言不需要像 Java 和 C# 这样复杂的垃圾收集器。这是因为它们没有被设计为在相同程度上依赖它们。
// Go: Make an an array of 15 000 Point objects in
type Point struct {
    X, Y int
}
var points [15000]Point

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

  • 值类型

下面的代码定义了一个矩形,用 Min和Maxpoint 定义其范围。
type Rect struct {
   Min, Max Point
}

这成为一个连续的内存块。在 Java 中,这将变成一个Rect对象,引用两个单独的对象,Min和Maxpoint 对象。
因此,在 Java 中,一个 的实例Rect需要 3 次分配,但在 Go、Rust、C/C++ 和 Julia 中仅需要 1 次分配。
Java 开发人员意识到他们搞砸了,并且您确实需要值类型以获得良好的性能。Project Valhalla是 Oracle 带头提供 Java 值类型的一项努力,
 
  • 值类型还不够

Valhalla会解决 Java 的问题吗?它只会让 Java 与 C# 处于同等地位。C# 在 Java 出现多年之后才出现,并且从那时起就意识到垃圾收集器并不是每个人都认为的那样神奇。因此,他们添加了值类型。
然而,在内存管理灵活性方面,这并没有让 C# 和 Java 与 Go 和 C/C++ 等语言处于同等地位。Java 不支持真正的指针。在 Go 中,我可以这样写:
// Go pointer usage
var ptr *Point = &rect.Min
// Store pointer to Min in ptr
*ptr = Point(2, 4)        
// replace rect.Min

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

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

然而,C# 指针支持带有一些不适用于 Go 的警告:
  1. 使用点的代码必须标记为不安全。这会创建安全性较低且更容易崩溃的代码。
  2. 堆栈上分配的纯值类型(所有结构字段必须是值类型)。
  3. 在固定范围内,垃圾收集已关闭,使用 fixed 关键字。

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

使用适当的指针,您可以做很多在只有值类型时无法实现的事情。一个例子是创建二级分配器。Chandra Sekar S. 举了一个例子:

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 := &(*arena)[len(*arena)-1]

 
Java Bump 分配器的问题
Java GC 使用的Bump分配器与 Arena 分配器类似,您只需增加一个指针即可获得下一个值。除非您不必自己构建它。这可能看起来更聪明。但这会导致在 Go 中避免的几个问题:
  1. 迟早你需要进行压缩,这涉及移动数据和固定指针。Arena 分配器不必这样做。
  2. 在多线程程序中,碰撞分配器需要锁(除非您使用线程本地存储)。这会扼杀它们的性能优势,要么是因为锁会降低性能,要么是因为线程本地存储会导致碎片,这需要稍后进行压缩。

Go 的创造者之一伊恩·兰斯·泰勒 (Ian Lance Taylor)阐明了凹凸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指向一个切片,它本质上与指向数组的指针相同。因为它被返回return,属于它逃脱了,这意味着values必须在堆heap上分配。
然而,在第二个例子中,没有任何指针values逃脱nonEscapingPtr函数。因此values可以在栈stack上分配,这是非常快速和廉价的。
逃逸分析本身只是分析一个指针是否逃逸。
 
  • Java 转义分析的局限性

Java 也进行转义分析,但在使用上有更多限制。 HotSpot VM 的Java SE 16 Oracle 文档
只有全局逃逸对象才可以用栈替代堆
然而,Java 使用了一种称为标量替换的替代技巧,它避免了将对象放在栈上的需要。
从本质上讲,它反对并将其原始成员放在栈中。请记住,Java 已经可以将诸如int和 之类的原始值float放在栈上。然而,正如Piotr Kołaczkowski在 2021 年发现的那样,在实践中,即使在非常微不足道的情况下,标量替换也不起作用。
相反,主要优点是避免锁定。如果您知道在函数外部不使用指针,您还可以确定它不需要锁。
  • Go Escape 分析的优势

但是,Go 使用转义分析来确定可以在堆栈上分配哪些对象。这显着减少了可以从分代 GC 中受益的短期对象的数量。请记住,分代 GC 的全部意义在于利用最近分配的对象存活时间很短的事实。然而,Go 中的大多数对象可能会存活很长时间,因为寿命很短的对象很可能会被逃逸分析捕获。
与 Java 不同,这也适用于复杂对象。Java 通常只能成功地对像字节数组这样的简单对象进行转义分析。即使内置ByteBuffer也不能使用标量替换在栈上分配。
 
现代语言不需要压缩 GC
你可以读到很多关于垃圾收集器的专家声称,由于内存碎片,Go 比 Java 更有可能耗尽内存。争论是这样的:因为 Go 没有压缩垃圾收集器,内存会随着时间的推移变得碎片化。当内存碎片化时,您将达到将新对象放入内存变得困难的地步。
但是,由于两个原因,这个问题大大减少了:
  1. Go 没有像 Java 那样分配那么多的小对象。它可以将大型对象数组分配为单个内存块。
  2. 现代内存分配器(例如 Google 的 TCMalloc 或英特尔的 Scalable Malloc)不会对内存进行碎片化。

在 Java 设计时,内存碎片是内存分配器的一个大问题。人们认为它无法解决。但即使回到 1998 年,也就是 Java 出现后不久,研究人员就开始解决这个问题。这是 Mark S. Johnstone 和 Paul R. Wilson 的一篇论文
这大大加强了我们之前的结果,表明内存碎片问题通常被误解,并且良好的分配器策略可以为大多数程序提供良好的内存使用。

因此,为 Java 设计内存分配策略时的许多假设前提是不再正确了。
 
分代 GC 与并发 GC 暂停

使用分代 GC 的 Java 策略旨在缩短垃圾收集周期。请记住,Java 必须停止一切以移动数据并修复指针。如果持续时间过长,这会降低性能和响应能力。使用分代 GC,每次减少时间检查的数据更少。
然而,Go 用许多替代策略解决了同样的问题:

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

为什么 Go 可以并发运行它的 GC 而 Java不能?
因为 Go 不固定任何指针或移动内存中的任何对象。因此,不存在尝试访问指向刚被移动但该指针尚未更新的对象的指针的风险。由于某些并发线程正在运行,不再有任何引用的对象不会突然获得引用。因此,并行移除死对象没有危险。
这是怎么玩的?
假设您有 4 个线程在 Go 程序中工作。其中一个线程偶尔会在任意时间段T秒内执行总共 4 秒的 GC 工作。
现在想象一个带有 GC 的 Java 程序只执行 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 等现代语言一开始就简单地避免了这些问题的产生,从而消除了对劳斯莱斯垃圾收集器的需求。当您拥有值类型、转义分析、指针、多核处理器和现代分配器时,Java 设计背后的许多假设就不再适用了。它们不再适用。
 
为什么低延迟对于 Java 也很重要
我们生活在一个 docker 容器和微服务的世界中。这意味着许多较小的程序可以相互通信和工作。想象一下工作要通过几个服务。每当一个链中的这些服务中的一个出现明显的停顿时,就会产生涟漪效应。它会导致所有其他进程停止工作。如果管道中的下一个服务正在等待忙于进行垃圾收集的服务,则它无法工作。
因此,延迟/吞吐量权衡不再是 GC 设计中的权衡。多个服务协同工作时,高延迟会导致吞吐量下降。Java 对高吞吐量和高延迟 GC 的偏好适用于单体世界。它不再适用于微服务世界。
 
Java假设的GC 权衡不再适用
Mike Hearn 在 Medium 上有一个非常受欢迎的故事,批评关于 Go GC:现代垃圾收集的说法。
Hearn 的关键信息是在 GC 设计中总是要权衡利弊。他提出的观点是,由于 Go 的目标是低延迟收集,因此他们将在许多其他指标上受到影响。这是一本有趣的读物,因为它涵盖了很多关于 GC 设计权衡的细节。
首先,低延迟是什么意思?与可能花费数百毫秒的各种 Java 收集器相比,Go GC 平均仅暂停约 0.5 毫秒。
我在 Mike Hearn 的论点中看到的问题在于,它们基于这样一个有缺陷的前提,即所有语言的内存访问模式都相同。正如我在本文中所介绍的那样,这根本不是真的。Go 将生成更少的对象以供 GC 管理,并且会使用逃逸分析及早清除其中的很多对象。
赫恩 (Hearn) 提出的论点表明,简单的收集器本质上是不好的:
停止世界 (STW) 标记/扫描是本科计算机科学课程中最常教授的 GC 算法。在面试时,我有时会要求应聘者谈谈 GC,而且几乎总是这样,他们要么将 GC 视为一个黑匣子,对它一无所知,要么认为它现在仍在使用这种非常古老的技术。
是的,它可能很旧,但是这种技术允许您同时运行 GC,这是“现代”技术所不允许的。在我们拥有多核的现代硬件世界中,这更重要。
 
Go 不是 C#
另一种说法:
由于 Go 是一种相对普通的具有值类型的命令式语言,它的内存访问模式可能与 C# 相当,其中分代假设肯定成立,因此 .NET 使用分代收集器。
情况并非如此。C# 开发人员将尽量减少对较大值对象的使用,因为不能安全地使用指针相关代码。我们必须假设 C# 开发人员更喜欢复制值类型而不是使用指针,因为这可以在 CLR 中安全地完成。这自然会带来更高的开销。
据我所知,C# 也没有利用逃逸分析来减少堆上短期对象的产生。其次,C# 不擅长并发运行大量任务。Go 可以利用它们的协程来同时加速收集,正如 Pacer 所提到的。
详细点击标题