本章旨在深入了解 HotSpot 最新垃圾收集器 Garbage First 垃圾收集器(简称为 G1 GC)的原理。第 1 章“Garbage First 概述”重点介绍了这些原理。
在叙述的各个阶段,将介绍【collection cycles】、内部结构【internal structures】和算法等概念,然后提供全面的详细信息。这样做的目的是记录细节,而不会让初学者感到负担过重。
为了从这一章中获得最大的收获,读者应该熟悉基本的垃圾收集概念和术语,以及垃圾收集在 Java HotSpot JVM 中的一般工作原理。这些概念和术语包括:分代垃圾收集【generational garbage collection】、Java 堆、年轻代空间、老年代空间、伊甸园空间、幸存者空间、并行垃圾收集、停止世界垃圾收集、并发垃圾收集、增量式垃圾收集【incremental garbage collection】、标记和压缩。第 1 章涵盖了其中一些概念和术语。。有关更多详细信息的一个很好的来源是 Java™ Performance 的第 3 章“JVM 概述”中的“HotSpot VM 垃圾收集器”部分。
Background
G1 GC 是 Java HotSpot 虚拟机的最新成员。它是一种基于垃圾优先收集原则【collecting the most garbage first】的压缩收集器,因此得名“Garbage First” GC。 G1 GC 具有增量【incremental】、并行【parallel】、停止世界暂停【stop-the-world 】功能,可通过复制实现压缩,还具有并行的、多阶段的并发标记【concurrent marking】,有助于将标记、重新标记和清理的暂停减少到最低限度。
随着 G1 GC 的推出,HotSpot JVM 从传统的堆布局(其中各代是连续的)转变为现在由不连续的堆区域组成的各代。因此,对于一个活跃的 Java 堆,一个特定的区域可能是伊甸区、幸存区或老年代的一部分,也可能是一个巨型区域,甚至只是一个空闲区域【free region】。多个这样的区域形成了一个“逻辑”代,与以前 HotSpot 垃圾收集器的分代空间思想形成的传统观点相匹配。
Garbage Collection in G1
G1 GC 在收集暂停【collection pause】期间回收其大部分堆区域。唯一的例外是多阶段并发标记周期【multistaged concurrent marking cycle】的清理阶段。在清理阶段,如果 G1 GC 遇到纯垃圾填充的区域,它可以立即回收这些区域并将其返还到空闲区域链表【 linked list of free regions】;因此,释放这些区域不必等待下一次垃圾收集暂停【garbage collection pause】。
G1 GC 有三种主要类型的垃圾收集周期【garbage collection cycles】:年轻代收集周期【young collection cycle】、多阶段并发标记周期【multistage concurrent marking cycle】和混合收集周期【mixed collection cycle】。还有一个单线程(在撰写本文时)回退暂停【fallback pause】,称为“full”垃圾收集暂停【“full” garbage collection pause】,这是 G1 GC 的故障安全机制,以防 GC 在 GC 时遇到疏散失败。
TIP 疏散失败也称为提升失败【promotion failure】或目标空间耗尽【to-space exhaustion 】甚至目标空间溢出【to-space overflow】。此类失败通常发生在没有更多可用空间来提升对象时。面对这样的场景,所有的 Java HotSpot VM GC 都会尝试扩展它们的堆大小。但是如果堆已经达到最大值,GC 会尝试保留那些已经成功复制对象的区域,并更新它们的引用。对于 G1 GC,无法复制的对象将被原地保留。然后,所有 GC 将自转发【self-forwarded】其引用。这些自转发的引用【self-forwarded references】将在垃圾收集周期结束时被删除。
在年轻区域收集期间,G1 GC 暂停应用程序线程,将活动对象从年轻代区域移动到幸存者区域,或将它们提升到老年代区域,或两者同时进行。对于混合收集,G1 GC 还将活动对象从最“高效【efficient】”(找不到更好的术语)的老年代区域移动到空闲区域,这些空闲区域将成为老年代的一部分。
TIP “GC效率”实际上是指要回收的空间与收集区域的估计GC成本的比率。由于缺乏更好的术语,在本书中,我们将堆中区域的排序称为计算“GC效率”的方法,以确定候选区域。使用相同术语的原因是,GC效率评估收集区域的好处与收集区域的成本。而我们这里所指的“效率”仅仅依赖于活跃度核算,因此只是收集一个区域的成本。例如,如果一个旧区域的收集时间比其他更昂贵的旧区域要短,那么它就被认为是一个“高效”区域。效率最高的区域将是区域排序数组中的第一个区域。
在收集周期【collection cycle】结束时,属于收集集【collection set】或 CSet 的区域(有关详细信息,请参阅本章后面的“收集集及其重要性”部分)保证是空闲的,并被返还到空闲区域列表。让我们更详细地讨论这些概念。
The Young Generation
G1 GC 是一种分代 GC,由年轻代和老年代组成。大多数分配(除了少数例外,例如对象太大而无法放入分配线程的本地分配缓冲区【也称为 TLAB】但小于“巨型”的对象,当然还有“巨型”对象本身(有关详细信息,请参阅“Humongous Regions”部分))都将落入特定线程的 TLAB 中。TLAB 可以实现更快的分配,因为拥有它的 Java 线程能够以无锁方式进行分配。这些 TLAB 来自 G1 中的区域【region】,而这些区域将成为年轻代的一部分。除非在命令行上明确指定,否则当前年轻代大小是根据初始和最大年轻代大小的界限来计算的(在JDK 8u45中,初始年轻代大小的默认值为总 Java 堆的 5% (-XX:G1NewSizePercent),最大年轻代大小为总 Java 堆的60% (-XX:G1MaxNewSizePercent),以及应用程序的暂停时间目标 (-XX:MaxGCPauseMillis)。
如果命令行中未设置 -XX:MaxGCPauseMillis,G1 GC 将选择默认值 200ms。如果用户设置 -Xmn 或相关的年轻代大小命令行选项(如 -XX:NewRatio),G1 GC 可能无法根据暂停时间目标调整年轻代大小,因此暂停时间目标可能成为无意义的选项。
根据 Java 应用程序的对象分配率,新的空闲区域会根据需要添加到年轻代,直到满足所需的代大小【generation size】。堆区域大小在 JVM 启动时确定。堆的区域【region】的大小必须是 2 的幂,范围可以从 1MB 到 32MB。 JVM 尝试生成大约 2048 个区域,并相应地设置堆区域大小(堆区域大小 = 堆大小 / 2048)。堆区域大小进行对齐并调整为落在 1MB 到 32MB 和 2 的次幂范围内。堆区域大小的自适应选择可以在命令行上通过设置 -XX:G1HeapRegionSize=n 来覆盖。第 3 章,“Garbage First Garbage Collector Performance Tuning”,包含有关何时覆盖 JVM 自动调整大小的更多信息。
A Young Collection Pause
年轻代由指定为伊甸区域的 G1 GC 区域和指定为幸存区域的 G1 GC 区域组成。当 JVM 无法在伊甸区域分配时,即伊甸区域被完全填满时,会触发一次年轻代收集。然后 GC 介入以释放一些空间。第一个年轻代收集会将所有活动对象从伊甸区域移动到幸存区域。这被称为“copy to survivor”。从那时起,任何年轻代收集都会将整个年轻代(即伊甸区域加上幸存区域)中的活动对象提升到新区域,这些区域现在成为新的幸存区域。年轻代收集还会偶尔将一些活动对象提升到老年代区域,只要它们存活的时间超过了预定的提升阈值。这称为“老化”活动对象。将存活对象从年轻代提升到老年代称为对象的“老龄化【tenuring】”,因此年龄阈值也被称为“老龄化阈值【tenuring threshold】”。将对象提升到幸存区域或老年代区域的过程,发生在提升 GC 线程的本地分配缓冲区(也称为提升实验室,或简称 PLAB)中。每个 GC 线程都有一个针对幸存区域和老生代的 PLAB。
每次年轻代收集暂停期间,G1 GC 都会根据执行当前收集所花费的总时间、记忆集【remembered sets】或 RSets 的大小 (在本章后面的“Remembered Sets and Their Importance”部分中对此进行了详细介绍)、当前、最大和最小年轻代容量,以及暂停时间目标,来计算当前年轻代大小需要执行的扩展或收缩量(即 G1 GC 时决定添加还是删除空闲区域)。因此,年轻代的大小会在收集暂停结束时发生调整。可以通过查看 -XX:+PrintGCDetails 的输出来观察和计算上一个和下一个年轻代的大小。让我们来看一个例子。(请注意,在本书中,输出的行已被换行以适合书页。)
这里年轻代的新大小可以通过将新的 Eden 大小与新的 Survivors 大小相加来计算(即 1043M + 13M = 1056M)。
Object Aging and the Old Generation
正如上一节“A Young Collection Pause”中简要介绍的那样,在每个年轻代收集期间,G1 GC 维护每个对象的年龄字段【age field】。任何特定对象在年轻代收集中存活下来的当前总数,被称为该对象的“年龄”。 GC 将年龄信息以及已提升到该年龄的对象的总大小保存在一个称为“年龄表【age table】”的表中。根据年龄表、幸存区大小、幸存区填充容量(由 -XX:TargetSurvivorRatio(默认 = 50)确定)和 -XX:MaxTenuringThreshold(默认值 = 15),JVM 会自适应地为所有幸存对象设置一个老龄化阈值,一旦对象超过了这个任期阈值,它们就会被提升/进入老年代区域。当这些老年代对象在老年代中死掉时,它们的空间可以通过混合收集【Mixed Collection】释放,或者在清理期间释放(但前提是整个区域都可以被回收),或者作为最后的手段,在 full GC 期间释放。
Humongous Regions
对于 G1 GC,收集的单位是一个区域【region】。因此,堆区域大小 (-XX:G1HeapRegionSize) 是一个重要参数,因为它决定了一个区域可以容纳多大大小的对象。堆区域大小还决定了哪些对象被称为“巨型对象”。 Humongous objects 是非常大的对象,它跨越 G1 GC 区域的 50% 甚至更多。这样的对象不遵循通常的快速分配路径,而是直接在标记为巨型区域的老年代中分配。
图 2.1 展示了一个连续的 Java 堆,其中标识了不同类型的 G1 区域:年轻代区域、老年代区域和巨型区域。在这里,我们可以看到年轻代和老年代区域中的每一个都跨越一个收集单元。另一方面,巨型区域跨越了两个收集单元,表明巨型区域是由连续的堆区域形成的。让我们更深入地了解三种不同的巨型区域。
Figure 2.1 G1 Java heap layout
在图 2.2 中,巨型对象1 跨越两个连续区域。第一个连续区域标记为“StartsHumongous”,连续相邻的区域标记为“ContinuesHumongous”。图 2.2 也说明了,巨型对象 2 跨越三个连续的堆区域,而 巨型对象 3 只跨越一个区域。
Figure 2.2 Differing humongous objects mapping to humongous regions
TIP 过去对这些巨型对象的研究表明,此类对象的分配很少,并且对象本身的寿命也很长。另一个要记住的要点是,巨型区域需要是连续的(如图 2.2 所示)。因此,移动它们是没有意义的,因为没有收益(没有空间回收),并且非常昂贵,因为大内存拷贝的开销并不小。因此,为了避免在年轻代垃圾收集期间复制这些巨型对象的开销,直接从老年代分配这些巨大对象被认为是更好的选择。但近年来,有许多事务类型的应用程序可能具有寿命不长的巨型对象。因此,正在做出各种努力来优化巨大对象的分配和回收。
如前所述,巨型对象遵循一个单独且相当复杂的分配路径。这意味着巨型的对象分配路径不会利用任何为分配或提升而优化的年轻代 TLAB 和 PLAB,因为将新分配的对象归零的成本将是巨大的,可能会抵消任何分配路径优化的收益。处理巨型对象的另一个显著区别是收集巨型区域。在 JDK 8u40 之前,如果任何巨型区域是完全空闲的,则只能在并发收集周期的清理暂停【cleanup pause】期间进行收集。为了优化短期巨型对象的收集,JDK 8u40 进行了一项值得注意的更改,如果确定巨型区域没有传入引用,它们可以在年轻代收集期间被回收,并返回到空闲区域列表。full GC 也将收集完全空闲的巨型区域。
TIP 这里需要强调一个重要的潜在问题或混淆。假设当前 G1 中区域的大小为 2MB。假设一个字节数组的长度对齐为 1MB。这个字节数组仍然会被认为是一个巨型对象,并且需要按此方式分配,因为 1MB 的数组长度不包括数组的对象头大小。
A Mixed Collection Pause
随着越来越多的对象被提升到老年代区域,或者当巨型对象被分配到巨型区域时,老年代的占用率以及 Java 堆的总占用率都会增加。为了避免耗尽堆空间,JVM 进程需要启动垃圾收集,不仅要覆盖年轻代中的区域,还要添加一些老年代区域。请参阅上一节关于巨型区域的内容,了解巨型对象的特殊处理(分配和回收)。
为了识别垃圾最多的老年代区域,G1 GC 启动一个并发标记周期【concurrent marking cycle】,这有助于标记根【Root】并最终识别出所有活动对象,并计算每个区域的活动因子【 liveness factor】。分配和提升的速率与触发此标记周期之间需要保持微妙的平衡,以使 JVM 进程不会耗尽 Java 堆空间。因此,在 JVM 进程开始时设置了占用阈值。在撰写本文时以及至少 JDK 8u45 之前,此占用阈值不是自适应的,可以通过命令行选项 -XX:InitiatingHeapOccupancyPercent(我非常喜欢将其称为 IHOP)来设置。
TIP 在 G1 中,IHOP 阈值默认为总 Java 堆的 45%。需要注意的是,这个堆占用百分比适用于整个 Java 堆,不像在 CMS GC 中使用的堆占用命令行选项,它只适用于老年代。在 G1 GC 中,没有物理上独立的老年代——只有一个单独的空闲区域池,可以分配为伊甸区、幸存区、老年区或巨型区。此外,分配的区域数量(例如伊甸区)可能会随时间而变化。因此,拥有老年代百分比并没有真正的意义。
TRIVIA 我曾经给 G1 GC 开发工程师和 G1 GC 用户写过关于标记阈值的电子邮件,并且总是必须将其完整地称为 InitiatingHeapOccupancyPercent,因为 CMS 和 G1 的标记阈值之间还有另一个区别——那就是 JVM 启动选项名称! CMS 的标记阈值称为 CMSInitiatingOccupancyFraction,如您所见,选项名称中没有“百分比”。所以为了避免任何混淆,我总是必须为 G1 指定完整的启动选项名称,很快我就为这个选项开发了一种喜爱的形式,并开始称它为 IHOP。
当老年代占用率达到(或超过)IHOP 阈值时,将启动并发标记周期。在标记结束时,G1 GC 计算每个老年代区域中的活动对象数量。此外,在清理阶段,G1 GC 根据老年代区域的“GC 效率”对它们进行排名。现在可以进行混合收集了!在混合收集暂停期间,G1 GC 不仅会收集年轻代中的所有区域,还会收集一些候选的老年代区域,以便回收垃圾最多的老生代区域。
TIP 比较 CMS 和 G1 日志时要记住的重要一点是,G1 中的多阶段并发周期比 CMS 中的多阶段并发周期具有更少的阶段。
一个单独的混合收集类似于年轻代收集暂停【young collection pause】,并通过复制实现对活动对象的压缩。唯一的区别是在混合收集期间,收集集【CSet】中还包含一些高效的老年代区域。根据几个参数(如本章后面讨论的那样),可能会有多个混合收集暂停。这称为“混合收集周期【mixed collection cycle】”。只有在超过 marking/IHOP 阈值并且完成并发标记周期之后,才会发生混合收集周期。
有两个重要参数可以帮助确定混合收集周期中混合收集的总数:-XX:G1MixedGCCountTarget 和 -XX:G1HeapWastePercent。
-XX:G1MixedGCCountTarget,默认为 8 (JDK 8u45),是混合 GC 计数目标选项【mixed GC count target option】,其目的是对标记周期完成后发生的混合收集数量设置物理限制。 G1 GC 将可供收集的候选老年代区域总数除以计数目标数,并将其设置为每次混合收集暂停【mixed collection pause.】要收集的最小老年代区域数。这可以表示为如下等式:
每个混合收集暂停的最小老年代 CSet = 为混合收集周期而识别出来的候选老年代区域总数 / G1MixedGCCountTarget。
-XX:G1HeapWastePercent,默认为 Java 总堆的 5%(JDK 8u45),是控制混合收集周期中要收集的老年代区域数量的重要参数。对于每个混合收集暂停,G1 GC 根据可回收的死对象空间识别可回收堆的数量。一旦 G1 GC 达到这个堆浪费阈值百分比,G1 GC 就会停止启动混合收集暂停,从而结束混合收集循环。设置堆浪费百分比基本上有助于限制您愿意浪费的堆数量,从而有效地加快混合收集周期。
因此,每个混合收集周期的混合收集数量可以通过每个混合收集暂停计数的最小老年代 CSet 和堆浪费百分比来控制。
Collection Sets and Their Importance
在任何垃圾回收暂停期间,CSet 中的所有区域都将被释放。 CSet 是一组在垃圾回收暂停期间以回收为目标的区域。这些候选区域中的所有活动对象将在收集期间被疏散,并且这些区域将返回到空闲区域列表中。在年轻收集期间,CSet 只能包含用于收集的年轻代区域。另一方面,混合收集不仅会添加所有年轻代区域,还会将一些老年代候选区域(基于它们的 GC 效率)添加到其 CSet。
有两个重要参数有助于为混合收集【Mixed GC】的 CSet 选择候选老年代区域:-XX:G1MixedGCLiveThresholdPercent 和 -XX:G1OldCSetRegionThresholdPercent。
-XX:G1MixedGCLiveThresholdPercent,默认为 G1 GC 区域的 85% (JDK 8u45),是一个活跃度阈值和设置限制,用于从混合收集的 CSet 中排除最昂贵的老年代区域。 G1 GC 设置了一个限制,使得任何低于此活跃度阈值的老年代区域都包含在混合集合的 CSet 中。
-XX:G1OldCSetRegionThresholdPercent,默认为总 Java 堆的 10% (JDK 8u45),设置每次混合收集暂停可以收集的老年代区域数量的最大限制。阈值取决于 JVM 进程可用的总 Java 堆,并表示为总 Java 堆的百分比。第 3 章涵盖了一些示例,以突出这些阈值的功能。
Remembered Sets and Their Importance
分代收集器根据这些对象的年龄来隔离堆中不同区域的对象。堆中的这些不同区域称为代【generations】。然后,分代收集器可以将其大部分收集工作集中在最近分配的对象上,因为它希望尽早发现它们中的大多数都死了。堆中的这些代可以独立收集。独立收集有助于降低响应时间,因为 GC 不必扫描整个堆,而且(例如,在复制分代收集器的情况下)不需要来回复制老年代中的长寿命对象,从而减少复制和引用更新的开销。
为了便于收集的独立性,许多垃圾收集器为他们的代维护了一个 RSet。 RSet 是一种数据结构,可帮助维护和跟踪对它自己的收集单元(在 G1 GC 的情况下指的是一个区域)的引用,因此无需扫描整个堆以获取此类信息。当 G1 GC 执行停止世界的收集(年轻代收集或混合收集)时,它会扫描其 CSet 中包含的区域的 RSet。一旦区域中的活动对象被移动,它们的传入引用就会更新。
使用 G1 GC,在任何年轻代或混合收集期间,年轻代总是被完整地收集,从而消除了跟踪驻留在年轻代对象包含对其他区域的引用的需要(简而言之,young-to-young 和 yound-to-old都是不需要要追踪的)。这减少了 RSet 开销。因此,G1 GC 只需要在以下两种情况下维护 RSet:
- old-to-young 引用——G1 GC 维护从老年代区域到年轻代区域的指针。年轻代区域被称为“own” RSet,因此该区域被称为 RSet “owning”的区域。
- old-to-old 引用——在这里,来自老年代不同区域的指针将被维护在“owning”老年代区域的 RSet 中。
在图 2.3 中,我们可以看到一个年轻代区域(区域 x)和两个老年代区域(区域 y 和区域 z)。区域 x 具有来自区域 z 的传入引用(简而言之,就是区域 z 中的某个对象持有区域 x 中某个对象的应用 )。此引用在区域 x 的 RSet 中注明。我们还观察到区域 z 有两个传入引用,一个来自区域 x,另一个来自区域 y。 区域 z 的 RSet 只需要记录来自区域 y 的传入引用,而不必记录来自区域 x 的引用,因为如前所述,年轻代总是被完全收集。最后,对于区域 y,我们看到来自区域 x 的传入引用,这在区域 y 的 RSet 中没有注明,因为区域 x 是一个年轻代区域。
Figure 2.3 Remembered sets with incoming object references
如图 2.3 所示,每个区域只有一个 RSet。根据应用程序的不同,一特定区域(以及它的 RSet)可能是比较“受欢迎的”,因此在同一区域甚至同一位置可能有许多更新。这在 Java 应用程序中并不少见。
G1 GC 有自己的方式来处理这种受欢迎的需求;它通过改变 RSet 的密度来实现。 RSets 的密度遵循三个级别的粒度,即稀疏、细和粗。对于一个受欢迎的区域,RSet 可能会被粗化以容纳来自其他各个区域的指针。这将反映在这些区域的 RSet 扫描时间中(有关 RSet 扫描时间的更多详细信息,请参阅第 3 章)。对于任何特定的 RSet ,三个粒度级别中的每一个都有一个per-region-table (PRT) 抽象外壳。
由于 G1 GC 区域在内部进一步划分为块【chunks】,在 G1 GC 的区域级别,可实现的最低粒度是一个 512 字节的堆块【heap chunk 】,称为“卡【card】”(参见图 2.4)。有一个全局的卡表维护着所有的卡。
Figure 2.4 Global card table with 512-byte cards
当指针引用 RSet 的所属区域【owning region】时,包含该指针的卡【card】会被记录 PRT 中。稀疏 PRT 【sparse PRT 】基本上是这些卡索引【card indices】的哈希表。这种简单的实现可缩短垃圾收集器的扫描时间。另一方面,细粒度 PRT 【fine-grained PRT 】和粗粒度的位图【coarse-grained bitmap】的处理方式不同。对于细粒度的 PRT,其开放哈希表【open hash table 】中的每个条目对应于一个区域(即持有对所属区域【owning region】引用的区域,更直白点,就是卡所属的区域),该区域内的卡索引存储在位图中。细粒度 PRT 有一个最大值限制,当超过它时,会在粗粒度位图中设置一个位(称为“粗粒度位”)。一旦设置了粗粒度位,细粒度 PRT 中的相应条目将被删除。粗粒度位图只是一个位图,其中每个区域对应一个位的,这样设置的位意味着相应的区域可能包含对所属区域【owning region】的引用。因此,必须扫描与设置位相关的整个区域,以找到引用。因此,粗化为粗粒度位图的记忆集【RSet】是垃圾收集器扫描速度最慢的。有关这方面的更多详细信息,请参见第 3 章。
在任何收集周期中,当扫描记忆集【RSet】和 PRT 中的卡时,G1 GC 将在全局卡表中标记相应的条目,以避免重新扫描该卡片。在收集周期结束时,这个卡表将被清空;这在 GC 输出(使用 -XX:+PrintGCDetails 打印)中显示为 Clear CT,其顺序仅次于 GC 线程完成的并行工作(即外部根扫描【external root scanning】、更新和扫描记忆集【pdating and scanning the remembered sets】、对象复制【object copying】、和终止协议【termination protocol】)。还有其他顺序活动,例如选择和释放 CSet 以及引用处理和入队。以下是使用 -XX:+UseG1GC -XX:PrintGCDetails -XX:PrintGCTimeStamps 在 JDK 8u45 下的示例输出。 RSet 和卡表活动被突出显示。第 3 章介绍了有关输出的更多详细信息。
Concurrent Refinement Threads and Barriers
先进的 RSet 结构以写屏障【write barriers】和并发“refinement”线程的形式有其自身的维护成本。
屏障是在托管运行时中执行某些语句时执行的本机代码片段。垃圾收集算法中屏障的使用已经很好地建立起来了,由于本机指令路径长度增加,因此与执行屏障代码相关的成本也是如此。
OpenJDK HotSpot 的 Parallel Old 和 CMS GC 使用写屏障,该屏障在 HotSpot JVM 执行对象引用写操作时执行:
该屏障更新卡表类型结构,以跟踪代际引用。在 minor 垃圾收集期间扫描卡表。写屏障算法基于 Urs Hölzle 的快速写屏障,它将屏障开销减少到编译代码中的两条额外指令。
G1 GC 采用了写前【pre-write】和写后【post-write】屏障。前者在实际应用程序发生分配之前执行,在并发标记部分详细介绍,而后者在分配之后执行,在这里进行进一步说明。
每当更新引用时,G1 GC 都会发出写屏障。例如,考虑这个伪代码中的更新:
此分配将触发屏障代码。由于屏障是在写入任何引用之后发出的,因此称为“写后”屏障。写屏障指令序列可能会变得非常昂贵,并且应用程序的吞吐量将与屏障代码的复杂度成正比下降;因此,G1 GC 完成了确定引用更新是否为跨区域更新所需的最少工作量,因为需要在 owning 区域的 RSet 中捕获跨区域的引用更新。对于 G1 GC,屏障代码包括一个在“Older-First Garbage Collection in Practice”中简要讨论的过滤技术,该技术涉及一个简单的检查,当更新位于同一区域时,该检查的计算结果为零。以下伪代码说明了 G1 GC 的写屏障:
每当发生跨区域更新时,G1 GC 都会将相应的卡放入一个称为“更新日志缓冲区【update log buffer】”或“脏卡队列【dirty card queue】”的缓冲区中。在我们的更新示例中,包含对象的卡被记录在更新日志缓冲区中。
TIP 并发 refinement 线程的唯一目的是专门用于通过扫描已填充日志缓冲区中记录的卡,然后更新这些区域的记忆集【RSet】来维护记忆集【RSet】。refinement 线程的最大数量由 –XX:G1ConcRefinementThreads 确定。从 JDK 8u45 开始,如果 -XX:G1ConcRefinementThreads 未在命令行中设置,则从人体工程学角度将其设置为与 -XX:ParallelGCThreads 相同。
一旦更新日志缓冲区【update log buffer】达到其容量,它就会被废弃,并分配一个新的日志缓冲区。然后在这个新的缓冲区中进行卡入队操作。废弃的缓冲区被放置在一个全局列表中。一旦 refinement 线程在全局列表中找到条目,它们就会开始并发地处理废弃的缓冲区。refinement 线程始终处于活动状态,尽管最初只有少数可用。 G1 GC 以分层方式处理并发 refinement 线程的部署,添加更多线程以跟上已填充的日志缓冲区的数量。激活阈值由以下标志设置:-XX:G1ConcRefinementGreenZone、-XX:G1ConcRefinementYellowZone 和 -XX:G1ConcRefinementRedZone。如果并发细化线程无法跟上已填充的缓冲区的数量,则需要 mutator 线程寻求帮助。此时,mutator 线程将停止工作,并帮助并发 refinement 线程完成已填充的日志缓冲区的处理。 GC 术语中的 Mutator 线程指的是 Java 应用程序线程。因此,当并发 refinement 线程无法跟上已填充的缓冲区的数量时,Java 应用程序将暂停,直到处理完已填充的日志缓冲区。因此,应采取措施避免出现这种情况。
TIP 一般而言,用户不需要手动调整三个配置中的任何一个。在极少数情况下,调优 –XX:G1ConcRefinementThreads 或 –XX:ParallelGCThreads 是有意义的。第 3 章详细解释了并发refinement 和 refinement 线程。
Concurrent Marking in G1 GC
随着 G1 GC 区域和每个区域的活跃度统计的引入,显然需要一种增量的、完整的并发标记算法。 Taiichi Yuasa 提出了一种用于增量式标记和扫描 GC 【incremental mark and sweep GC】的算法,其中他采用了“原始快照”(SATB)标记算法。
Yuasa 的 SATB 标记优化集中在 mark-sweep GC 的并发标记【concurrent marking】阶段。 SATB 标记算法非常适合 G1 GC 的区域化堆结构,并解决了对 HotSpot JVM 的 CMS GC 算法的主要问题——可能会出现长时间的重新标记暂停。
G1 GC 建立一个标记阈值【marking threshold】,表示为总 Java 堆的百分比,默认为 45%。该阈值可以在命令行使用 -XX:InitiatingHeapOccupancyPercent (IHOP) 选项设置,当超过此阈值时,将启动并发标记周期。标记任务被划分成多个块,这样大部分工作可以在 mutator 线程处于活动状态时并发完成。目标是在整个 Java 堆达到其最大容量之前完成对其的标记工作。
SATB 算法只是创建一个对象图,它是堆的逻辑“快照”。 SATB 标记保证在并发标记阶段开始时存在的所有垃圾对象都将被快照识别出来。在并发标记阶段分配的对象将被认为是活动的,但它们不会被跟踪,从而减少了标记开销。该技术保证在标记阶段开始时处于活动状态的所有活动对象都被标记和跟踪,并且在标记周期期间由并发 mutator 线程进行的任何新分配都被标记为活动对象,因此不会被收集。
标记数据结构仅包含两个位图:previous 和 next 。previous 位图保存最后一个完整的标记信息。当前标记周期创建并更新 next 位图。随着时间的推移,previous 标记信息变得越来越陈旧。最终,next 位图将在标记周期完成时替换 previous 位图。
对应 previous 位图和 next 位图,每个 G1 GC 堆区域有两个 top-at-mark-start(TAMS)字段,分别称为 previous TAMS(或 PTAMS)和 next TAMS(或 NTAMS)。 TAMS 字段可用于识别在标记周期中分配的对象。
在标记周期开始时,next TAMS(NTAMS) 字段设置为每个区域的当前顶部【current top of each region 】,如图 2.5 所示。自标记周期开始以来分配(或已死亡)的对象位于相应的 TAMS 值之上,并被认为是隐式活动的。 TAMS 下方的活动对象需要明确标记。让我们来看一个例子:在图 2.6 中,我们在并发标记期间看到一个这样的堆区域,其中显示了“Previous Bitmap”、“Next Bitmap”、“PTAMS”、“NTAMS”和“Top”。 PTAMS 和堆底部(图中表示为“Bottom”)之间的活动对象都被标记并保存在前面的位图中,如图 2.7 所示。 PTAMS 和堆区域顶部之间的所有对象都是隐式活动的(相对于前一个位图),如图 2.8 所示。这些包括在并发标记期间分配的对象,因此分配在 NTAMS 之上,并且相对于下一个位图隐含地存在,如图 2.10 所示。在备注暂停结束时,PTAMS 上方和 NTAMS 下方的所有活动对象都被完全标记,如图 2.9 所示。如前所述,在并发标记周期中分配的对象将被分配到 NTAMS 之上,并且相对于下一个位图被认为是隐式活动的(参见图 2.10)。
Figure 2.5 A heap region showing previous bitmap, next bitmap, PTAMS, and NTAMS during initial mark
Figure 2.6 A heap region showing previous bitmap, next bitmap, PTAMS, and NTAMS during concurrent marking
Figure 2.7 Step 1 with previous bitmap
Figure 2.8 Step 2 with previous bitmap
Figure 2.9 Step 1 with next bitmap
Figure 2.10 Step 2 with next bitmap
Stages of Concurrent Marking
标记任务块大多是同时执行的。在短暂的停顿期间完成了一些任务。现在让我们谈谈这些任务的重要性。
Initial Mark
在初始标记期间,mutator 线程会停止,以便于标记 Java 堆中 Root 可直接访问的所有对象(也称为根对象)。
由于 mutator 线程已停止,因此初始标记阶段是一个世界停止阶段。此外,由于年轻代收集也追踪根【root】并且是停止世界的,因此初始标记通常是紧跟在年轻代收集之后,这也称为“捎带”。在初始标记暂停期间,每个区域的 NTAMS 值设置为该区域当前的顶部【top】(参见图 2.5)。这是迭代执行的,直到堆的所有区域都被处理。
Root Region Scanning
为每个区域设置 TAMS 后,mutator 线程重新启动,G1 GC 现在与 mutator 线程并发工作。为了标记算法的正确性,需要扫描在初始标记年轻代收集时,复制到幸存区域的对象,并将其视为标记根【marking roots】。 G1 GC 因此开始扫描幸存区域。从幸存区域引用的任何对象都会被标记。因此,以这种方式扫描的幸存区域被称为“根区域【root regions】”。
根区域扫描阶段必须在下一次年轻代垃圾收集暂停之前完成,因为在扫描整个堆以查找活动对象之前,需要识别和标记从幸存区域引用的所有对象。
Concurrent Marking
并发标记阶段是并发且多线程的。用于设置要使用的并发线程数的命令行选项是 -XX:ConcGCThreads。默认情况下,G1 GC 将线程总数设置为并行 GC 线程的四分之一(-XX:ParallelGCThreads)。并行 GC 线程由 JVM 在 VM 启动时计算。并发线程一次扫描一个区域,并使用“手指”指针优化来声明该区域。这种“finger”指针优化类似于 CMS GC 的“finger”优化,可以自行进行研究。
如“RSets and Their Importance”部分所述,G1 GC 还采用了写前屏障来执行 SATB 并发标记算法所需的操作。当应用程序改变其对象图时,在标记开始时可访问的并且是快照的一部分的对象,可能会在它们被标记线程发现和跟踪之前被覆盖。因此,SATB 标记保证要求修改 mutator 线程在 SATB 日志队列/缓冲区【SATB log queue/buffer】中记录需要修改的指针的先前值。这被称为“concurrent marking/SATB pre-write barrier”,因为屏障代码在更新之前执行。写前屏障能够记录对象引用字段的先前值,以便并发标记可以通过其值被覆盖的对象进行标记。
赋值形式为 x.f := y 的写前屏障的伪代码如下:
marking_is_active 条件是对线程本地标志【thread-local flag】的简单检查,在初始标记【initial-mark】暂停期间,该线程本地标志【thread-local flag】在开始标记时设置为 true 。通过该条件来保护写前屏障代码,减少了在标记不激活时执行屏障代码的开销。由于该标志是线程本地的,它的值可能被加载多次,因此任何单独的检查很可能会在缓存中命中,从而进一步减少屏障的开销。
satb_enqueue() 首先尝试将先前的值放入线程本地缓冲区(称为 SATB 缓冲区)中。一个 SATB 缓冲区的初始大小是 256 个条目,每个应用程序线程都有一个 SATB 缓冲区。如果 SATB 缓冲区中没有空间放置 pre_val ,则调用JVM运行时【runtime】;线程当前的 SATB 缓冲区被停用,并放置到一个已填充 SATB 缓冲区的全局列表中,为线程分配一个新的 SATB 缓冲区,并记录pre_val。并发标记线程【concurrent marking threads】的工作是定期检查和处理已填充的缓冲区,以便对记录的对象进行标记。
通过从全局列表中遍历已填充的 STAB 缓冲区,并通过在标记位图中设置相应的味儿来标记每个被记录的对象(如果对象位于finger指针后面,则将该对象推送到本地标记栈【local marking stack】)。标记然后遍历标记位图的一个部分中的设置位,跟踪标记对象的字段引用,在标记位图中设置更多位,并在必要时推送对象。
实时数据统计【Live data accounting 】是依赖于标记操作。因此,每次标记一个对象时,它也会被统计(即,它的字节被添加到区域的总字节中)。只有低于 NTAMS 的对象才会被标记和统计。在这个阶段结束时,next 标记位图被清除,以便在下一个标记周期开始时准备好。这是与 mutator 线程同时完成的。
TIP JDK 8u40引入了一个新的命令行选项-XX:+ClassUnloadingWithConcurrentMark,默认情况下,它允许使用并发标记卸载类。因此,并发标记可以跟踪类并计算它们的活跃度。在重新标记【remark】阶段,可以卸载不可访问的类。
Remark
重新标记【remark】阶段是最后的标记阶段。在这个停止世界的阶段,G1 GC 完全排干所有剩余的 SATB 日志缓冲区并处理任何更新。 G1 GC 还会遍历任何未访问的活动对象。在 JDK 8u40 中,重新标记【remark】阶段是停止世界的【stop-the-world】,因为 mutator 线程负责更新 SATB 日志缓冲区并因此“own”这些缓冲区。因此,为了覆盖所有实时数据并安全地完成实时数据核算,需要进行最后的停顿。为了减少在此暂停中花费的时间,使用多个 GC 线程来并行处理日志缓冲区。 -XX:ParallelGCThreads 帮助设置在任何 GC 暂停期间可用的 GC 线程数。引用处理也是重新标记【remark】阶段的一部分。
TIP 由于引用处理开销,任何大量使用引用对象(弱引用、软引用、虚引用或最终引用)的应用程序可能会看到大量的重新标记【remark】时间。我们将在第 3 章学到更多。
Cleanup
在清理暂停期间,两个标记位图交换角色:next 标记位图变为 previous 标记位图(假设当前标记周期已经结束,下一个标记位图具有一致的标记信息),前一个标记位图变为下一个标记位图(在下一个循环中将作为当前标记位图使用)。同样,PTAMS和NTAMS也可以互换角色。清理暂停的三个主要贡献是:识别完全自由的区域、对堆区域进行排序以识别用于混合垃圾收集的高效旧区域以及RSet清理。当前的启发式算法根据活动(有很多活动对象的区域收集起来非常昂贵,因为复制是一个昂贵的操作)和记住的大小(同样,由于区域的受欢迎程度——受欢迎的概念在“RSets及其重要性”一节中进行了讨论),拥有大量记忆集的区域收集起来非常昂贵。我们的目标是首先收集/疏散那些被认为不太昂贵的候选区域(更少的活物体和更不受欢迎的)。
在每个区域中识别活动对象的一个优点是,当遇到完全自由的区域(即没有活动对象的区域)时,记得集可以清除和该地区可以立即回收,回到自由地区的列表,而不是放在GC-efficient (GC效率的概念是“垃圾收集在G1”一节中讨论)排序的数组,不得不等待回收垃圾收集暂停(混合)。RSet清除还有助于检测过时的引用。因此,例如,如果标记发现特定卡上的所有对象都已死亡,则该特定卡的条目将从“拥有”RSet中清除。
在清理暂停期间,两个标记位图交换角色:next 标记位图成为 previous 标记位图(假设当前标记周期已经结束,并且 next 标记位图现在具有一致的标记信息),previous 标记位图成为next 标记位图(将在下一个周期用作当前标记位图)。同样,PTAMS 和 NTAMS 也互换角色。清理暂停的三个主要贡献是:识别完全空闲的区域、对堆区域进行排序以识别混合垃圾收集的有效老年代区域以及 RSet 清理。当前的启发式算法根据活跃度(具有大量活动对象的区域收集起来非常昂贵,因为复制是一项昂贵的操作)和记忆集大小(同样,具有大记忆集的区域收集起来很昂贵,因为地区的流行度——流行度的概念在“RSets 及其重要性”一节中讨论过)。目标是首先收集/疏散被认为成本较低(活动对象较少且不受欢迎)的候选区域。
识别每个区域中的存活对象的一个好处是,在遇到完全空闲的区域(即没有存活对象的区域)时,可以清除其记忆集,立即回收该区域并返回空闲列表区域,而不是被放置在 GC 效率(GC 效率的概念在“Garbage Collection in G1”部分中讨论过)排序数组中,并且必须等待(混合)垃圾收集暂停。RSet 清理还有助于检测过时的引用。因此,例如,如果标记发现特定卡上的所有对象都已死亡,则该特定卡对应的条目将从“owning”RSet中清除。
Evacuation Failures and Full Collection
有时,G1 GC 在试图从年轻代区域复制活对象时,或在从老年区域疏散期间试图复制活对象时,无法找到空闲区域。此类故障在 GC日志中被报告为 to-space exhausted 故障持续时间在日志中进一步显示为 Evacuation Failure time(以下示例中为 331.5ms):
在其他情况下,一个巨型分配可能无法在年老代中找到相邻的区域来分配该巨型对象。
在这种情况下,G1 GC 将尝试增加对 Java 堆的使用。如果 Java 堆空间的扩容不成功,G1 GC 将触发其故障安全机制,并回退到串行(单线程) full GC。
在 full GC 期间,单个线程在整个堆上运行,并对构成代的所有区域(无论是否昂贵)进行标记、扫描和压缩。收集完成后,生成的堆现在由纯活动对象组成,并且所有代都已完全压缩。
TIP 在 JDK 8u40 之前,只能在一个 full GC 中卸载类。
串行 full GC 的单线程特性以手机跨越整个堆的事实可能会使其成为一个非常昂贵的收集,尤其是在堆大小相当大的情况下。因此,强烈建议在频繁发生 full GC 的情况下进行进行重要的调优练习。