写点什么

深入探索垃圾收集

作者:俞凡
  • 2024-04-15
    上海
  • 本文字数:14822 字

    阅读完需:约 49 分钟

垃圾收集是应对应用程序内存管理挑战的主要方式之一,本文介绍了业界主流的垃圾收集算法(以 Java 为主),比较了不同算法的优劣、适用场景,对于研发团队选择适合业务场景的垃圾收集算法提供了很好的参考。原文: In-depth Exploration of Garbage Collector (GC)


导言

在 C 或 C++ 等传统编程语言中,由开发人员负责为对象或数据结构显式分配、释放内存。


但是手动管理内存容易出错,导致内存泄漏(分配的内存未释放)或野指针(指针引用已被释放的内存)等错误,而这些问题会造成软件不稳定或不安全。


垃圾回收通过自动化内存管理过程来应对这些挑战。垃圾回收器不需要开发人员手动分配和释放内存,而是自动识别和回收程序无法再访问或引用的内存。


垃圾回收算法的复杂程度和实现方式各不相同,不同编程语言和运行环境可能会使用不同的垃圾回收策略。


虽然垃圾回收有很多好处,如自动内存管理、防止与内存相关的错误,但也会给 CPU 和内存使用造成额外开销。


本文旨在探讨不同垃圾收集算法,研究其内部工作原理、优点和局限性。


从而有助于我们选择合适的算法和配置,使我们能够更高效的编写代码。

历史背景

程序运行内存

运行中程序的典型内存布局如下:



在程序运行期间,静态内存(Static memory) 用于存储全局变量或使用 static 关键字声明的变量。


栈(Stack) 是计算机程序临时存放数据的指定内存区域,是一个连续内存块,在函数调用期间存储数据,并在函数结束后清除。


栈内存遵循后进先出(LIFO,Last-In-First-Out) 原则,即最新放入栈的条目将被最先移除。


在函数调用时,程序会生成一个称为栈帧(Stack Frame) 的新元素,然后将其推入该特定函数调用的栈中。


栈帧包括:


  • 函数局部变量。

  • 传递给函数的参数。

  • 返回地址,指示程序在函数结束后继续执行的位置。

  • 附加元素,如前一帧的基本指针。


函数执行完毕后,其栈帧将被移除,控制权将转回到帧内指示的返回地址。


请记住,内存的容量是有限的,一旦用完,就会发生栈溢出(Stack Overflow),导致程序失败。因此,并不适合存储大量数据。


此外,不允许内存块在分配后调整大小。例如,如果我们为上的数组分配的内存太少,就无法像动态分配内存那样调整其大小。


这些因素促成了堆内存的发明。


堆(Heap) 是指定用于动态内存分配的计算机内存区域。与内存自动管理不同,内存需要手动管理,通过 malloc 等方法分配内存,用 free 等方法释放内存。


上的对象是通过引用(references) 访问的,引用是指向这些对象的指针(pointers)。对象在空间中实例化,而内存则保存对象的引用:



常用于以下场景:


  • 数据结构(如数组或对象)所需的内存只有在运行时才能确定(动态分配)。

  • 数据的保留时间必须超过单次函数调用的持续时间。

  • 将来有可能需要调整所分配内存的大小。


本文后续内容的重点将放在内存的管理上。

垃圾收集器的出现

堆对象占用的内存可以通过显式删除分配(使用 C 的 free 或 C++ 的 delete 等操作)进行手动回收,也可以由运行时系统通过引用计数垃圾回收实现自动回收。


手动内存管理可能会导致两种主要的编程错误:


🔴第一种情况是在内存引用仍然存在的情况下过早释放内存,造成所谓的野指针



🔴 第二种情况是可能无法释放程序不再需要的对象,造成内存泄漏



在并发编程中,这些问题变得更加复杂,因为多个线程可能会同时引用同一个对象。


因此,有必要对各种方法进行评估和重组,从而打造自动内存管理。


简单来说,自动内存管理可以看作是对手动内存管理的重构:


☑️ 内存管理集中在垃圾回收器这一单一工具中,由运行时控制(通常由虚拟机控制)。



☑️ 垃圾收集器的内存管理方法简化了手动释放内存的过程,使代码更易于阅读和维护。


☑️ 内存调试和分析变得更加高效。


☑️ 垃圾收集器和自动内存管理算法的升级变得可扩展、通用和高效。


垃圾回收器里有什么秘密?接下来我们将进一步探讨!

垃圾收集器概述

垃圾收集器(GC)是一个定期触发的后台程序,能自动释放不再使用的对象所占用的内存,从而使内存满足应用程序的未来需求。


GC 如何识别未使用的对象?


垃圾收集器(GC)通过定位那些不再被运行程序的任何部分引用的对象来识别未使用的对象。如果一个对象没有任何活跃引用,就会被认为是死对象,可以被收集,从而释放内存。


堆不仅可以被局部变量引用,也可以被全局变量和静态变量、线程栈(被线程的执行栈引用的对象)和常量池(存储程序使用的常量值)引用。



任何持有对象但不在中的指针(引用)都可以称为根(root),垃圾回收利用这些来确定对象是否有效。如果某个对象可以被任意一个直接访问或临时访问,那么就不会被视为垃圾对象。如果无法访问,则视为垃圾,需要进行收集。



托管内存系统由在堆中运行的突变器(mutator)和垃圾回收器组成:


  • 突变器是修改堆对象的程序(应用程序)。

  • 突变器通过分配器申请堆内存,而收集器则负责回收堆内存。

  • 内存分配器和垃圾回收器合作管理堆内存空间。



垃圾回收器的基本特征包括:


  • 最短的整体执行时间(吞吐量)。

  • 优化空间使用(内存开销)。

  • 停顿时间最少(尤其是实时任务)。

  • 改进突变器的定位。

  • 可扩展性。


垃圾收集器所使用的各种算法在方法、优化和利弊方面各不相同,因此上述特性也会受到影响。


在接下来的章节中,我们将研究不同的算法,以确定它们的优势、局限性和在现实世界中的潜在用途。


开始!

Mark-Sweep

工作原理和算法

Mark-Sweep 原始算法是由约翰-麦卡锡(John McCarthy)于 1960 年为 Lisp 引入,基于某种 STW(stop-the-world)机制。当程序请求内存但没有可用内存时,程序会被停止,并执行一次完整的垃圾回收以释放空间。


Mark-Sweep GC 的运行可概括为以下几个阶段:


1️⃣第一步是提取并准备根列表。根可能是局部变量、全局变量、静态变量或线程栈中引用的变量。


2️⃣ 确定所有根节点后,就进入标记阶段(mark phase)。标记阶段需要对根节点进行深度优先搜索(DFS)遍历,目的是将根节点可到达的所有节点标记为活跃节点。


许多垃圾收集算法在收集程序的核心部分都采用了跟踪例程(tracing routine)该例程会标记每一个可以从初始根集到达的对象。跟踪例程通常会执行图遍历,使用标记栈,其中包含一组已访问过但其子对象尚未扫描的对象。跟踪流程会反复从标记栈中取出对象,标记其子对象,并将之前未标记的每个子对象插入标记栈。从标记栈中插入和移除对象的顺序决定了跟踪顺序。最常见的跟踪顺序是后进先出标记栈的 DFS(深度优先搜索)和作为先进先出队列工作的标记栈的 BFS(广度优先搜索)

-- 数据结构感知垃圾回收器



3️⃣ 未标记的节点是垃圾节点,因此在清扫阶段(sweep phase),GC 会遍历所有对象并释放未标记的对象。此外还会重置已标记的对象,为下一循环做好准备。



基本 Mark-Sweep 算法如下:


//MarkingAdd each object referenced by the root set to Unscanned and set its reached-bit to 1;while(Unscanned != empty set){     remove some object o from Unscanned;     for(each object o' referenced in o){         if(o' is unreached; i.e, reached-bit is 0){              set the reached-bit of o' to 1;            place o' in Unscanned;         }     }}
//SweepingFree = empty set;for(each chunk of memory o in heap){ if(o is unreached, i.e, its reached-bit is 0) add o to Free; else set reached-bit of o to 0;}
复制代码


Mark-Sweep GC 有一个优点:由于对象在 GC 期间不会移动,因此无需更新其指针。不过,也有几个缺点:


  • 由于在不扫描整个堆的情况下很难找到无法到达的对象,因此其扫描阶段的成本很高。

  • 垃圾回收过程中必须停止执行程序,从而导致严重的性能问题。

  • 堆中未使用内存的累积会导致内存碎片,使其无法用于新对象。


显然,Mark-Sweep GC 不适合实时系统。


我们看看这种算法到底是如何实现和使用的。

真实用例

该算法的原始版本可以在这些应用中找到:


1️⃣ uLisp:


uLisp 中使用的垃圾回收器类型称为标记和扫描。首先,markobject() 会标记所有仍可访问的对象。然后,sweep() 基于未标记的对象建立一个新的 freelist,并删除已标记对象的标记。

-- uLisp — Garbage collection


2️⃣ Ruby 的早期版本:


Ruby 的第一个版本已经有了基于标记扫描(M&S,Mark and Sweep)算法的 GC。M&S 是最简单的 GC 算法之一,包括两个阶段:(1) 标记:遍历所有存活对象并标记。(2) 清扫:对未标记的未使用对象执行垃圾收集。

虽然 M&S 算法简单且运行良好,但也存在一些问题。最重要的问题是"吞吐量 "和"暂停时间"。由于 GC 的开销,Ruby 程序的 GC 速度会减慢。换句话说,低吞吐量会增加应用程序的总执行时间。每次 GC 都会停止 Ruby 应用程序的运行。停顿时间过长会影响交互式网络应用的用户体验。Ruby 2.1 引入了分代垃圾回收(generational garbage collection),以解决"吞吐量"问题。

-- Ruby 2.2 中的增量式垃圾回收 | Heroku


此外,还可以找到原始算法的改进版本:


1️⃣增量式标记扫描算法(Incremental Mark-Sweep algorithm):


➖ Ruby 2.2:


增量式垃圾回收算法将垃圾回收执行过程拆分为多个细粒度过程,并将垃圾回收过程和 Ruby 进程交错处理。增量式垃圾回收会在一段时间内发起许多次较短的暂停,而不是一次长时间的暂停。总暂停时间是一样的(或者由于使用增量 GC,开销甚至更长一些),但每个单独的暂停时间要短得多,从而使得性能更加稳定。

-- Ruby 2.2 中的增量式垃圾回收 | Heroku



➖ OCaml:


大堆通常比小堆大很多,可以扩展到千兆字节大小。垃圾收集算法分几个阶段运行[...]标记和清扫阶段在堆的片段上增量运行,以避免长时间暂停应用程序,并在每个片段之前进行快速的小堆收集。只有压缩阶段会一次性触及所有内存,不过这是相对罕见的操作。

-- 了解垃圾回收器 - 真实的 OCaml 世界


➖ Mozilla SpiderMonkey:


SpiderMonkey 的垃圾回收(GC)采用增量标记模式、 分代回收和压缩。大部分 GC 工作在辅助线程上执行。

-- 垃圾回收(realityripple.com)


2️⃣ 并发标记扫描(CMS,Concurrent Mark-Sweep)算法:


并发标记扫描(CMS)收集器是专为那些希望缩短垃圾收集暂停时间的应用程序而设计的,这些应用程序可以在应用程序运行时与垃圾收集器共享处理器资源。

-- 并发标记扫描(CMS)收集器(oracle.com)


并发标记扫描收集器(或并发收集器、CMS)是 Oracle HotSpot Java 虚拟机(JVM)中的标记扫描垃圾收集器,自 1.4.1 版起可用,在第 9 版中被弃用,在第 14 版中被移除,因此从 Java 15 开始就不再可用了。

-- 并发标记清扫收集器 - 维基百科


顺便提一下,Java 已用 Z 垃圾收集器 (ZGC) 和 Shenandoah 收集器取代了并发标记扫描(CMS),我们将在接下来的章节中介绍。

复制

工作原理和算法

复制垃圾回收(Copying Garbage Collection)的原理是将堆分成两个大小相等的半空间:源空间(from-space)目标空间(to-space)



1️⃣ 内存在源空间中分配,而目标空间为空。


2️⃣ 当源空间已满时,源空间中所有可访问的对象都会被复制到目标空间,指向它们的指针也会相应更新。



⛔ 在复制对象之前,有必要验证该对象以前是否被复制过。如果已经复制,则不应再次复制该对象,而应使用现有副本。在复制完源空间对象后,可以通过在该对象中放置一个"转发指针"来进行验证。


3️⃣ 最后,两个空间的角色互换,程序继续运行。



✔️ 在复制垃圾回收器中,内存分配在源空间中线性进行,无需空闲列表或搜索空闲块。只需用一个指针标记源空间中已分配区域和空闲区域的边界即可。


✔️ 此外,复制垃圾回收器中的分配非常快,几乎与栈分配一样快。


原始复制垃圾回收算法执行的是可达图(reachable graph)的深度优先遍历,在递归执行时可能会导致栈溢出


相比之下,Cheney 的复制垃圾回收算法是一种高效的技术,它对可达图进行广度优先遍历,只需要一个指针作为附加状态:


✳️ 在任何广度优先遍历中,都有必要跟踪已被访问但其子节点尚未被探索的节点集。


需要额外内存(通常是队列)来跟踪已访问但尚未探索的子节点。

-- 广度优先搜索 - 维基百科


✳️ Cheney 算法从根本上使用目标空间来存储这组节点,并用一个名为 scan 的指针来表示。


✳️ 这个指针将目标空间分为两部分:一部分是已访问过的子节点,另一部分是尚未访问过的子节点。



➕ 使用复制垃圾回收代替标记和扫描的优势在于消除外部碎片、快速分配和避免死对象遍历。


➕ 切尼算法的一个主要特点是,它从不接触任何需要释放的对象,只遵循从活跃对象到活跃对象的指针。


➖ 但缺点是需要消耗两倍的虚拟内存,必须准确识别指针,复制成本可能很高。


Github 上有实现Cheney算法的伪代码


让我们看看这种算法到底是如何实现和使用的。

真实用例

1️⃣ Ocaml:


为了对小堆进行垃圾回收,OCaml 会使用复制收集(copying collection)将小堆中的所有活跃块移动到大堆中。这需要的工作量与小堆中的活跃块数量成正比,而根据世代假设,小堆中的活跃块数量通常较少。一般来说,垃圾回收器会在运行过程中 STW(即停止程序运行),这就是为什么必须快速完成,以便让应用程序以最小的代价恢复运行。

-- 了解垃圾回收器 - 真实世界 OCaml


2️⃣ LISP:


Fenichel 和 Yochelson 描述了在使用虚拟内存的 LISP 系统中,性能是如何随着时间的推移而下降的。他们的解决方案--复制垃圾回收(经 Cheney 进一步修改)--在现代 LISP 系统中被广泛采用,但其性能受到了限制,因为需要扫描可能很大的根集,并在每次垃圾回收时将通过计算维护的所有结构从一个区域移动到另一个区域。

-- 通用计算机上 LISP 系统的终身垃圾收集器。(dtic.mil)


3️⃣ Chicken (Scheme 实现):


所使用的设计是一种复制垃圾收集器,最初由 C. J. Cheney 设计,它将所有活跃对象复制到堆中。

-- Chicken (Scheme implementation) — Wikipedia


我们对复制垃圾回收领域的探索到此结束,现在让我们进入一种新的算法!

标记-压缩(Mark-Compact)

工作原理和算法

标记-压缩算法可以看作是标记-扫描算法和 Cheney 复制算法的结合。首先,对可达对象进行标记,然后通过压缩步骤将可达(标记)对象移至堆区域的起始位置。

-- 标记-压缩算法 - 维基百科


Mark-Compact 算法在运行过程中会经历不同阶段:


1️⃣ 从标记阶段开始,在这一阶段识别实时数据。


2️⃣ 接下来,通过重新定位对象和更新所有已移动对象的实时引用的指针值来压缩实时数据。



压缩的方法有很多种,既可以保留原来的顺序,也可以不考虑。以下是几种不同的方法:


1️⃣ 任意(Arbitrary):不保持逻辑或空间顺序。



2️⃣ 线性化(Linearizing):移动物体,然后根据逻辑关系排序,即把相互指向的物体移动到相邻位置。



3️⃣ 滑动(Sliding):保持原来的分配顺序。



让我们看看这种算法到底是如何实现和使用的。

真实用例

有五种著名的压缩算法:



算法可分为以下几类:


  • 单处理器压缩(Uni-processor compaction) 包括双指、Lisp2 和 Jonkers 线程。

  • 并行编译(Parallel compaction) 包括 Sun 的编译、IBM 的编译和 Compressor(并行、并发、延迟......)。


下面的表格总结了单处理器压缩算法的特点:



该表比较了 Jonkers 的线程算法、仅限于单线程的 IBM 并行压缩算法和完全并行的 IBM 并行压缩算法的性能(时间单位为毫秒):



即使仅限于单线程,IBM 的并行编译算法仍能保持高效,提供显著的速度提升和高质量的编译。


让我们使用更先进的算法!

分代

工作原理和算法

分代垃圾收集是指根据对象的年龄将其分成若干代,并优先收集年轻代,而不是较老的一代。



1️⃣ 年轻代(The Young Generation) 是分配和老化所有新对象的地方。


2️⃣ 当年轻代填满时,会引发一次小规模垃圾收集,被死对象填满的年轻代很快就会被收集起来。


🚩 所有小规模垃圾回收都是 STW 事件。


3️⃣ 根据晋升政策(Promotion Policy),幸存对象被晋升为老年代(Old Generation)


4️⃣ 老年代用于存储存活时间较长的对象。通常情况下,会为年轻代对象设置阈值,当达到该年龄时,该对象就会被转移到老年代。


5️⃣ 当老年代内存已满时,将进行一次大回收,回收该代和所有年轻代的内存。


6️⃣ 此外,还为年轻代对象设置了阈值,当达到该阈值(即对象被复制的次数)时,对象就会被移到老年代。


🚩 大型垃圾回收也是 STW 事件。


✔️ 大部分分代收集器通过复制来管理年轻代:原始复制收集器、并行复制收集器或并行清理收集器。


✔️ 可以通过 Mark-Sweep 算法、并发收集器增量收集器来管理旧世代。


回顾一下优缺点:


➕ 分代 GC 往往能减少 GC 暂停时间,因为大部分时间只收集最年轻的一代,也是最小的一代。


➕ 在复制 GC 时,分代还能避免重复复制长寿命对象。


➖ 在此方案中,由于实时对象可以处于不同的代际空间,因此出现了代间指针(例如,从上一代指向下一代的指针)的问题。


➖ 由于老年代的回收不如年轻代频繁,所以老对象有可能会阻止回收死去的年轻对象。这个问题被称为裙带关系问题(Nepotism)


让我们看看这种算法到底是如何实现和使用的。

真实用例

1️⃣ Python:


标准 CPython 垃圾回收器有两个组成部分,即引用计数回收器和分代垃圾回收器(称为 gc 模块)。

-- Python 中的垃圾回收:你需要知道的事情 | Artem Golubin (rushter.com)


为了限制每次垃圾回收所需时间,默认构建的 GC 实现使用了一种流行的优化方法:世代。这个概念背后的主要思想是假设大多数对象的生命周期都很短,因此可以在创建后很快被回收。事实证明,这与许多 Python 程序的实际情况非常接近,因为许多临时对象的创建和销毁都非常快。为了利用这一事实,所有容器对象都被划分为三个空间/世代。每个新对象都从第一代(第 0 代)开始。前一种算法只对某一代的对象执行,如果一个对象在其一代的回收中存活下来,就会被转移到下一代(第 1 代),那里回收频率会更低。如果同一对象在新一代(第 1 代)的另一轮 GC 中存活下来,将被转移到最后一代(第 2 代),在那里回收的次数最少。

-- 垃圾收集器设计(python.org)


2️⃣ Ruby:


回到问题的关键:从 Ruby 2.1 开始,Ruby 引入了利用弱代际假设的分代 GC,将更频繁的垃圾回收工作集中在年轻、较新的对象上。Ruby 的垃圾回收器实际上有两种不同类型的垃圾回收:大 GC 和小 GC。小 GC 的频率更高,主要针对年轻对象。(大 GC 发生的频率较低,主要处理所有对象。小 GC 比大 GC 更快,因为查看的对象更少。

-- Ruby 垃圾回收深度剖析:分代垃圾回收 | Jemma Issroff


3️⃣ V8 的垃圾回收:


V8 使用分代垃圾收集器,将 Javascript 堆分成小的年轻代和大的老年代,前者用于分配新对象,后者用于长期存活的对象。由于大多数对象生命周期较短,因此这种分代策略使垃圾收集器可以在较小的年轻代中执行定期、短暂的垃圾收集(称为清扫),而无需跟踪老年代中的对象。年轻代使用半空间(复制)分配策略,新对象最初分配到年轻代的活跃半空间中。一旦半空间满了,清扫操作就会把活跃对象移到另一个半空间。已经移动过一次的对象会被提升到老年代,并被视为长寿对象。一旦活跃对象被移动,就会激活新的半空间,旧半空间中剩余的死对象就会被丢弃。老年垃圾收集器使用标记-扫描收集器(分多个小步骤增量标记活跃对象),并进行了多项优化,以改善延迟和内存消耗。

-- 免费获取垃圾回收 - V8


4️⃣ Java 序列收集器:


串行 GC 是最简单的 Java GC 算法,是单核 32 位机器上的默认收集器。[...]串行收集器是一种分代垃圾收集器,年轻代使用疏散收集器(也称为标记复制收集器),老年代使用标记-清扫-压缩(MSC)收集器。年轻代的收集器称为 Serial,而老年代的收集器称为 Serial(MSC)。不过,对于大多数现代服务端应用来说,串行收集器并不实用。[...]并行收集器是具有两个或两个以上 CPU 的 64 位机器上的默认垃圾收集器(最高到 Java 8)。与串行收集器类似,都是分代收集器,但使用多线程来执行垃圾收集。并行收集器使用多个线程同时收集年轻代和老年代。并行收集器使用名为 ParallelScavenge 的标记复制收集器收集年轻代,使用名为 ParallelOld 的标记-清扫-压缩收集器收集老年代,这与串行收集器类似,主要区别在于并行收集器中使用了多个线程。

-- 了解 JVM 垃圾收集 - 第 6 部分(串行和并行收集器)


年轻代由伊甸园(eden)和两个幸存者空间(survivor spaces)组成。大多数对象最初都分配在 eden 中。其中一个幸存者空间在任何时候都是空的,是 eden 中所有活跃对象的目的地,另一个幸存者空间则是下一次复制集合的目的地。对象以这种方式在活跃空间之间复制,直到年龄足够长(复制到老年代)。

-- 世代(oracle.com)



各种各样的算法和思考让我着迷。让我们在接下来的章节中进一步了解更现代、更复杂的 GC!

垃圾优先(G1,Garbage-first)

工作原理和算法

垃圾优先(G1)是 Sun Microsystems 推出的一种垃圾收集算法,用于 Java 虚拟机(JVM)。


是一个分代、分区、增量、并行、并发为主、stop-the-world 和疏散(压缩)的垃圾收集器。


🔵 分区垃圾收集器将堆划分为多个区域,每个区域大小相等:



🔵 每个区域都由不同部分组成:


  • 空间(Space):根据堆的最大大小,为每个区分配的空间从 1MB 到 32MB 不等。

  • 活跃(Alive):区域内仍然活跃的部分对象。

  • 垃圾(Garbage):区域内某些不再需要的对象,可归类为垃圾。

  • RSet(Remembered Set):一种元数据,可帮助跟踪哪些对象是有效的,哪些不再需要。该数据有助于 JVM 在任何给定时间内计算区域内有效对象的百分比(Liveness % = Live Size / Region Size)。



🔵 伊甸园(Eden)幸存者(Survivor)旧(Old) 区不需要像旧版垃圾收集器(逻辑区域集)那样连续:



🔵 伊甸园区("E")和幸存者区("S")属于年轻代。


🔵 应用程序总是专门在伊甸园区分配对象,但大对象(跨越多个区的对象)除外,这些对象直接分配给老年代。


🔵 G1 收集器在两个阶段之间交替运行:年轻(young-only)阶段和空间回收(space-reclamation)阶段。



1️⃣ Young-only GC 负责将对象从伊甸园提升到幸存者区,或将幸存者区提升到老年区。Young-only 事件被视为 STW 事件。


2️⃣ G1 收集器执行以下阶段,作为 Young-only GC 的一部分:


  • 初始标记(Initial Mark):启动标记过程,同时进行常规的只收集年轻对象的工作。并发标记会确定旧一代区域中的所有实时对象(不是 STW 事件)。

  • 标记(Remark):通过执行全局引用处理和类卸载,最终完成标记。回收完全清空的区域并清理内部数据结构(这是 STW 事件)。

  • 清理(Cleanup):确定是否需要进行空间回收混合收集(这是 STW 事件)。


3️⃣ 空间回收阶段涉及多个混合收集,不仅针对年轻代区域,还从选定的老年代区域回收活跃对象。


4️⃣ 当 G1 得出结论,进一步回收老年代区域不会产生大量空闲空间来证明所做努力的合理性时,空间回收阶段结束。


5️⃣ 空间回收后,收集周期重新开始,进入另一个 Young-only 阶段。


6️⃣ 如果应用程序在收集有效性信息时内存耗尽,G1 将执行就地停止的全堆压缩(Full GC)作为预防措施,与其他收集器类似。


🔵 根据我们所看到的,以下是使用 G1 的一些优缺点:


➕ 优点:


  • 可预测的暂停时间

  • 基于区域的收集

  • 自适应大小

  • 压缩

  • 软实时性能


➖ 缺点:


  • 初始标记暂停

  • 增加了 CPU 开销

  • 与其他收集器相比,成熟度较低

  • 堆碎片

  • 配置复杂


总之,垃圾优先(G1)具有可预测的暂停时间和有效的内存管理等显著优势。不过,并不是每个应用程序的理想选择,通常需要进行细致调整才能获得最佳性能。


垃圾优先(G1)收集器是一种服务器风格的垃圾收集器,适用于具有大内存的多处理器机器,能高概率的实现垃圾收集(GC)暂停时间目标,同时实现高吞吐量。

-- G1 垃圾收集器入门 (oracle.com)

真实用例

垃圾优先(G1)算法是为 Java 虚拟机(JVM)创建的,通常用于在 JVM 上运行的编程语言,主要是 Java。任何使用 JVM 垃圾收集功能的 Java 应用程序都有可能通过 G1 垃圾收集器得到改进。


值得注意的是,G1 并不局限于 Java 语言本身,而是 JVM 运行时环境。其他可在 JVM 上运行的语言,如 Kotlin、Scala、Groovy 和 Clojure,在 JVM 上运行时也可以使用 G1 垃圾收集器。


接下来,我们将深入探讨另一种现代垃圾回收器的工作原理:Z!

Z

工作原理和算法

Z 垃圾收集器(ZGC)是一种高性能的垃圾收集器,专门用于处理大型内存堆,如太字节级的内存堆。


ZGC 分为两类:非分代 ZGC分代 ZGC。非分代 ZGC 自 Java 15 开始在生产中使用,而分代 ZGC 是 Java 21 的一部分。


有一份关于在未来版本中废弃非分代 ZGC 的草案


弃用非分代 ZGC,以便在未来版本中将其删除。将分代 ZGC 改为默认 ZGC 模式,并废弃 ZGenerational 标志。

-- JEP 草案:废弃非世代 ZGC(openjdk.org)


ZGC 可同时管理几乎所有垃圾回收进程:



ZGC 运行周期包括 3 个阶段。每个阶段都以一个"安全点(safe-point)"同步点开始,包括暂停所有应用线程,又称 STW。除这 3 个阶段外,所有操作都与应用程序的其他部分同步进行:



停顿总是低于毫秒:




1️⃣ 第一阶段:周期开始时有一个同步暂停 (STW1),允许:


  • 线程确定要使用的正确"颜色"(彩色指针,colored pointers)。

  • 创建内存页(ZPages)。

  • 确保所有"GC 根"都有效(颜色正确),必要时进行更正(加载屏障,Load Barriers


Mark/Remap 的后续并发阶段包括遍历对象图,以确定候选收集对象。


2️⃣ 第二阶段:STW2 暂停标志着标记阶段的结束。并行处理可识别需要压缩的内存区域。


3️⃣ 第三阶段:在 STW3 中再次识别出正确的颜色后,同时移动对象以压缩内存,从而完成循环。


让我们深入了解一下各种关键词的细节:


✳️ ZPages:ZGC 将堆内存分割成称为 ZPage 的区域,分为小、中、大三种:


  • 小(2 MiB - 对象大小不超过 256 KiB)。

  • 中(32 MiB - 对象大小不超过 4MiB)。

  • 大(4 MiB 以上 - 对象大小 > 4 MiB)。


中小页可以容纳多个对象,而大页只能容纳一个对象。这种限制有助于防止大型对象的移动,因为移动大型对象需要复制大量内存,可能会导致严重的延迟。



✳️ 压缩和重定位:堆对象会不断压缩,以解决内存逐步碎片化的问题,并保证新对象的快速分配。


在生命周期中,可压缩页在第二阶段被识别(标记)(通常是对象最少的页),然后驻留在这些页上的所有对象在第三阶段被重定位。一旦页上没有任何对象,就可以回收其内存。



为了同时执行重定位操作,ZGC 维护路由表。这些表存储在堆外,并为快速读取进行了优化,但会增加内存成本。


✳️ 彩色指针:我们的目标是在指针中存储对象生命周期的相关信息,这是允许同时执行多种操作的关键。


4 比特专门用于存储元数据:



指针的"颜色"由标记 0 (M0)、标记 1 (M1) 和重映射 (R) 这三个元比特的状态决定:


  • M0 和 M1 用于标记要收集的对象。

  • 重映射表示引用已被重定位。


🚩 这三个比特中只有一个比特的值为 1。因此,得到了三种颜色:M0 (100)、M1 (010) 和 R (001)。


一种颜色要么是"好"的,要么是"坏"的,这是由生命周期阶段决定的:


  • 新实例化的对象会被标上正确的颜色。

  • ZGC 周期以短暂的 STW(STW 1)开始,在此期间,通过交替改变 M0 和 M1 位的值来确定正确的颜色。因此,如果在一个周期中 M0 是正确的颜色,那么在下一个周期中 M1 将是正确的颜色。

  • 在下一个并发阶段,即并发标记/重映射(Concurrent Mark/Remap)阶段,如果垃圾回收器遇到着色不正确的指针,会将指针更新为正确的地址,并分配适当的颜色。

  • 在周期的最后一个同步点(STW 3),R 为正确的颜色。


✳️ 堆多重映射(Heap Multi-Mapping):多内存映射允许多个虚拟地址指向同一个物理地址。因此,虚拟地址仅因元数据不同而不同的两个指针会指向相同的物理地址。


ZGC 需要这种技术,因为 ZGC 可以在应用程序运行时移动对象在堆内存中的物理位置。通过多重映射,对象的物理位置会映射到虚拟内存中的三个视图,分别对应指针的每种潜在"颜色":



这样,加载屏障就能识别自上一个同步点以来被重定位的对象。


✳️ 加载屏障(Load barriers):是 JIT 编译器在策略点注入的小段代码,特别是在从堆中加载对象引用时。



因为有了加载屏障,对象才可以随时移动,而不会更新指向它的指针。加载屏障会拦截对指针的读取,并对其进行纠正。这就确保了在 GC 和应用程序线程同时运行时,指针在任何时候被加载时都能指向正确的对象。


在计算机术语中,内存屏障(memory barrier)也称为内存围栏(membar)、内存栅栏(memory fence)或栅栏指令(fence instruction),是一种屏障指令,能使中央处理器(CPU)或编译器对在屏障指令前后发出的内存操作执行顺序约束。通常意味着,在屏障指令之前发起的操作,能够保证在屏障指令之后的操作之前完成。

-- 内存屏障 - 维基百科

真实用例

Z 垃圾收集器(ZGC)主要用于在 Java 虚拟机(JVM)上运行的 Java 应用程序。因此,任何编译成 Java 字节码并在 JVM 上运行的编程语言都有可能使用 ZGC,这类语言中最主要的就是 Java 本身。


不过值得注意的是,除了 Java 之外,其他语言也可以在 JVM 上运行,包括 Kotlin、Scala、Groovy、Clojure 和 JRuby 等。只要这些语言利用 JVM 执行,就能受益于 ZGC 提供的功能和优化(如果进行了配置)。


总之,虽然 ZGC 由于与 JVM 集成而主要与 Java 有关,但也可用于其他基于 JVM 的语言。


让我们保持节奏,进入下一个算法!

Shenandoah

工作原理和算法

Shenandoah 主要由 Red Hat 开发,从 Java 12 开始作为 OpenJDK 的实验功能提供,得到了社区的积极开发和支持。


✔️ 虽然 Shenandoah 和 ZGC 都优先考虑通过并发垃圾收集来尽量减少暂停时间,但 Shenandoah 的设计是完全并发的,旨在完全避免 STW 暂停。ZGC 虽然采用了高度并发的方法,但在垃圾收集的某些阶段仍会出现短暂的 STW 停顿。


✔️ 虽然 Shenandoah 和 ZGC 都使用分段方法来管理堆,但分段粒度不同。Shenandoah 使用较大的区域,而 ZGC 使用较小的 ZPage。


✔️ Shenandoah 分几个阶段运行,包括初始标识、同步标识、同步驱逐(同步复制实时对象)和同步清理:



✔️ Shenandoah 主要采用了并发驱逐,而 ZGC 则综合利用了并发标记、驱逐(重定位活跃对象)和压缩(尽量减少碎片)等技术。


✔️ 虽然 Shenandoah 和 ZGC 都使用专门的指针技术来保持垃圾回收过程中的一致性,但采用了不同的机制--Shenandoah 使用 Brooks 指针,而 ZGC 使用 Colored 指针。



Shenandoah 会在对象布局中增加一个字,这就是间接指针,允许 Shenandoah 在不更新所有引用的情况下移动对象,也被称为 Brooks 指针。

-- JVM 中的实验性垃圾回收器 | Baeldung


✔️ Shenandoah 和 ZGC 都依靠加载屏障来确保垃圾回收过程中的内存一致性和正确性。加载屏障会拦截应用程序线程对对象引用的读取,确保观察到堆的一致视图,即使在并发垃圾回收操作的情况下也是如此。


💡 对那些优先考虑超低延迟和完全并发操作的用户来说,Shenandoah 是最佳选择,尤其是对于大规模堆内存和动态工作负载。相反,ZGC 则是经过验证、低延迟、兼容性强且易于配置的垃圾回收器的理想选择。

真实用例

Shenandoah 垃圾收集器(GC)主要用于在 Java 虚拟机(JVM)上运行的 Java 应用程序。因此,任何编译成 Java 字节代码并在 JVM 上运行的编程语言都有可能使用 Shenandoah GC,其中最主要的就是 Java 本身。


不过,值得注意的是,除了 Java 之外,其他语言也可以在 JVM 上运行,包括 Kotlin、Scala、Groovy、Clojure 和 JRuby 等。只要这些语言利用 JVM 执行,就能受益于 Shenandoah GC 提供的功能和优化(如果配置了这样的功能和优化)。


总之,虽然 Shenandoah GC 由于与 JVM 集成而主要与 Java 有关,但也可用于其他基于 JVM 的语言。


我们已经接近尾声,只需要介绍最后一个 GC,就可以进行比较了。开始吧!

Epsilon

工作原理和算法

Epsilon GC 是 Java 11 中引入的一种特殊垃圾收集器,是一项试验性功能。传统垃圾收集器通过识别和收集未使用的对象来回收内存,而 Epsilon GC 与之不同,它根本不执行任何垃圾收集。相反,它允许 Java 虚拟机(JVM)在不收集垃圾的情况下分配内存!


以下是有关 Epsilon GC 的一些要点:


✔️ 无垃圾回收:Epsilon GC 不执行任何垃圾回收。它根据需要分配内存,但从不回收。这使它适用于不需要考虑垃圾回收开销的情况,如短期应用程序或手动管理内存的应用程序。


✔️ 用于性能测试:Epsilon GC 主要用于性能测试和基准测试。通过消除垃圾回收的开销,开发人员可以完全专注于应用程序性能,而不受垃圾回收暂停的干扰。


✔️ 不适合生产环境:Epsilon GC 不适用于生产环境,在生产环境中,内存管理和垃圾回收对应用程序的稳定性和性能至关重要。它仅用于测试和实验目的。


⛔ Epsilon GC 通过完全消除垃圾回收开销,为性能测试和实验提供了独特的选择。不过,它并不打算用于生产,其适用性有限,具体取决于应用程序的具体要求。

真实用例

Epsilon GC 是专为 Java 虚拟机(JVM)设计的垃圾收集器,因此与 Java 之外的任何编程语言都没有直接关联。任何在支持 Epsilon GC 的 JVM 上运行的 Java 应用程序都有可能使用。


是时候总结一下了!

垃圾收集器算法比较

比较垃圾回收算法需要评估各种因素,如暂停时间、吞吐量、可扩展性、内存开销以及对特定应用场景的适用性。


1️⃣ 串行或单线程垃圾收集器:


  • 暂停时间:通常暂停时间较长,因为它会在垃圾回收期间停止所有应用线程。

  • 吞吐量:与并发垃圾收集器相比,吞吐量一般较低。

  • 可扩展性:堆大小较大或多线程应用时可能无法很好扩展。

  • 内存开销:与其他收集器相比,内存开销通常较低。

  • 适用性:适用于中小型应用或对暂停时间要求不高的应用。


2️⃣ 并行垃圾收集器:


  • 暂停时间:与串行收集器相比,暂停时间更短,因为使用多个线程进行垃圾收集。

  • 吞吐量:由于并行性,吞吐量比串行收集器高。

  • 可扩展性:使用多核处理器和更大的堆大小时,可扩展性更好。

  • 内存开销:由于增加了线程,内存开销通常比串行收集器高。

  • 适用性:适用于吞吐量重要但暂停时间不重要的中型应用。


3️⃣ Concurrent Mark-Sweep (CMS) 垃圾收集器:


  • 暂停时间:旨在通过与应用程序线程同时执行大部分垃圾收集工作,最大限度减少暂停时间。

  • 吞吐量:吞吐量适中,但可能存在碎片问题。

  • 可扩展性:在堆规模非常大或高度多线程的应用中可能无法很好的扩展。

  • 内存开销:内存开销适中

  • 适用性:适合堆的规模中等,以及对延迟要求敏感的应用。


4️⃣ 垃圾优先(G1)垃圾收集器:


  • 暂停时间:旨在通过将堆划分为多个区域,并以增量方式执行垃圾回收,从而提供低暂停时间行为。

  • 吞吐量对于大多数应用,尤其是具有大型堆的应用,吞吐量良好。

  • 可扩展性:可基于多核处理器和大型堆实现良好扩展。

  • 内存开销:由于基于区域的管理,内存开销适中。

  • 适用性:适用于暂停时间短、吞吐量大的大规模应用。


5️⃣ Z 垃圾收集器(ZGC):


  • 暂停时间:旨在提供超低延迟性能,暂停时间通常低于 10 毫秒,即使在大型堆上也是如此。

  • 吞吐量:为大多数应用提供良好的吞吐量,同时优先考虑低暂停时间。

  • 可扩展性:高度可扩展性,支持超大堆大小和多线程应用。

  • 内存开销:使用 ZPages 和其他技术,内存开销适中。

  • 适用性:适用于有严格延迟要求和大规模堆的应用。


6️⃣ Shenandoah 垃圾收集器:


  • 暂停时间:目标是通过并发垃圾收集实现超低延迟,即使在大型堆上也能最大限度减少暂停时间。

  • 吞吐量:提供良好的吞吐量,同时优先考虑低暂停时间。

  • 可扩展性:高度可扩展性,支持超大堆和多线程应用。

  • 内存开销:使用基于区域的管理,内存开销适中。

  • 适用性:适用于有严格延迟要求和大规模堆的应用程序,尤其是具有动态内存分配模式的应用程序。


总之,垃圾收集算法的选择取决于应用需求、堆大小、延迟敏感性和可用硬件资源等因素。必须仔细评估这些因素,为特定用例选择最合适的垃圾收集器。

结论

了解垃圾回收算法对于理解编程语言和运行时环境中如何进行内存管理至关重要,因为编程语言和运行时环境会自动执行这项任务。


这些算法在方法、优化和利弊方面各不相同,影响到暂停时间、吞吐量、内存开销和可扩展性等因素。


虽然垃圾回收提供了自动内存管理功能,并简化了开发人员分配和释放内存的过程,但也带来了权衡和挑战,在设计和实施软件系统时必须深思熟虑。


最后,将垃圾收集归类为"绿色技术"取决于多个因素,如其对资源利用、能源消耗、系统效率和整体环境可持续性的影响。虽然垃圾收集技术可以提高资源利用效率,并可能减少电子垃圾,但必须根据具体的使用情况和系统设置来评估其对环境的影响。


相信这次垃圾收集之旅一定会给你带来启发。




你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

发布于: 2024-04-15阅读数: 3
用户头像

俞凡

关注

公众号:DeepNoMind 2017-10-18 加入

俞凡,Mavenir Systems研发总监,关注高可用架构、高性能服务、5G、人工智能、区块链、DevOps、Agile等。公众号:DeepNoMind

评论

发布
暂无评论
深入探索垃圾收集_Java_俞凡_InfoQ写作社区