Java Hotspot G1 GC 原理

发布时间 2023-10-28 17:36:44作者: LARRY1024


G1 GC(Garbage-First Garbage Collector)是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。

在 JDK 9 中,G1 GC 被提议设置为默认垃圾收集器(JEP 248),G1 收集器的设计目标是取代 CMS 收集器,它同 CMS 相比,在以下方面表现的更出色:

  • G1 是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。

  • G1 的 STW 更可控,G1 在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

原理

G1 是一个分代、增量、并行、大部分并发、stop-the-world 和 evacuating 垃圾收集器,它监视每个 stop-the-world 暂停中的暂停时间目标。与其他收集器类似,G1 将堆分为(虚拟)年轻代和老年代。空间回收工作集中在效率最高的年轻一代上,偶尔也会在老一代中进行空间回收。

某些操作始终在 STW 暂停中执行,以提高吞吐量。其他在应用程序停止时需要更多时间的操作(例如全局标记等全堆操作),是与应用程序并行并发执行的。为了使空间回收的停顿时间较短,G1 分步并行地增量执行空间回收。

G1 通过跟踪有关先前应用程序行为和垃圾收集暂停的信息来构建相关成本的模型,从而实现可预测性。它使用此信息来确定暂停期间完成的工作量。例如,G1 首先回收最有效区域的空间(即大部分被垃圾填满的区域,因此得名)。

G1 主要通过使用疏散(即复制或移动)来回收空间在选定的内存区域中找到要收集的活动对象被复制到新的内存区域,并在此过程中压缩它们。疏散完成后,先前由活动对象占用的空间将重新用于由应用程序分配。

G1 收集器不是实时收集器。它尝试在较长时间内,以高概率满足设定的暂停时间目标,但对于给定的暂停并不总是绝对确定。

概念

初始堆占用情况

初始堆占用百分比(Initiating Heap Occupancy Percent,IHOP)是触发初始标记收集的阈值,它被定义为老年代大小的百分比。

默认情况下,G1 通过观察标记需要多长时间以及标记周期期间通常在老一代中分配多少内存来自动确定最佳 IHOP。此功能称为自适应 IHOP。

标记

G1 标记(Marking)使用了一种称为“开始快照”(Snapshot-At-The-Beginning,SATB)的算法。它在初始标记暂停期间获取堆的虚拟快照,此时在标记开始时处于活动状态的所有对象,都被认为在标记的剩余时间内处于活动状态。这意味着在标记期间变成死亡(无法访问)的对象仍然被视为活动的,以进行空间回收。

与其他收集器相比,这可能会导致错误保留一些额外的内存。但是,SATB 可能会在 Remark 暂停期间提供更好的延迟。在该标记期间过于保守地考虑的活动对象将在下一次标记期间被回收。

Remember Set

在串行和并行收集器中,GC 时会通过整堆扫描来确定对象是否处于可达路径中。

然而,G1 为了避免 STW 式的整堆扫描,为每个分区各自分配了一个 RSet(Remembered Set),它内部类似于一个反向指针,记录了其它 Region 对当前 Region 的引用情况,这样就带来一个极大的好处:回收某个 Region 时,不需要执行全堆扫描,只需扫描它的 RSet 就可以找到外部引用,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况,而这些引用就是 initial mark 的根之一。

事实上,并非所有的引用都需要记录在 RSet 中,

  • 如果引用源是本分区的对象,那么就不需要记录在 RSet 中;

  • 每次 GC 时,所有的新生代都会被扫描,因此,引用源是年轻代的对象,也不需要在 RSet 中记录;

所以,最终只需要记录老年代到新生代之间的引用即可。

原理

在 Young GC 的时候,只需要选定 young generation region 的 RSet 作为根集,这些 RSet 记录了 old -> young 的跨代引用,避免了扫描整个 old generation。

在 Mixed GC 的时候,old generation 中记录了 old->old 的 RSet,young -> old 的引用由扫描全 部young generation region 得到,这样也不用扫描全部 old generation region。

所以,G1 中 Young GC 不需要扫描整个老年代,只需要扫描 Rset 就可以知道老年代引用了哪些新生代中的对象。这样,RSet 的引入就极大地减少了 GC 的工作量。

Card Table

如果一个线程修改了 Region 内部的引用,就必须要去通知 RSet,更改其中的记录。需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,因此 G1 回收器引入了 Card Table 解决这个问题。

一个 Card Table 将一个 Region 在逻辑上划分为若干个固定大小(介于 128 到 512 字节之间)的连续区域,每个区域称之为卡片 Card,因此 Card 是堆内存中的最小可用粒度,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时,便可通过卡片 Card 来查找,每次对内存的回收,也都是对指定分区的卡片进行处理。

一个Region可能有多个线程在并发修改,因此也可能会并发修改 RSet。为避免冲突,G1 垃圾回收器进一步把 RSet 划分成了多个 HashTable,每个线程都在各自的 HashTable 里修改。

最终,从逻辑上来说,RSet 就是这些 HashTable 的集合。哈希表是实现 RSet 的一种常见方式,它的好处就是能够去除重复,这意味着,RS 的大小将和修改的指针数量相当,而在不去重的情况下,RS 的数量和写操作的数量相当。

image

G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

Collect Set

Collect Set(CSet),在拷贝(Evacuation)阶段,由 G1 垃圾回收器选择的待回收的 Region 集合,在任意一次 GC 中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。

G1 的软实时性就是通过 CSet 的选择来实现的,对应于算法的两种模式 fully-young generational mode 和 partially-young mode,CSet的选择可以分成两种:

  • fully-young generational mode:也称 young GC,该模式下 CSet 将只包含 young region,G1 通过调整新生代的 region 的数量来匹配软实时的目标;

  • partially-young mode:也称 Mixed GC,该模式会选择所有的 young region,并且选择一部分的 old region,old region 的选择将依据在 Marking cycle phase 中对存活对象的计数,筛选出回收收益最高的分区添加到 CSet 中(存活对象最少的 Region 进行回收)。

候选老年代分区的 CSet 准入条件,可以通过活跃度阈值 -XX:G1MixedGCLiveThresholdPercent 进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据 CSet 对堆的总大小占比 -XX:G1OldCSetRegionThresholdPercent 设置数量上限。

由上述可知,G1 的收集都是根据 CSet 进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。

停顿预测模型

G1 GC 是一个响应时间优先的 GC 算法,它与 CMS 最大的不同是,用户可以设定整个 GC 过程的期望停顿时间,可以通过参数 -XX:MaxGCPauseMillis 指定一个 G1 收集过程目标停顿时间,默认值 200ms,不过它不是硬性条件,只是期望值。

停顿预测模型(Pause Prediction Model)是以衰减标准偏差为理论基础实现的:

//  share/vm/gc_implementation/g1/g1CollectorPolicy.hpp
double get_new_prediction(TruncatedSeq* seq) {
    return MAX2(seq->davg() + sigma() * seq->dsd(),
                seq->davg() * confidence_factor(seq->num()));
}

在这个预测计算公式中:davg 表示衰减均值,sigma() 表示信赖度系数,dsd 表示衰减标准偏差,confidence_factor 表示可信度相关系数。方法的参数 TruncateSeq,是一个截断的序列,它只跟踪了序列中的最新的 n 个元素。

G1 根据这个模型统计计算出来的历史数据,来预测本次收集需要选择的 Region 数量,从而尽量满足用户设定的目标停顿时间。

G1的垃圾回收过程

对象分配

每一个分配的 Region 都可以分成两个部分:已分配的和未被分配的。它们之间的界限被称为 top。总体上来说,把一个对象分配到 Region 内,只需要简单增加 top 的值即可,如下图所示:

image

线程本地分配缓冲区

如果对象在一个共享的空间中分配,那么我们就需要采用同步机制来解决并发冲突问题。

为了减少并发冲突损耗的同步时间,G1 为每个应用线程和 GC 线程分配了一个本地分配缓冲区( Thread Local allocation buffer,TLAB),分配对象内存时,就在这个 buffer 内分配,线程之间不再需要进行任何的同步,以提高 GC 效率。

但是当线程耗尽了自己的 Buffer 之后,需要申请新的 Buffer。这个时候,依然会带来并发的问题,G1 回收器采用的是 CAS 操作。

显然的,采用TLAB的技术,就会带来碎片。举例来说,当一个线程在自己的Buffer里面分配的时候,虽然Buffer里面还有剩余的空间,但是却因为分配的对象过大以至于这些空闲空间无法容纳,此时线程只能去申请新的Buffer,而原来的Buffer中的空闲空间就被浪费了。Buffer的大小和线程数量都会影响这些碎片的多寡。

Eden 区中分配

对 TLAB 空间中无法分配的对象,JVM 会尝试在 Eden 空间中进行分配。如果 Eden 空间无法容纳该对象,就只能在老年代中进行分配空间。

Humongous 区分配

巨型对象会独占一个、或多个连续分区,其中第一个分区会被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。

由于无法享受 TLab 带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此,确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。

G1 内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

堆内存结构

传统的 GC 收集器

JDK 8 去除了永久代,引入了元空间 Metaspace。

传统的 GC 收集器(串行、并行、CMS)都将连续的内存空间划分为新生代、老年代和永久代,这种划分的特点是各代的存储地址是连续的。如下图所示:

image

G1 收集器

G1 GC 将堆内存划分为一组大小相等的区域(Region),每个区域都是连续的虚拟内存范围,区域是内存分配和内存回收的单位。如下图所示:

image

某些区域集被分配与旧收集器中相同的角色(eden、survivor、old),但它们没有固定的大小。这为内存使用提供了更大的灵活性。

区域大小可以通过 -XX:G1HeapRegionSize 参数指定,默认是将堆内存按照 2048 份均分。区域大小的区间范围:[1MB, 32MB],且必须为 2 的整数次幂。

堆内存在逻辑上被划分为:

  • 年轻代(Young Generation)

    • Eden Region:新生代空间,应用程序新创建的对象通常都分配到 Eden 区,除了大对象以外。

    • Survivor Region:幸存区空间

  • 老年代(Old Generation)

    • Old Generation Region:老年代空间

    • Humongous Region:巨大对象区,会独占一个、或多个连续分区。为了防止大对象反复拷贝移动,大对象直接分配到老年代。

  • 堆中未分配的区域(如上图中灰色部分所示)

巨大对象(humongous object)是指对象的大小超过 region 一半的对象。

当执行垃圾收集时,G1 的操作方式与 CMS 收集器类似。G1 执行并发全局标记阶段来确定整个堆中对象的活跃度。标记阶段完成后,G1 知道哪些区域大部分是空的。它首先在这些区域收集,这通常会产生大量的可用空间。这就是为什么这种垃圾收集方法被称为垃圾优先的原因。

顾名思义,G1 将其收集和压缩活动集中在堆中可能充满可回收对象(即垃圾)的区域。G1 使用暂停预测模型来满足用户定义的暂停时间目标,并根据指定的暂停时间目标选择要收集的区域数量。

G1 识别为适合回收的区域通过疏散进行垃圾收集。G1 将对象从堆的一个或多个区域复制到堆上的单个区域,并在此过程中压缩并释放内存。疏散在多处理器上并行执行,以减少暂停时间并提高吞吐量。因此,每次垃圾收集时,G1 都会在用户定义的暂停时间内持续工作以减少碎片。这超出了之前两种方法的能力。CMS(并发标记扫描)垃圾收集器不进行压缩。ParallelOld 垃圾收集仅执行整个堆压缩,这会导致相当长的暂停时间。

G1 垃圾收集周期

在G1 垃圾收集暂停期间,G1 将对象从该集合集中复制到堆中的一个或多个不同区域。对象的目标区域取决于该对象的源区域:整个年轻代被复制到幸存者或旧区域,并且利用老化的方式,将对象从旧区域复制到其他不同的旧区域。

在较高层面上,G1 收集器在两个阶段之间交替:

image

  • Young-Only 阶段:包含垃圾收集,逐渐用老年代中的对象填充当前可用的内存。

    此阶段从一些仅限年轻集合开始,这些集合将对象提升到老年代。当老年代占用达到某个阈值(初始堆占用阈值)时,Young-Only 阶段和 Space-Reclamation 阶段之间的过渡开始。此时,G1 安排了一个初始标记 young-only 回收,而不是常规的 young-only 回收。

    • 初始标记:除了执行常规的仅限年轻的收集之外,这种类型的收集还会启动标记过程。并发标记确定老年代区域中所有当前可到达的(活动的)对象,以便为接下来的空间回收阶段保留。虽然标记尚未完全完成,但可能会发生常规的年轻集合。标记以两个特殊的 STW 暂停结束:Remark 和 Cleanup。

    • 备注:此暂停完成标记本身,并执行全局引用处理和类卸载。在 Remark 和 Cleanup 之间,G1同时计算活性信息的摘要,该摘要将在 Cleanup 暂停时最终确定并用于更新内部数据结构。

    • 清理:此暂停还会回收完全空的区域,并确定是否会真正进行空间回收阶段。如果随后是空间回收阶段,则仅年轻阶段会以单个仅年轻集合完成。

  • Space-Reclamation 阶段:G1 除了处理年轻代之外,还逐步回收老年代的空间。然后,循环以仅年轻阶段重新开始。

    该阶段由多个混合集合组成,除了年轻代区域之外,还疏散老年代区域组的活动对象。当 G1 确定疏散更多老年代区域不会产生足够的可用空间时,Space-Reclamation 阶段结束。

空间回收后,收集周期从另一个 Young-only 阶段重新开始。作为备份,如果应用程序在收集活跃信息时内存不足,G1 会像其他收集器一样执行就地 STW 全堆压缩(Full GC)。

Young GC

Young GC 是一个 STW 暂停操作,活动对象被疏散(即复制或移动)到一个或多个幸存者区域。如果满足老化阈值,则某些对象将被提升到老一代区域。

image

Young GC 结束后,存活对象已被疏散到幸存者区域或老年代区域。

image

其中,最近晋升的对象用深蓝色表示,幸存者区域呈绿色。

Young GC 总结

G1 的年轻代 GC 总结:

  • 堆是划分为多个区域的单个内存空间。

  • 年轻代内存由一组不连续的区域组成。这使得在需要时可以轻松调整大小。

  • Young GC 是 STW 事件。所有应用程序线程都会因该操作而停止。

  • young GC 使用多个线程并行完成。

  • 存活对象会被复制到新的幸存区或老一代区域。

Mixed GC

年轻代不断进行垃圾回收活动后,为了避免老年代的空间被耗尽。当老年代占用空间超过整堆比 IHOP 阈值,G1 就会启动一次混合垃圾回收(Mixed GC),Mixed GC 不仅进行正常的新生代垃圾收集,同时,也会回收部分后台扫描线程标记的老年代分区。

Mixed GC 的步骤分为 2 步:

  • 全局并发标记(Global Concurrent Marking)

  • 拷贝存活对象(Evacuation)

全局并发标记

初始标记

初始标记(Initial Mark) 是一个 STW 事件。会标记出所有 GC Roots 节点以及直接可达的对象。

image

初始标记过程与 young GC 息息相关。当达到 IHOP 阈值时,G1 并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的 STW 时间段,完成初始标记,这种方式称为借道。

在 GC 日志中,Initial Mark 被标记为 GC pause (young)(inital-mark)

根区域扫描(Root Region Scan)

扫描初始标记的存活区中(即 survivor 区)可直达的老年代区域对象,并标记根对象。该阶段与应用程序并发运行,并且只有完成该阶段后,才能开始下一次 STW 的 young GC。

并发标记

并发标记(Concurrent Marking) 阶段,从 GC Roots 对堆中的对象进行可达性分析,找出存活的对象。如果发现区域中的所有对象都是垃圾,那这个区域会立即被回收。同时,会计算每个区域中对象的存活比例。如果发现空区域(如“X”所示),则在备注阶段立即将其删除。此外,还计算确定活跃度的“记账”信息。

image

重新标记

重新标记(Remark) 阶段是一个 STW 事件。重新标记阶段是为了修正在并发标记期间,因应用程序继续运作而导致标记产生变动的那一部分标记记录,找出所有未被访问的存活对象。

该阶段空区域将被删除并回收;同时,使用开始快照 (SATB) 算法,计算所有区域的区域活跃度,完成堆中存活对象的标记。

image

清理

清理(Cleanup) 阶段,主要是排序各个 Region 的回收价值和成本,并根据用户所期望的 GC 停顿时间来制定回收计划。如果发现无存活对象的分区,会在清除阶段直接回收该分区。

image

拷贝存活对象

拷贝(Copying) 阶段,会选择“活跃度”最低的区域,即可以最快收集的区域。将活动对象疏散或复制到新的未使用区域。然后这些区域会与 Young GC 同时收集。所以年轻代和老年代都会同时被收集。

复制/清理阶段结束后,所选区域已被收集并压缩,如下图深蓝色区域和深绿色区域所示:

image

在 GC 日志中,[GC pause (young)]:表示只包含年轻代的收集; [GC pause (mixed)]:表示同时包含年轻代和老年代的收集。

Full GC

当 G1 无法在堆空间中申请新的分区时,G1 便会触发担保机制,执行一次 STW 式的、单线程的 Full GC,Full GC 会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。

G1在以下场景中会触发 Full GC,同时会在日志中记录 to-space-exhausted 以及 Evacuation Failure:

  • 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区

  • 从老年代分区转移存活对象时,无法找到可用的空闲分区

  • 分配巨型对象时在老年代无法找到足够的连续分区

由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。

G1 GC 选项

G1 收集器的选项如下:

选项和默认值 描述
-XX:MaxGCPauseMillis=200 最大暂停时间的目标。
-XX:GCPauseTimeInterval=<ergo> 最大暂停时间间隔的目标。默认情况下G1不设定任何目标,允许G1在极端情况下连续执行垃圾收集。
-XX:ParallelGCThreads=<ergo> 垃圾收集暂停期间用于并行工作的最大线程数。这是通过以下方式从运行 VM 的计算机的可用线程数得出的:
如果进程可用的 CPU 线程数小于等于 8,则使用该线程数。
否则,添加大于最终线程数的八分之五作为并行线程数量。
XX:ConcGCThreads=<ergo> 用于并发工作的最大线程数。默认情况下,该值 -XX:ParallelGCThreads 除以 4。
-XX:+G1UseAdaptiveIHOP
-XX:InitiatingHeapOccupancyPercent=45
控制初始堆占用,默认值情况下已经开启了 IHOP 自适应,并且对于前几个收集周期,老年代的 45% 作为标记开始的阈值。
-XX:G1HeapRegionSize=<ergo> 堆区域大小设置。
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60
年轻代的总大小,在这两个值之间变化,以当前使用的 Java 堆的百分比表示。
-XX:G1HeapWastePercent=5 集合集候选中允许的未回收空间(以百分比表示)。如果收集集候选中的可用空间低于该值,G1 停止空间回收阶段。
-XX:G1MixedGCCountTarget=8 多个集合中空间回收阶段的预期长度。
-XX:G1MixedGCLiveThresholdPercent=85 在此空间回收阶段中,不会收集活动对象占用率高于此百分比的老一代区域。

注意,表中的 <ergo> 表示其值需要根据环境实际情况修改。

如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。

总结

G1 最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至 CMS 的众多缺陷。

G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

G1在回收时间(200ms)时间内,回收垃圾最多的region,就是收益最大化,就是Garbage First的含义。

年轻代回收,是由Eden主动回收,Survior 区是被动的,只有 Eden 要进行回收 Survior 才进行回收。


参考: