美文网首页读书谈技术
Go 不需要 Java 风格的 GC

Go 不需要 Java 风格的 GC

作者: 技术的游戏 | 来源:发表于2022-11-17 23:58 被阅读0次

    为什么 Go 不需要像 Java 和 C# 这样昂贵的垃圾收集器?

    image.png

    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 基本上忽略了堆栈并选择在堆上分配所有内容,除了像整数和浮点数这样的基元。每当您new Something()使用 Java 编写时,您都会消耗堆上的内存。

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

    class Knight {
       int health;
    }
    

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

    • Type——标识类或对象的类型。
    • Lock——用在同步语句中。
    • Mark——在标记期间和垃圾收集器的清扫期间使用。

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

    class oopDesc {
        volatile markOop  _mark;    // 用于标记和扫描
        Klass*                   _klass;    // 类型
    }
    

    内存碎片

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

    image.png

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

    Java如何克服内存碎片

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

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

    增加复杂性

    为了减少这些长时间的停顿,Java 使用了所谓的分代垃圾收集器。

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

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

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

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

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

    值类型

    这在其他语言中可行的原因是因为它们支持值类型。下面的代码定义了一个矩形,其中一个Min和一个Max点定义了它的范围。

    type Rect struct {
       Min, Max Point
    }
    

    这成为一个连续的内存块。在 Java 中,这将变成一个Rect对象,该对象引用两个单独的对象,即MinMax point 对象。因此在 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 字节内存,并且访问速度较慢,因为字节本身与容器对象位于不同的内存区域。 我们尝试通过将 byte[20] 转换为 5 个整数来解决它,但这会花费我们的机器指令。

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

    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 中,我可以这样写:

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

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

    // C# 指针用法
    unsafe 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 分配器的示例。

    type Arena[T any] struct {
        blocks Stack[*T]
    }
    func (arena *Arena[T]) Alloc() *T {
        if arena.blocks.IsEmpty() {
            var blocks [32]T     // 一次分配 32 个元素
            for i, _ := range blocks {
                arena.blocks.Push(&blocks[i])
            }
        }
        b, _ := arena.blocks.Top()
        arena.blocks.Pop()
        return b
    }
    

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

    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 个元素的连续块。然后,我们将指向此块中每个元素的指针存储在堆栈中,该堆栈包含可用于分配的块列表。

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

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

    Java Bump 分配器的问题

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

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

    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指向一个切片,它本质上与指向数组的指针相同。它逃脱了,因为它被返回了。这意味着values必须在堆上分配。

    然而,在第二个例子中,没有指针values离开nonEscapingPtr函数。因此values可以在堆栈上分配,这是非常快速和便宜的。逃逸分析本身只是分析一个指针是否逃逸。

    Java 逃逸分析的局限性

    Java 也可以逃避分析,但在使用上有更多限制。来自涵盖 HotSpot VM 的Java SE 16 Oracle 文档:

    对于不全局逃逸的对象,它不会用堆栈分配替换堆分配。

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

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

    Go逃逸分析的优势

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

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

    现代语言不需要压缩 GC

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

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

    1. Go 不像 Java 那样分配那么多的小对象。它可以将大型对象数组分配为单个内存块。
    2. 现代内存分配器(例如 Google 的 TCMalloc 或 Intel 的 Scalable Malloc)不会对内存进行碎片化。

    在设计 Java 时,内存碎片是内存分配器的一个大问题。人们认为它无法解决。但即使是在 1998 年,Java 刚问世不久,研究人员就开始着手解决这个问题。这是 Mark S. Johnstone 和 Paul R. Wilson 的一篇论文

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

    因此,为 Java 设计内存分配策略的许多假设不再正确。

    分代 GC 与并发 GC 暂停

    使用 Generational 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。

    如果垃圾的产生速度比 Go 清理速度快怎么办?

    反对当前垃圾收集器的一个流行论点是,您可能会遇到这样一种情况,即活动工作线程产生垃圾的速度比垃圾收集器线程收集垃圾的速度更快。在 Java 世界中,这被称为“并发模式失败”。

    声称在这种情况下,运行时别无选择,只能完全停止您的程序并等待 GC 周期完成。因此,当 Go 声称 GC 暂停非常低时,这种说法只能适用于 GC 有足够的 CPU 时间和空间来超过主程序的情况。

    但是 Go 有一个巧妙的技巧来解决Go GC 大师 Rick Hudson 描述的这个问题。Go 使用所谓的 Pacer。

    如果需要,Pacer 会在加快标记速度的同时减慢分配速度。在较高的层次上,Pacer 停止了 Goroutine,它正在做大量的分配工作,并让它开始做标记。工作量与 Goroutine 的分配成正比。这加快了垃圾收集器的速度,同时减慢了修改器的速度。

    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 管理,并且它将使用逃逸分析尽早清理掉很多对象。

    旧技术本质上是坏的?

    赫恩提出的论点表明,简单的收集在某种程度上本质上是不好的:

    停止世界 (STW) 标记/清除是本科计算机科学课程中最常教授的 GC 算法。在进行工作面试时,我有时会要求应聘者谈一谈 GC,但几乎总是,他们要么将 GC 视为一个黑盒子,对此一无所知,要么认为它仍然使用这种非常古老的技术。

    是的,它可能很旧,但这种技术允许您并发运行 GC,这是“现代”技术所不允许的。在我们拥有多核的现代硬件世界中,这更重要。

    Go 不是 C#

    另一个说法:

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

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

    据我所知,C# 也不使用逃逸分析来减少堆上短期对象的生成。其次,C# 不太擅长同时运行大量任务。正如 Pacer 所说,Go 可以使用他们的协程来同时加速收集。

    Compaction

    Compaction:因为没有 compaction,你的程序最终会碎片化它的堆。我将在下面更多地讨论堆碎片。您也不会从缓存中整齐地放置东西中受益。

    在这里,Mike Hearn 根本不了解分配器的当前状态。TCMalloc 等现代分配器基本上消除了这个问题。

    程序吞吐量:由于 GC 必须为每个周期做大量工作,这会从程序本身窃取 CPU 时间,从而减慢程序速度。

    当您有并发 GC 时,这不适用。当 GC 工作时,所有其他线程都可以继续运行——不像 Java,它必须停止世界。

    堆开销

    Hearn 提出了“并发模式失败”问题,假设 Go GC 将冒跟不上垃圾生产者步伐的风险。

    堆开销:因为通过标记/清除收集堆非常慢,你需要大量的空闲空间来确保你不会遭受“并发模式故障”。Go 默认使用 100% 的堆开销……它使程序所需的内存量翻倍。

    我对这种说法持怀疑态度,因为我看到的许多现实世界的例子似乎都表明 Go 程序使用的内存更少。更不用说这忽略了 Pacer 的存在,它将抓住 Goroutines 产生大量垃圾并让它们清理。

    为什么即使对于 Java,低延迟也很重要

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

    因此,延迟/吞吐量权衡不再是 GC 设计中的权衡。在多个服务协同工作的情况下,高延迟将导致吞吐量下降。Java 对高吞吐量和高延迟 GC 的偏好适用于整体世界。它不再适用于微服务世界。

    这是迈克·赫恩 (Mike Hearn) 认为没有灵丹妙药,只有权衡取舍的论点的一个根本问题。它试图给人一种印象,即 Java 权衡同样有效。但必须根据我们生活的世界进行权衡。

    简而言之,我认为可以公平地说 Go 做出了许多明智的举动和战略选择。挥手不去,就好像它只是任何人都可以做出的权衡一样,并不能解决问题。

    Have a nice day, Happy coding.

    相关文章

      网友评论

        本文标题:Go 不需要 Java 风格的 GC

        本文链接:https://www.haomeiwen.com/subject/nmdmxdtx.html