0013 G1收集器原理与验证

Posted on Sun, Jun 11, 2023 JVM GC G1

0013 G1垃圾收集器

对于大多数人来说,Java 垃圾收集器是一个可以愉快地完成其工作的黑匣子。程序员开发应用程序,QE 验证功能,运营团队部署它。在此过程中,您可能会对整体堆、PermGen/Metaspace 或线程设置进行一些调整,但除此之外,一切似乎都正常。那么问题来了,当您开始突破极限时会发生什么?当这些默认值不再足够时会发生什么?作为开发人员、测试人员、性能工程师或架构师,了解垃圾收集工作原理的基本知识是一项非常宝贵的技能,同时也了解如何收集和分析相应的数据并将其转化为有效的调优实践。在这个正在进行的系列中,我们将带您踏上 G1 垃圾收集器的旅程,将您的理解从初学者转变为爱好者,将 GC 置于您的性能堆的首位。

G1(垃圾优先)收集器的意义是什么?它实际上是如何工作的?如果不全面了解它的目标、它如何做出决策以及它是如何设计的,将无法实现理想的最终状态,也没有工具或地图来帮助你实现目标。带着这些问题我们开始吧。

1、G1收集器是什么?

G1:Garbage First的缩写,是垃圾优先收集器。

从本质上讲,G1 收集器的目标是实现可预测的软目标暂停时间(通过 -XX:MaxGCPauseMillis 定义),同时保持一致的应用程序吞吐量。关键目标和最终目标是能够保持这些目标,满足当今高性能、多线程应用程序的需求,这些应用程序需要不断增加堆大小。G1 的一般规则是,暂停时间目标越高,可实现的吞吐量和总体延迟就越高。暂停时间目标越低,可实现的吞吐量和总体延迟就越低。垃圾收集的目标是结合对应用程序运行时要求、应用程序的物理特性和对 G1 的理解,调整一组选项并实现满足业务需求的最佳运行状态。重要的是要记住,调整是一个不断发展的过程,在这个过程中,您可以通过重复测试和评估建立一组基线和最佳设置。没有明确的指南或神奇的选项集,您有责任评估绩效、进行渐进式更改并重新评估,直到实现目标。

就其本身而言,G1 通过几种不同的方式来实现这些目标。首先,正如其名称所示,G1 收集活动数据量最少的区域(垃圾优先!)并将活动数据压缩/撤离到新区域中。其次,它使用一系列增量、并行和多阶段循环来实现其软暂停时间目标。这使得 G1 能够在定义的时间内完成必要的操作,而不管整体堆大小如何。

2、G1收集器原理

我们提到了 G1 中称为“区域”的新概念。简单地说,区域代表一块分配的空间,可以容纳任何一代的对象,而无需与同一代的其他区域保持连续性。在 G1 中,传统的年轻代和老年代仍然存在。年轻代由伊甸园空间组成,所有新分配的对象都从这里开始,而幸存者空间则在收集期间将伊甸园对象复制到这里。对象将保留在幸存者空间中,直到它们被收集或足够老以进行提升,由 XX:MaxTenuringThreshold(默认为 15)定义。老年代由老年代空间组成,当对象达到 XX:MaxTenuringThreshold 时,它们将从幸存者空间中提升。当然,这有一个例外,我们将在本文结尾处介绍。区域大小是在 JVM 启动时计算和定义的。它基于尽可能接近 2048 个区域的原则,其中每个区域的大小为 2 的幂,介于 1 到 64 MB 之间。更简单地说,对于 12 GB 的堆:

12288 MB / 2048 个区域 = 6 MB ——这不是 2 的幂

12288 MB / 8 MB = 1536 个区域 ——通常太低

12288 MB / 4 MB = 3072 个区域 ——可接受

根据上述计算,JVM 默认会分配 3072 个区域,每个区域可容纳 4 MB,如下图所示。您还可以选择通过 -XX:G1HeapRegionSize 明确指定区域大小。设置区域大小时,了解堆大小比将创建的区域数量非常重要,因为区域越少,G1 的灵活性就越低,扫描、标记和收集每个区域所需的时间就越长。在所有情况下,空区域都会添加到无序列表(也称为“空闲列表”)中。

3、G1分代收集

虽然 G1 是分代收集器,但空间的分配和消耗既不连续,又可以自由发展,因为它可以更好地理解最有效的年轻与年老比率。当开始生成对象时,会从空闲列表中分配一个区域作为线程本地分配缓冲区 (TLAB),使用比较和交换方法来实现同步。然后可以在这些线程本地缓冲区内分配对象,而无需额外的同步。当该区域的空间耗尽时,将选择、分配和填充一个新区域。这一直持续到累积的 Eden 区域空间被填满,从而触发疏散暂停(也称为年轻收集/年轻 gc/年轻暂停或混合收集/混合 gc/混合暂停)。Eden 空间的累积量表示我们认为可以在定义的软暂停时间目标内收集的区域数量。分配给 Eden 区域的总堆百分比范围为 5% 到 60%,并根据上一次年轻收集的性能在每次年轻收集后进行动态调整。

下面是一个将对象分配到非连续的 Eden 区域的示例:

GC pause (young); #1
          [Eden:612.0M(612.0M)->0.0B(532.0M) Survivors: 0.0B->80.0M Heap: 612.0M(12.0G)->611.7M(12.0G)]
GC pause (young); #2
          [Eden:532.0M(532.0M)->0.0B(532.0M) Survivors: 80.0M->80.0M Heap: 1143.7M(12.0G)->1143.8M(12.0G)]

根据上面的“GC 暂停(年轻代)”日志,您可以看到在暂停 #1 中,由于 Eden 达到了612.0M(总共612.0M,共计 153 个区域),因此触发了撤离。当前 Eden 空间已完全撤离,为0.0B,考虑到所花费的时间,它还决定将 Eden 的总分配量减少到532.0M或 133 个区域。在暂停 #2 中,你可以看到当我们达到新的限制532.0M时触发了撤离。由于我们实现了最佳暂停时间,因此 Eden 保持在532.0M

当上述年轻代收集发生时,死亡对象会被收集,任何剩余的存活对象会被撤离并压缩到 Survivor 空间中。G1 有一个明确的硬边界,由 G1ReservePercent(默认 10%)定义,这会导致在撤离期间,一定比例的堆始终可用于 Survivor 空间。如果没有这个可用空间,堆可能会填满到没有可用区域可供撤离的地步。虽然不能保证这种情况不会发生,但这就是调整的目的!此原则确保每次成功撤离后,所有先前分配的 Eden 区域都会返回到空闲列表,并且任何撤离的存活对象最终都会进入 Survivor 空间。

下面是一个标准年轻代收集的示例:

继续这种模式,对象再次被分配到新请求的 Eden 区域。当 Eden 空间填满时,将发生另一次年轻代收集,并且根据现有活动对象的年龄(各种对象在多少次年轻代收集中幸存下来),您将看到它们被提升到老生代区域。鉴于 Survivor 空间是年轻代的一部分,死对象在这些年轻代暂停期间被收集或提升。

下面是年轻代垃圾收集的示例,其中幸存者空间中的活动对象被撤离并提升到老生代空间中的新区域,而伊甸园中的活动对象被撤离到新​​的幸存者空间区域。撤离的区域(用删除线表示)现在为空并返回到空闲列表。

G1 将继续这种模式,直到以下三件事之一发生:

  1. 它达到了可配置的软边界,称为 InitiatingHeapOccupancyPercent (IHOP)。
  2. 它达到了可配置的硬边界(G1ReservePercent)
  3. 它遇到了一个巨大的分配(这是我之前提到的例外,更多内容在最后)。

重点关注主要触发器,IHOP 表示在年轻代收集期间计算出的某个时间点,其中老年代区域中的对象数量占总堆的 45% 以上(默认值)。此活跃率会作为每次年轻代收集的组成部分不断计算和评估。当其中一个触发器被触发时,会发出请求以启动并发标记周期。

8801.974: [G1Ergonomics (Concurrent Cycles)request concurrent cycle initiation, reason:occupancy higher than threshold, occupancy: 12582912000 bytes, allocation request: 0 bytes, threshold: 12562779330 bytes (45.00 %), source: end of GC]
8804.670: [G1Ergonomics (Concurrent Cycles)initiate concurrent cycle, reason: concurrent cycle initiation requested]
8805.612: [GC concurrent-mark-start]
8820.483: [GC concurrent-mark-end, 14.8711620 secs]

在 G1 中,并发标记基于起始快照 (SATB) 原则。这意味着,出于效率目的,它只能将对象识别为垃圾,前提是这些对象在拍摄初始快照时存在。在并发标记周期中出现的任何新分配的对象都被视为活动对象,而不管其真实状态如何。这一点很重要,因为并发标记完成所需的时间越长,可回收对象与被视为隐式活动对象的比例就越高。如果在并发标记期间分配的对象多于最终回收的对象,则最终会耗尽堆。在并发标记周期中,您将看到年轻对象回收继续进行,因为它不是 Stop-the-World 事件。

下面是在达到 IHOP 阈值并触发并发标记时,年轻收集之后堆可能的样子的示例。

一旦并发标记周期完成,就会立即触发年轻代收集,然后是第二种类型的撤离,即混合收集。混合收集的工作原理与年轻代收集几乎完全相同,但有两个主要区别。首先,混合收集还将收集、撤离和压缩一组选定的旧代区域。其次,混合收集并不基于年轻代收集使用的相同撤离触发器。它们的目标是尽可能快速、频繁地进行收集。它们这样做是为了最大限度地减少分配的 Eden / Survivor 区域的数量,从而最大限度地增加在软暂停目标内选择的旧代区域的数量。

8821.975: [G1Ergonomics (Mixed GCs) start mixed GCs, reason: candidate old regions available, candidate old regions: 553 regions, reclaimable: 6072062616 bytes (21.75 %), threshold: 5.00 %]

上面的日志告诉我们,混合收集即将开始,因为候选老旧区域的数量 (553) 总共具有 21.75% 的可回收空间。此值高于 G1HeapWastePercent 定义的 5% 最小阈值(JDK8u40+ 中默认为 5% / JDK7 中默认为 10%),因此混合收集将开始。因为我们不想执行浪费的工作,所以 G1 坚持垃圾优先政策。基于有序列表,候选区域根据其活动对象百分比进行选择。如果老旧区域的活动对象少于 G1MixedGCLiveThresholdPercent 定义的百分比(JDK8u40+ 中默认为 85%,JDK7 中默认为 65%),我们会将其添加到列表中。简而言之,如果老生常谈区域的活跃度超过 65% (JDK7) 或 85% (JDK8u40+),我们不想在这个混合周期内浪费时间尝试收集和清理它。

8822.178: [GC pause (mixed) 8822.178: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 74448, predicted base time: 170.03 ms, remaining time: 829.97 ms, target pause time: 1000.00 ms]

与年轻代收集相比,混合收集将尝试在相同的暂停时间目标内收集所有三代。它通过基于 G1MixedGCCountTarget 的值(默认为 8)增量收集老生代区域来实现这一点。这意味着,它将候选老生代区域的数量除以 G1MixedGCCountTarget,并尝试在每个周期内收集至少那么多的区域。每个周期结束后,将重新评估老生代区域的活跃度。如果可回收空间仍然大于 G1HeapWastePercent,则混合收集将继续。

8822.704: [G1Ergonomics (Mixed GCs)continue mixed GCs, reason: candidate old regions available,candidate old regions: 444 regions, reclaimable: 4482864320 bytes (16.06 %), threshold: 10.00 %]

此图表示混合收集。所有 Eden 区域均被收集并撤离到 Survivor 区域,并且根据年龄,所有 Survivor 区域均被收集,并且足够老的存活对象被提升到新的 Old 区域。同时,还收集 Old 区域的选定子集,并将任何剩余的存活对象压缩到新的 Old 区域。压缩和撤离过程可以显著减少碎片并确保维护足够的空闲区域。

此图表示混合收集完成后的堆。所有 Eden 区域均被收集,并且活动对象驻留在新分配的 Survivor 区域中。现有的 Survivor 区域被收集,并且活动对象被提升到新的 Old 区域。收集的 Old 区域集合被返回到空闲列表,并且任何剩余的活动对象都被压缩到新的 Old 区域中。

混合收集将持续进行,直到所有八个收集完成或可回收百分比不再满足 G1HeapWastePercent。从那里,您将看到混合收集周期结束,并且后续事件将返回到标准年轻收集。

8830.249: [G1Ergonomics (Mixed GCs)do not continue mixed GCs, reason:reclaimable percentage not over threshold, candidate old regions: 58 regions,reclaimable: 2789505896 bytes (9.98 %), threshold: 10.00 %]

既然我们已经介绍了标准用例,让我们回过头来讨论一下我之前提到的例外情况。它适用于对象大小大于单个区域 50% 的情况。在这种情况下,对象被视为超大对象,并通过执行专门的超大分配来处理。

区域大小:4096 KB

对象 A:12800 KB

结果:4 个地区的巨额分配

该图概述了跨越 4 个连续区域的 12.5 MB 对象的巨大分配。

  1. 超大分配表示单个对象,因此必须分配到连续的空间中。这可能会导致严重的碎片化。
  2. 巨型对象被直接分配到老生代内的一个特殊巨型区域。这是因为在年轻代中清除和复制此类对象的成本太高。
  3. 即使所讨论的对象只有 12.5 MB,它也必须消耗四个完整区域,总使用量为 16 MB。
  4. 无论是否满足 IHOP 标准,巨大的分配总是会触发并发标记周期。

少量巨型对象可能会有问题,但稳定地分配这些对象可能会导致严重的堆碎片化和明显的性能影响。在 JDK8u40 之前,巨型对象只能通过完整 GC 来收集,因此这对 JDK7 和早期 JDK8 用户的影响可能性非常高。这就是为什么了解应用程序生成的对象的大小以及 G1 对区域大小的定义至关重要的原因。即使在最新的 JDK8 中,如果您正在进行大量巨型分配,最好评估并尽可能多地调整。

4948.653: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation, reason: requested byGC cause,GC cause: G1 Humongous Allocation]
7677.280: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 14050918400 bytes, allocation request: 16777232 bytes, threshold: 12562779330 32234.274: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation, reason: occupancy higher than threshold, occupancy: 12566134784 bytes, allocation request: 9968136 bytes, threshold: 12562779330 bytes (45.00 %),source: concurrent humongous allocation]

最后,不幸的是,G1 还必须处理可怕的完整 GC。虽然 G1 最终试图避免完整 GC,但它们仍然是一个严酷的现实,尤其是在未正确调整的环境中。鉴于 G1 的目标是更大的堆大小,完整 GC 的影响可能会对正在进行的处理和 SLA 造成灾难性的影响。主要原因之一是完整 GC 在 G1 中仍然是单线程操作。查看原因,第一个也是最可避免的与元空间有关。

[Full GC (Metadata GC Threshold) 2065630K->2053217K(31574016K), 3.5927870 secs]

一个预先提示是更新到 JDK8u40+,其中类卸载不再需要完整 GC!您可能仍会在 Metaspace 上遇到完整 GC,但这将与 UseCompressedOops 和 UseCompressedClassesPointers 或并发标记所需的时间有关(我们将在以后的文章中讨论)。

后两个原因是真实存在的,而且往往无法避免。作为工程师,我们的工作是尽力通过调整和评估生成我们试图收集的对象的代码来延迟和避免这些情况。第一个主要问题是“目标空间耗尽”事件,随后发生完整 GC。此事件表示撤离失败,其中堆无法再扩展,并且没有可用区域来容纳撤离。如果您还记得,我们之前讨论过由 G1ReservePercent 定义的硬边界。此事件表示您正在撤离到目标空间的对象多于您的保留空间,并且堆太满,我们没有其他可用区域。在某些情况下,如果 JVM 可以解决空间问题,则不会发生完整 GC,但这仍然是一个代价高昂的世界停止事件。

6229.578: [GC pause (young) (to-space exhausted), 0.0406140 secs]
6229.691: [Full GC 10G->5813M(12G), 15.7221680 secs]

如果您经常看到这种模式,您可以立即假设您有很大的调整空间!第二种情况是并发标记期间的完整 GC。在这种情况下,我们并没有疏散失败,我们只是在并发标记完成并触发混合收集之前用完了堆。这两个原因要么是内存泄漏,要么是您生产和提升对象的速度比收集它们的速度快。如果完整 GC 收集是堆的很大一部分,您可以假设它与生产和提升有关。如果收集的很少,并且您最终遇到 OutOfMemoryError,那么您很可能正在查看内存泄漏。

57929.136: [GC concurrent-mark-start]
57955.723: [Full GC 10G->5109M(12G), 15.1175910 secs]
57977.841: [GC concurrent-mark-abort]

希望这篇文章能让你了解 G1 的设计方式以及它如何做出垃圾收集决策。