算法, 权衡和真实应用
简介
在 C 或 C++ 等传统编程语言中, 开发人员负责为对象和数据结构明确分配和取消分配内存.
然而, 手动内存管理容易出错, 导致内存泄漏(已分配的内存未释放)或悬空指针(指针引用的内存已被取消分配)等错误. 这些问题会导致软件不稳定或不安全.
GC就是通过自动化内存管理的过程来应对这些挑战的. GC不需要开发人员手动分配和取消内存, 而是自动识别和回收程序无法再访问或引用的内存.
GC算法的复杂程度和实现方式各不相同, 不同的编程语言和运行环境可能会使用不同的GC策略.
虽然GC具有自动内存管理和防止内存相关错误等显著的优势, 但它也会带来 CPU 和内存使用方面的开销.
本文旨在探讨不同的GC算法, 研究它们的内部工作原理, 优点和局限性.
这将有助于我们选择合适的算法和配置, 从而能够以更高的效率来写代码.
我希望这篇文章能为今后的研究提供有价值的参考.
打起精神, 我们开始这次冒险吧!
马上开始了哈!
A Few Months Later...
历史花絮
正在运行程序的内存
在运行程序的典型内存布局如下:
在程序运行期间, 静态内存用于存储全局变量或使用静态关键字声明的变量.
Stack/栈是计算机程序临时存放数据的指定内存区域. 它是一个连续的内存块, 在函数调用期间存储数据, 并在函数结束后清除数据.
Stack内存遵循后进先出/LIFO原则, 即最新放入Stack的项将最先被移除.
在函数调用时, 程序会生成一个称为Stack Frame/栈帧的新元素, 然后将其推入Stack, 用于特定的函数调用.
一个栈帧包括:
- 函数的局部变量.
- 传递给函数的参数.
- 返回地址, 用于指导程序在函数结束后在何处继续执行.
- 其他元素, 如前一帧的基指针.
函数执行完毕后, 其栈帧将被移除, 控制权将转回到框架内指示的返回地址.
请记住, 栈内存的容量是有限的, 一旦用完, 就会发生栈溢出, 导致程序失败. 因此, 栈并不适合存储大量数据.
此外, 栈一旦分配完毕, 就不允许调整内存块的大小. 例如, 如果我们在栈上为一个数组分配的内存太少, 它就不能像动态分配内存那样被调整大小.
这些因素导致了堆内存的发明.
堆/Heap是计算机内存中指定用于动态分配内存的区域. 与自动管理的Stack内存不同, 堆内存需要手动管理, 使用malloc
等方法分配内存, 使用free
等方法删除内存.
通过引用访问堆上的对象, 引用是指向这些对象的指针. 对象在堆空间中实例化, 而栈内存则保存对象的引用:
堆常用于以下情况:
- 数组或对象等数据结构所需的内存在运行前无法确定(动态分配).
- 数据保留时间必须超过单次函数调用的持续时间.
- 将来可能需要调整已分配内存的大小.
在接下来的步骤中, 重点将放在堆内存的管理上.
GC的出现
堆对象占用的内存可以手动显式地通过删除分配(使用 C 的 free
或 C++ 的 delete
等操作)来回收, 也可以自动由运行时系统通过引用计数或GC来回收.
手动内存管理会导致两种主要的编程错误:
🔴 第一种是过早释放内存, 而对内存的引用仍然存在, 这就是所谓的悬空指针.
🔴 第二种情况是, 程序可能无法释放不再需要的对象, 导致内存泄漏.
在并发编程中, 这些问题会变得更加复杂, 因为多个线程可能会同时引用一个对象.
因此, 有必要对各种想法进行评估和重组, 这就产生了自动内存管理.
更简单地说, 自动内存管理可以看作是手动内存管理的重构:
☑️ 内存管理集中在一个工具中, 即GC(Garbage Collector), 它由运行时控制(一般由虚拟机控制).
☑️ GC的内存管理方法简化了手动释放内存的过程, 使代码更易于阅读和维护.
☑️ 内存调试和剖析变得更加高效.
☑️ GC和自动内存管理算法的升级变得可扩展, 通用和高效.
GC有哪些秘密? 让我们一探究竟!
GC概述
GC是一个定期触发的后台程序, 它能自动释放不再使用的对象占用的内存, 从而使这些内存满足应用的未来需求.
GC 如何识别未使用的对象?
GC通过定位那些不再被运行程序的任何部分引用的对象来识别未使用的对象. 如果一个对象没有任何活动引用, 它就会被认为死了, 可以被回收, 从而释放内存.
堆不仅可以被局部变量引用, 还可以被全局变量和静态变量, 线程栈(被线程的执行栈引用的对象)和常量池(存储程序使用的常量值)引用.
任何在堆中持有对象但不在堆中的指针(引用)都可称为根. GC需要这些根来确定对象是否还活着. 如果一个对象可以通过任何一个根直接或临时到达, 那么它就不会被视为垃圾. 如果无法访问, 则视为垃圾, 需要回收.
内存管理系统由在堆中运行的Mutator和GC组成:
- Mutator是我们修改堆中对象的程序(应用).
- Mutator通过Allocator在堆上申请内存, 而Collector负责回收堆上的内存空间.
- 内存Allocator和GC协作管理程序堆的内存空间.
垃圾回收器的基本特征包括:
- 最少的整体执行时间(吞吐量).
- 最佳空间使用率(内存开销).
- 最少的暂停时间(尤其是实时任务).
- 提高Mutator的局部性
- 可扩展性.
上述特性受GC所用各种算法的影响, 这些算法在实现方式, 优化和权衡方面各不相同.
在接下来的章节中, 我们将研究这些不同的算法, 以确定它们的优势, 局限性和潜在的实际用途.
A Few Months Later...
那么咱们开始吧!
Mark-Sweep/标记-清除
运行原理和算法
Mark-Sweep - “naive”算法由John McCarthy 于 1960 年为 Lisp提出, 它基于“stop-the-world”机制. 当程序请求内存但没有可用内存时, 程序会被停止, 并执行一次完整的GC以释放空间.
Mark-Sweep GC 的运行可归纳为以下几个阶段:
1️⃣第一步是提取并准备根列表. 根可以是局部变量, 全局变量, 静态变量或线程栈中引用的变量.
2️⃣一旦确定了所有根, 我们就进入标记阶段. 标记阶段需要对根节点进行深度优先搜索(DFS)遍历, 目标是将根节点可达的所有节点标记为“活节点”.
许多GC算法在回收过程的核心采用跟踪例行程序, 标记从初始根节点集可达的每个对象. 跟踪例程通常执行图遍历, 采用标记Stack, 标记栈中包含一组已访问过但其子对象尚未扫描的对象. 跟踪程序重复地从标记Stack中弹出一个对象, 标记其子对象, 并将先前未标记的每个子对象插入标记栈中. 从标记栈中插入和移除对象的顺序决定了跟踪顺序. 最常见的跟踪顺序是后LIFO标记栈的DFS/深度优先搜索和作为一个FIFO队列工作的标记栈的BFS/广度优先搜索. - 数据结构感知GC
3️⃣未标记的节点是垃圾节点, 因此在清除阶段, GC会遍历所有对象并释放未标记的对象. 它还会重置已标记的对象, 为下一个循环做好准备.
基本的 Mark-Sweep 算法如下:
//Marking
Add 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;
}
}
}
//Sweeping
Free = 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 期间不会移动, 因此无需更新其指针. 不过, 它也有几个缺点:
- 由于在不扫描整个堆的情况下很难找到无法访问的对象, 因此其清除阶段的成本很高.
- 执行GC时必须停止程序的执行, 从而导致严重的性能问题.
- 堆中未使用内存的积累会导致内存碎片, 使其无法用于新对象.
显然, Mark-Sweep GC 不适合实时系统.
让我们看看这种算法是如何真正实现和使用的.
实际使用案例
该算法的“naive”版本可以在这些应用中找到:
1️⃣ uLisp:
uLisp 中使用的GC类型称为标记和清除. 首先, markobject() 标记所有仍可访问的对象. 然后, sweep() 从未标明的对象中建立一个新的自由列表, 并取消标记对象. - uLisp - GC
2️⃣ Ruby 的早期版本:
Ruby 的第一个版本已经使用标记和清除(M&S)算法了GC. M&S 是最简单的 GC 算法之一, 由两个阶段组成:(1) Mark:遍历所有存活对象并标记为“活对象”. (2) 清除:GC未标记的未使用对象.
虽然 M&S 算法简单且运行良好, 但也存在一些问题. 最重要的问题是“吞吐量”和“暂停时间”. GC 会因 GC 开销而减慢 Ruby 程序的运行速度. 换句话说, 低吞吐量会增加应用的总执行时间. 每次 GC 都会停止 Ruby 应用的运行. 停顿时间过长会影响交互式网络应用的UI/UE. Ruby 2.1 引入了分代GC, 以解决“吞吐量”问题. - Ruby 2.2中的增量 GC
此外, 还可以找到“naive”算法的改进版本:
1️⃣ 增量 Mark-Sweep 算法:
➖ Ruby 2.2:
增量 GC 算法将一个 GC 执行进程拆分为多个细粒度进程, 并将 GC 进程和 Ruby 进程交错在一起. 总的停顿时间是相同的(或稍长一些, 因为使用增量 GC 会产生开销), 但每个单独的停顿时间要短得多. 这使得性能更加稳定. - Ruby 2.2 中的增量GC|Heroku
➖ OCaml:
大堆(major heap)通常比小堆(minor heap)大很多, 可以扩展到千兆字节大小. 它通过标记和清除GC算法进行清理, 该算法分几个阶段运行[...] 标记和清除阶段在堆的切片上增量运行, 以避免长时间暂停应用, 并在每个切片之前进行快速小回收. 只有压缩阶段会一次性触及所有内存, 而且是相对罕见的操作. - 了解GC - 真实的 OCaml 世界
Mozilla SpiderMonkey:
SpiderMonkey 有一个标记清除GC, 具有增量标记模式, 分代回收和压缩功能. 大部分 GC 工作在辅助线程上执行. - GC
2️⃣ 并发标记清除(CMS)算法:
并发标记清除(CMS)GC是专为那些喜欢较短的GC暂停时间, 并且有能力在应用运行时与GC共享处理器资源的应用设计的. - 并发标记扫描(CMS) GC
并发标记清除GC(CMS)是 Oracle HotSpot JVM中的标记和清除GC, 自 1.4.1 版起可用. 它在第 9 版中被弃用, 在第 14 版中被移除, 因此从 Java 15 开始它就不再可用了. - 并发标记清除 GC
仅供参考, Java 已用 ZGC 和 Shenandoah GC取代了并发标记清除(CMS), 我们将在接下来的章节中看到.
复制算法
运行原理和算法
复制算法的原理是将堆分成两个大小相等的半空间: from-space 和 to-space :
1️⃣内存分配在from-space
中, 而to-space
则留空.
2️⃣ 当from-space
已满时, from-space
中所有可达对象都会被复制到to-space
, 并相应地更新指向它们的指针.
⛔ 在复制对象之前, 有必要验证该对象是否已被复制. 如果已经复制, 则不应再次复制该对象, 而应使用现有副本. 这种验证是通过在from-space
对象中放置一个转发指针/Forwarding pointer
来完成的.
3️⃣最后, 两个空间的角色互换, 程序继续运行.
✔️ 在复制算法中, 内存分配在from-space
中线性进行, 无需空闲列表或搜索空闲块. 只需用一个指针标记from-space
中已分配区域和空闲区域的边界即可.
✔️, 此外, 复制算法的分配速度非常快, 几乎和Stack分配一样快.
"naive"的算法会对可达图进行深度优先遍历, 在递归执行时可能会导致Stack溢出.
相比之下, Cheney的复制算法是一种高效的技术, 它对可达图进行广度优先遍历, 只需要一个指针作为附加状态:
✳️ 在任何广度优先遍历中, 都有必要跟踪已被访问但其子节点尚未被探索的节点集.
✳️ Cheney的算法从根本上说是使用to-space
来存储这组节点, 并用一个名为scan
的指针来表示.
✳️ 这个指针将to-space
分为两部分:一部分用于表示子节点已被访问过的节点, 另一部分用于表示子节点尚未被访问过的节点.
使用复制算法代替标记-清除的优点是消除外部碎片, 快速分配和避免遍历死对象.
Cheney的算法的一个主要特点是, 它从不接触任何需要释放的对象; 它只遵循从活对象的指针.
➖ 然而, 它的缺点包括需要消耗两倍的虚拟内存, 必须准确识别指针以及复制的潜在高成本.
我看到了这个实现Cheney算法的伪代码.
让我们看看这种算法到底是如何实现和使用的.
真实用例
1️⃣ Ocaml:
为了对小堆(minor heap)进行GC, OCaml 使用 复制算法将小堆中的所有活块移动到大堆(major heap)中. 这需要的工作量与小堆中的活块数量成正比, 根据分代假设, 小堆中的活块数量通常较少. 一般来说, GC在运行时会stop-the-world(即停止应用), 这就是为什么它必须快速完成, 以便让应用在最少中断的情况下继续运行. - 理解 GC - 真实世界的 OCaml
2️⃣ LISP:
Fenichel 和 Yochelson 描述了在使用虚拟内存的 LISP 系统中, 性能是如何随着时间的推移而下降的. 他们的解决方案 -- 复制GC, 经 Cheney进一步修改后, 在现代 LISP 系统中被广泛采用; 但由于需要扫描一个潜在的大型根集, 并且每次GC都要将计算中维护的所有结构从一个区域移到另一个区域, 因此其性能受到了限制. - 通用计算机上 LISP 系统的基于生命周期的GC
3️⃣ Chicken(Scheme 实现):
使用的是 C. J. Cheney 最初设计的复制算法, 它将所有活的连续操作和其他活的对象复制到堆中. - Chicken (Scheme 实现).
我们对复制算法领域的探索到此结束, 现在让我们大胆尝试一种新算法!
标记-压缩算法
运行原理和算法
我将从这段话开始:
标记-压缩算法可以看作是标记-清除算法和Cheney的复制算法的组合. 首先, 对可达对象进行标记, 然后通过压缩步骤将可达(标记)对象重定位到堆区域的起始位置. - 标记-压缩 算法
标记-压缩算法在运行过程中会经历不同的阶段:
1️⃣它从标记阶段开始, 在此阶段识别活数据.
2️⃣接下来, 通过重新定位对象并更新所有指向已移动对象的活引用的指针值, 对活数据进行压缩.
进行压缩有多种方法, 可以保留原始顺序, 也可以忽略原始顺序. 以下是不同的方法:
1️⃣ 乱序: 不保留逻辑或空间顺序.
2️⃣ 线性化: 对象被移动, 然后根据逻辑关系排序, 即相互指向的对象被移动到相邻位置.
3️⃣ 滑动: 保持原来的分配顺序.
让我们来看看这种算法是如何真正实现和使用的.
实际使用案例
有五种著名的压缩算法:
- 双指算法(桑德斯 - 1974:由于性能问题, 实际中并不使用).
- Lisp 2 算法.
- Jonkers 的线程算法 (1979).
- SUN 的并行算法(Flood-Detlefs-Shavit-Zhang - 2001).
- IBM 的并行算法(Abuaiadh-Ossia-Petrank-Silbershtein - 2004).
- 压缩机(Kermany-Petrank - 2006).
算法可分为以下几类:
- 单处理器编译包括双指编译, Lisp2 和 Jonkers 的线程编译.
- 并行编译包括 Sun 的压缩算法, IBM 的压缩算法和 压缩机/Compressor(并行, 并发, 延迟...).
下面的表格总结了单处理器压缩算法的特点:
该表比较了 Jonkers 的线程算法, 仅限于单线程的 IBM 并行压缩算法和完全并行的 IBM 并行压缩算法的性能(时间单位为毫秒):
即使仅限于单线程, IBM 的并行压缩算法仍能保持高效, 提供显著的速度提升和高质量的编译.
让我们继续学习更先进的算法!
分代
运行原理和算法
分代GC是指根据对象的年龄将其分成若干代, 并优先回收年轻代, 而不是老年代.
1️⃣年轻代是所有新对象分配和老化的地方.
2️⃣ 当年轻代填满时, 会导致小GC(minor GC). 充满死对象的年轻代会很快被回收.
🚩所有小GC都是“stop-the-world”事件.
3️⃣根据晋升策略, 一些幸存的对象被晋升到老年代.
4️⃣ 老年代用于存储存活时间较长的对象. 通常, 会为年轻代对象设置一个阈值, 当达到该年龄时, 该对象就会被移到老年代.
5️⃣当老年代内存满时, 会执行一次大GC, 以回收该代和所有年轻代的内存.
6️⃣此外, 还为年轻代对象设置了一个阈值, 当达到该阈值(即对象被复制的次数)时, 对象就会被移到老年代.
🚩 主要的GC也是“stop-the-world”事件.
✔️ 大多数分代GC通过复制 来管理年轻代的: 原始复制回收器, 并行复制回收器或并行清除回收器.
✔️ 可以通过Mark-Sweep算法, 并发回收器或增量回收器来管理老年代.
让我们回顾一下我们所看到的优缺点:
分代 GC 有减少 GC 暂停时间的趋势, 因为大部分时间只回收最年轻的一代, 也是最小的一代.
在复制算法时, 分代的使用也避免了重复复制长寿命对象.
➖由于在这种方案中, 活对象可以处于不同的代空间中, 因此会出现代际间指针(例如, 从年轻代指向老年代的指针)的问题.
➖由于老年代的回收频率不如年轻代的高, 因此死的老对象有可能阻止死的年轻对象的回收. 这个问题叫做裙带关系.
让我们来看看这种算法是如何真正实现和使用的.
真实使用案例
1️⃣ Python:
标准 CPython 的GC有两个部分, 一个是引用计数回收器, 另一个是分代GC, 即 gc 模块. - Python中的 GC: 你应该知道的事件
为了限制每次GC所需的时间, 默认构建的 GC 实现使用了一种流行的优化方法: 分代. 这个概念背后的主要思想是假设大多数对象的生命周期都很短, 因此可以在创建后很快被回收. 事实证明, 这与许多 Python 程序的实际情况非常接近, 因为许多临时对象的创建和销毁都非常快. 为了利用这一事实, 所有容器对象都被隔离成三个空间/代. 每个新对象都从第一代(第 0 代)开始. 前一种算法只对某一代的对象执行, 如果一个对象在其一代的回收过程中存活下来, 它将被转移到下一代(第 1 代), 在那里, 它被调查回收的次数会减少. 如果同一对象在新的一代(第 1 代)的另一轮 GC 中存活下来, 它将被转移到下下一代(第 2 代), 在那里它被回收的次数最少. - GC设计
2️⃣ Ruby:
回到问题的核心:从 Ruby 2.1 开始, Ruby 引入了分代 GC, 它利用了分代假设. 它将更频繁的GC工作集中在年轻, 较新的对象上. Ruby 的GC实际上有两种不同的GC类型: 大GC 和 小GC. 小 GC 发生得更频繁, 主要针对年轻的对象. (大 GC 发生的频率较低, 而且会关注所有对象. 小 GC 比大 GC 更快, 因为它们查看的对象更少. - Ruby GC深度挖掘: 分代GC
3️⃣ V8 的GC:
V8 使用分代GC, 将 Javascript 堆分成小的年轻代和大的老年代, 年轻代用于新分配的对象, 老年代用于长期存活的对象. 由于大多数对象都是早死的, 这种分代策略使GC能够在较小的年轻代中执行定期, 短暂的GC(称为清除), 而无需跟踪老年代中的对象. 年轻代使用半空间(复制)分配策略, 新对象最初分配到年轻代的活的半空间中. 一旦半空间满了, 清除操作就会把活的对象移到另一个半空间. 已经移动过一次的对象会被提升到老年代, 并被视为长活对象. 一旦移动了活对象, 新的半空间就会激活, 旧半空间中剩余的死对象就会被丢弃. 老年代GC使用标记-清除回收器(分多小步增量标记活对象), 并进行了多项优化, 以改善延迟和内存消耗. - 免费获取GC - V8
4️⃣ Java 串行回收器:
串行 GC 是最简单的 Java GC 算法. 它是单核 32 位机器上的默认回收器. [...] 串行回收器是一种分代GC, 年轻代使用疏散回收器(也称为标记和复制回收器), 老年代使用标记-清扫-压缩(MSC)回收器. 年轻代的回收器称为串行, 而老年代的回收器称为串行(MSC). 不过, 对于大多数现代服务器端应用来说, 串行回收器并不实用. [...] 并行回收器是具有两个或两个以上 CPU 的 64 位机器上的默认GC(最多支持 Java 8). 它与串行回收器类似, 都是分代回收器, 但它使用多个线程来执行GC. 并行回收器使用多个线程同时回收年轻代和老年代. 并行回收器使用名为 ParallelScavenge 的标记和复制回收器来回收年轻代, 使用名为 ParallelOld 的标记-清扫-压缩回收器来回收老年代, 这与串行回收器类似. 不过, 主要区别在于并行回收器使用了多个线程. - 了解 JVM GC--第 6 部分(串行和并行回收器)
年轻代由Eden和两个Survivor空间组成. 大多数对象最初都分配在 eden 中. 其中一个Survivor空间在任何时候都是空的, 它是 eden 中任何存活对象的目的地; 另一个Survivor空间则是下一次复制回收时的目的地. 对象在Survivor空间之间以这种方式复制, 直到它们的年龄足够长(复制到终生代). - 分代
各种各样的算法和思考让我着迷. 接下来, 我们将深入探讨更现代, 更复杂的 GC!
垃圾优先 (G1)
运行原理和算法
垃圾优先(G1) 是 Sun Microsystems 推出的一种GC算法, 用于 JVM.
它是一种分代, 区域化, 增量, 并行, 多数并发, "stop-the-world"和压缩的GC.
🔵 区域化GC表示堆被分为多个区域, 每个区域的大小相等:
每个区域都由不同的部分组成:
- 空间: 为每个区域分配的空间从 1MB 到 32MB 不等, 具体取决于最大堆大小.
- 存活: 区域内的部分对象仍在使用中.
- 垃圾: 区域中的部分对象不再需要, 可归类为垃圾.
- RSet: 记忆集(Remembered Set)是一种元数据, 有助于跟踪哪些对象还活着, 哪些不再需要. 该数据有助于 JVM 在任何给定时间内计算区域内有效对象的百分比(有效百分比 = 有效大小 / 区域大小).
🔵Eden, Survivor 和 Old区 并不像旧版GC(区域逻辑集)那样要求连续:
🔵 Eden区('E')和Survivor区('S')属于年轻代.
🔵 应用总是专门在Eden区为年轻代分配对象, 但大/humongous('H')对象(跨越多个区域的对象)除外, 这些对象直接分配为属于老年代的对象.
🔵G1回收器交替使用两个阶段: 纯年轻阶段和空间改造/space-reclamation阶段.
1️⃣ 纯年轻 GC 负责将对象从 Eden 区域提升到 Survivor 区域, 或将 Survivor 区域提升到老年代区域. 只针对年轻代的事件被视为stop-the-world(即 STW)事件.
2️⃣ G1 回收器执行以下阶段, 作为纯年轻 GC 的一部分:
- 初始标记: 与常规的只对年轻对象进行回收的同时启动标记过程. 并发标记会确定老年代区域中的所有活对象(这不是一个stop-the-world事件).
- 标记: 通过执行全局引用处理和类卸载最终完成标记. 收回完全为空的区域并清理内部数据结构(这是一个stop-the-world事件).
- 清除: 确定是否需要进行空间回收混合回收(这是一个stop-the-world事件).
3️⃣ 空间改造阶段涉及多个混合回收, 不仅以年轻代区域为目标, 还从选定的老年代区域疏散活物体.
4️⃣ 当 G1 得出结论, 认为进一步疏散老年代区域不会产生大量空闲空间来证明所做努力的合理性时, 空间开垦阶段结束.
5️⃣ 空间改造之后, 回收周期重新开始, 进入另一个纯年轻阶段.
6️⃣ 如果应用在回收有效性信息时内存耗尽, G1 会执行一次就地停止的完全垃圾回收(Full GC), 作为预防措施, 这与其他回收器类似.
🔵 根据我们所看到的, 以下是使用 G1 的一些优缺点:
优势:
- 可预测的暂停时间.
- 基于区域的回收.
- 自适应大小.
- 压缩
- 软实时性能.
➖ 缺点:
- 初始标记暂停.
- CPU 开销增加.
- 与其他回收器相比成熟度较低.
- 堆碎片.
- 配置复杂.
总之, 垃圾优先(G1)具有可预测的暂停时间和有效的内存管理等显著优势. 不过, 它并不是每个应用的理想选择, 通常需要进行细致的调整才能获得最佳性能.
垃圾优先(G1)回收器是一种服务器风格的GC, 适用于具有大内存的多处理器机器. 它能以很高的概率实现GC暂停时间目标, 同时达到很高的吞吐量. - G1 GC入门
实际使用案例
垃圾优先(G1)算法是为 JVM 创建的, 通常用于在 JVM 上运行的编程语言, 主要是 Java. 任何使用 JVM GC 功能的 Java 应用都有可能通过 G1 GC得到改进.
值得注意的是, G1 并不局限于 Java 语言本身, 而是 JVM 运行时环境. 其他可以在 JVM 上运行的语言, 如 Kotlin, Scala, Groovy 和 Clojure, 在 JVM 上运行时也可以使用 G1 GC.
接下来, 我们将深入研究另一种现代GC的工作原理: 继续!
Z
运行原理和算法
ZGC是一种高性能的GC, 专门设计用于处理大型内存堆, 如 TB 级内存堆.
ZGC 有两类: 非分代 ZGC和分代 ZGC. 非分代 ZGC 从 Java 15 开始在生产中使用, 而分代 ZGC 是 Java 21 的一部分.
我还找到了关于在未来版本中废弃非分代 ZGC 的 草案:
废弃非分代 ZGC, 并打算在未来的版本中删除它. 将分代 ZGC 改为默认 ZGC 模式, 并废弃
ZGenerational
标志. - JEP 草案: 废弃非分代 ZGC
ZGC 并发管理几乎所有GC进程:
ZGC 运行周期包括 3 个阶段. 每个阶段都以一个“安全点”同步点开始, 该同步点涉及暂停所有应用线程, 又称 STW “Stop-The-World”. 除这 3 个点外, 所有操作都与应用的其他部分同步进行:
这些停顿总是低于毫秒级:
1️⃣ 第 1 阶段: 该周期以同步暂停 (STW1) 开始, 同步暂停允许线程识别正确的“着色”:
- 线程确定要使用的正确“颜色”(着色指针).
- 创建内存页(ZPages).
- 确保所有“GC 根”都是有效的(具有正确的颜色), 必要时进行纠正(加载屏障(Load barrier)).
Mark/Remap 的后续并发阶段包括遍历对象图, 以确定候选回收对象.
2️⃣ 第二阶段: STW2暂停 标志着标记阶段的结束. 并行处理可以识别需要压缩的内存区域.
3️⃣ 第三阶段: 在 STW3 中再次识别出正确的颜色后, 同时移动对象以压缩内存, 从而完成循环.
让我们深入了解之前遇到的各种关键字的细节:
✳️ ZPages: ZGC 将堆内存分割成称为 ZPage 的区域, 分为小, 中, 大三种:
- 小型(2MB -- 对象大小不超过 256 KiB).
- 中型(32MB -- 对象大小不超过 4MB).
- 大型(4MB以上 -- 对象大小大于 4MB).
小型和中型页面可以容纳多个对象, 而大型页面只能容纳一个对象. 这一限制有助于防止移动大型对象, 因为移动大型对象需要复制大量内存范围, 可能会导致严重的延迟.
✳️ 压缩和重定位: 堆对象会不断压缩, 以解决内存逐步碎片化的问题, 并保证快速分配新对象.
在此生命周期中, 可压缩页面在第二阶段被识别(标记)(通常是对象最少的页面), 然后驻留在这些页面上的所有对象在第三阶段被重新定位. 一旦页面上没有任何对象, 就可以回收其内存.
为了同时执行这些重定位操作, ZGC 维护了路由表. 这些表存储在堆外, 并为快速读取进行了优化, 但代价是额外的内存成本.
✳️ 着色指针: 目标是在指针中存储对象生命周期的相关信息. 这是允许同时执行多种操作的关键因素.
有 4比特 专门用于存储这些元数据:
指针的“颜色”由 Marked0 (M0) , Marked1 (M1) 和 Remapped (R) 这 3 个元比特的状态决定:
- M0 和 M1 用于标记要回收的对象.
- R 表示引用已被重新定位.
这三个位中只有一个位的值为 1. 因此, 得到了三种颜色: M0 (100), M1 (010) 和 R (001).
一种颜色要么是“好”的, 要么是“坏”的, 这在生命周期的几个阶段全局确定:
- 新实例化的对象会被标上正确的颜色.
- ZGC 周期以短暂的“stop-the-world”暂停(STW 1)开始, 在此期间, 通过交替改变 M0 和 M1 位的值来确定正确的颜色. 因此, 如果在一个周期中 M0 是正确的颜色, 那么在下一个周期中 M1 将是正确的颜色.
- 在下一个并发阶段, 即并发标记/重映射 阶段, 如果GC遇到颜色不正确的指针, 它会将指针更新为正确的地址, 并分配适当的颜色.
- 在周期的最后一个同步点(STW 3), R 变成了正确的颜色.
✳️ 堆多映射: 多内存映射允许多个虚拟地址指向同一个物理地址. 因此, 虚拟地址仅因元数据不同而不同的两个指针可以指向相同的物理地址.
ZGC 需要这种技术, 因为 ZGC 可以在应用运行时移动堆内存中对象的物理位置. 通过多映射, 对象的物理位置会映射到虚拟内存中的三个视图, 分别对应指针的每种潜在“颜色”:
这样, 加载屏障(Load barrier)就能识别自上次同步点以来被重新定位的对象.
✳️ 加载屏障(Load barrier): 是 JIT 编译器在策略点注入的小段代码, 特别是在从堆中加载对象引用时.
因此, 有了加载屏障(Load barrier), 对象可以随时移动, 而不会更新引用它的指针. 加载屏障会拦截对指针的读取, 并对其进行纠正. 这就确保了在 GC 和应用线程同时运行时, 指针在任何时候被加载时都能指向正确的对象.
在计算中, 内存屏障(memory barrier)也称为 membar, 内存屏障或屏障指令, 是一种屏障指令, 可使中央处理器(CPU)或编译器对在屏障指令之前和之后发出的内存操作执行排序约束. 这通常意味着, 在屏障指令之前执行的操作保证在屏障指令之后执行的操作之前执行. - 内存屏障
实际使用案例
ZGC主要用于在 JVM 上运行的 Java 应用. 因此, 任何编译成 Java 字节码并在 JVM 上运行的编程语言都有可能使用 ZGC. 这类语言中最突出的就是 Java 本身.
不过, 值得注意的是, 除了 Java 之外, 其他语言也可以在 JVM 上运行, 包括 Kotlin, Scala, Groovy, Clojure 和 JRuby 等. 只要这些语言利用 JVM 执行, 它们就能受益于 ZGC 提供的功能和优化(如果进行了配置).
总之, 虽然 ZGC 由于与 JVM 的集成而主要与 Java 有关, 但它也可用于其他基于 JVM 的语言.
让我们保持节奏, 继续下一个算法!
Shenandoah/仙纳度GC
运行原理和算法
Shenandoah主要由 Red Hat 开发, 从 Java 12 开始作为 OpenJDK 的实验功能提供. 它得到了社区的积极开发和支持.
✔️ 虽然 Shenandoah 和 ZGC 都优先考虑通过并发GC来尽量减少暂停时间, 但 Shenandoah 的设计是完全并发的, 旨在完全避免STW暂停. ZGC 虽然采用了高度并发的方法, 但在GC的某些阶段可能仍会出现短暂的 STW 暂停.
✔️ 虽然 Shenandoah 和 ZGC 都采用分段方式管理堆, 但它们的分段粒度不同. Shenandoah 使用较大的区域, 而 ZGC 使用较小的 ZPage.
✔️ Shenandoah 分几个阶段运行, 包括初始标记, 并发标记, 并发疏散(并发复制活对象)和并发清理:
✔️ Shenandoah 主要采用并发疏散方法, 而 ZGC 则综合利用了并发标记, 疏散(重定位活对象)和压缩(尽量减少碎片)等技术.
✔️ 虽然 Shenandoah 和 ZGC 都使用专门的指针技术来保持GC过程中的一致性, 但它们采用了不同的机制--Shenandoah 使用溪边/Brooks指针, 而 ZGC 使用着色指针.
Shenandoah 在此对象布局中增加了一个字. 这就是间接指针, 它允许 Shenandoah 在不更新所有引用的情况下移动对象. 这也称为溪边/Brooks指针. - JVM中的实验性GC
✔️ Shenandoah 和 ZGC 都依靠加载屏障来确保GC过程中的内存一致性和正确性. 加载屏障会拦截应用线程对对象引用的读取, 并确保它们观察到堆的一致视图, 即使存在并发的GC操作也是如此.
对于优先考虑超低延迟和完全并发操作的用户来说, 特别是对于大堆规模和动态工作加载来说, Shenandoah 是最佳选择. 相反, ZGC 则是经过验证的, 低延迟的, 兼容性强且易于配置的GC的理想选择.
实际使用案例
Shenandoah GC 主要用于在 JVM上运行的 Java 应用. 因此, 任何编译成 Java 字节代码并在 JVM 上运行的编程语言都有可能使用 Shenandoah GC. 这类语言中最突出的就是 Java 本身.
不过, 值得注意的是, 除了 Java 之外, 其他语言也可以在 JVM 上运行, 包括 Kotlin, Scala, Groovy, Clojure 和 JRuby 等. 只要这些语言利用 JVM 执行, 它们就能受益于 Shenandoah GC 提供的功能和优化(如果配置为这样做).
总之, 虽然 Shenandoah GC 因其与 JVM 的集成而主要与 Java 有关, 但它也可用于其他基于 JVM 的语言.
我们已经接近尾声; 我们只需要揭开最后一个 GC 的面纱, 然后就可以进行比较了. 咱们开始吧!
A Few Months Later...
Epsilon GC
运行原理和算法
Epsilon GC是 Java 11 中作为实验功能引入的一种特殊GC. 传统的GC通过识别和回收未使用的对象来回收内存, 而 Epsilon GC 与之不同, 它根本不执行任何GC. 相反, 它允许 JVM在不回收垃圾的情况下分配内存!
以下是有关 Epsilon GC 的一些要点:
✔️ 无GC: Epsilon GC 不执行任何GC. 它根据需要分配内存, 但从不回收. 这使它适用于不需要考虑GC开销的应用场景, 如短时应用或手动管理内存的应用.
✔️ 用于性能测试: Epsilon GC 主要用于性能测试和基准测试. 通过消除GC的开销, 开发人员可以完全专注于应用的性能, 而不受GC暂停的干扰.
✔️ 不适合生产环境: Epsilon GC 不适用于生产环境, 在生产环境中, 内存管理和GC对应用的稳定性和性能至关重要. 它仅用于测试和实验目的.
⛔ Epsilon GC 通过完全消除GC开销, 为性能测试和实验提供了独特的选择. 不过, 它并不打算用于生产, 其适用性可能有限, 具体取决于应用的具体要求.
实际使用案例
Epsilon GC 是专为 JVM设计的GC. 因此, 它与 Java 之外的任何编程语言都没有直接关联. 任何在支持 Epsilon GC 的 JVM 上运行的 Java 应用都有可能使用它.
是时候总结一下了! FINALLY!
比较GC算法
对GC算法进行比较需要评估各种因素, 如暂停时间, 吞吐量, 可扩展性, 内存开销以及对特定应用场景的适用性.
1️⃣ 串行或单线程GC:
- 暂停时间: 通常暂停时间较长, 因为它会在GC期间停止所有应用线程.
- 吞吐量: 与并发GC相比, 吞吐量通常较低.
- 可扩展性: 堆大小较大或多线程应用时可能无法很好地扩展.
- 内存开销: 与其他回收器相比, 内存开销通常较低.
- 适用性: 适用于中小型应用或暂停时间不重要的应用.
2️⃣ 并行GC:
- 暂停时间: 由于使用多个线程进行GC, 暂停时间比串行回收器更短.
- 吞吐量: 由于采用并行方式, 与串行回收器相比, 吞吐量更高.
- 可扩展性: 在使用多核处理器和更大的堆大小时, 可扩展性更好.
- 内存开销: 由于增加了线程, 内存开销通常高于串行回收器.
- 适用性: 适用于吞吐量重要但暂停时间不重要的中型应用.
3️⃣ 并行标记清除(CMS)GC:
- 暂停时间: 旨在通过与应用线程同时执行大部分GC工作, 尽量减少暂停时间.
- 吞吐量: 吞吐量适中, 但可能存在碎片问题.
- 可扩展性: 可能无法很好地扩展超大堆大小或高度多线程的应用.
- 内存开销: 内存开销适中.
- 适用性: 适合具有中等规模堆和对延迟要求敏感的应用.
4️⃣ 垃圾优先(G1)GC:
- 暂停时间: 旨在通过将堆划分为多个区域并以增量方式执行GC, 从而提供低暂停时间行为.
- 吞吐量: 对于大多数应用, 尤其是具有大型堆的应用, 吞吐量良好.
- 可扩展性: 对多核处理器和大型堆具有良好的扩展性.
- 内存开销: 由于基于区域的管理, 内存开销适中.
- 适用性: 适用于暂停时间短, 吞吐量大的大规模应用.
5️⃣ ZGC:
- 暂停时间: 专为提供超低延迟行为而设计, 暂停时间通常低于 10ms, 即使在大型堆上也是如此.
- 吞吐量: 为大多数应用提供良好的吞吐量, 同时优先考虑低暂停时间.
- 可扩展性:高度可扩展性, 支持超大堆大小和多线程应用.
- 内存开销: 通过使用 ZPages 和其他技术, 内存开销适中.
- 适用性: 适合有严格延迟要求和大堆规模的应用.
6️⃣ Shenandoah/仙纳度GC:
- 暂停时间: 旨在通过并发GC实现超低延迟行为, 即使在大型堆上也能最大限度地减少暂停时间.
- 吞吐量: 提供良好的吞吐量, 同时优先考虑低暂停时间.
- 可扩展性:支持超大堆大小和多线程应用, 具有高度可扩展性.
- 内存开销: 使用基于区域的管理, 内存开销适中.
- 适用性: 适用于有严格延迟要求和大堆规模的应用, 尤其是具有动态内存分配模式的应用.
总之, GC算法的选择取决于应用需求, 堆大小, 延迟敏感性和可用硬件资源等因素. 必须仔细评估这些因素, 为特定用例选择最合适的GC.
总结一下
了解GC算法对于理解编程语言和运行时环境中如何进行内存管理至关重要, 这些语言和运行时环境可自动执行内存管理任务.
这些算法在实现, 优化和权衡方面各不相同, 影响着暂停时间, 吞吐量, 内存开销和可扩展性等因素.
虽然GC提供了自动内存管理, 并简化了开发人员的内存分配和删除过程, 但它也带来了一些权衡和挑战, 在软件系统的设计和实施过程中必须深思熟虑.
最后, 将GC归类为“绿色技术”取决于多个因素, 例如它对资源使用, 能源消耗, 系统效率和整体环境可持续性的影响. 虽然GC技术可以提高资源利用效率, 并可能减少电子废物, 但必须根据具体的使用案例和系统设置来评估其对环境的影响.
相信这次GC之旅一定会给你带来启发.
一家之言, 欢迎拍砖!
Happy Coding! Stay GOLDEN!