增量式垃圾回收器——带你深入理解G1垃圾回收器

增量式垃圾回收器------带你深入理解G1垃圾回收器

欢迎关注,​分享更多原创技术内容~

微信公众号:ByteRaccoon、知乎:一只大狸花啊、稀土掘金:浣熊say

微信公众号海量Java、数字孪生、工业互联网电子书免费送~

概述

G1垃圾回收器是在Java 7之后引入的一种垃圾回收器。它被设计为一种分代、增量、并行和并发的标记-复制垃圾回收器,旨在适应不断扩大的内存和增加的处理器数量,以显著降低垃圾回收造成的暂停时间,同时保持良好的吞吐量,主要有以下特点:

  • 并行与并发: G1回收器在垃圾回收的不同阶段使用了并行和并发的方式,充分利用多核处理器的优势,提高了垃圾回收的效率。

  • 分代回收: G1垃圾回收器依旧采用分代回收的思想,但是和CMS等分代回收算法不同,G1不是将整个堆内存划分为年轻代、老年代和元空间。而是将堆内存划分为一个个固定大小的region,每个region可以属于年轻代或老年代。垃圾回收的基本单位是region,而不是整个堆,这使得垃圾回收更加灵活。

  • 增量回收: G1采用增量回收的方式,将整个垃圾回收过程分解为多个阶段进行,这样更有利于分散垃圾回收的压力,减小每次暂停的时间,提高系统的响应性。

  • Compacting回收: 与CMS回收器不同,G1是一种compacting回收器,其回收的内存空间是连续的。这样就可以避免CMS收集器由于不连续内存空间造成的所需堆空间更大和浮动垃圾的问题。连续空间意味着G1垃圾回收器可以不必采用空闲链表的内存分配方式,而可以直接采用bump-the-pointer的方式;

  • 软实时: G1回收器具有软实时(soft real-time)的特性,用户可以指定垃圾回收时间的限时。虽然G1会努力在限定时间内完成垃圾回收,但并不保证每次都能在时限内完成。通过设定合理的目标,可以使大部分垃圾回收时间都在规定的时限内完成。

G1垃圾回收器以其创新性的设计和优越的性能特点,逐渐成为Java应用程序中首选的垃圾回收器之一。通过分代、增量、并行、并发等多种技术手段的结合,G1回收器在处理大内存和多核处理器的环境下表现出色,为Java应用程序提供了更好的性能和响应能力。

G1的内存模型和分代策略

G1收集器相关参数

参数 默认值 描述
-XX:G1NewSizePercent 整堆的5% 年轻代的初始空间百分比。
-XX:G1MaxNewSizePercent 60% 年轻代的最大空间百分比
-XX:MaxGCPauseMillis 200ms 指定的最大垃圾收集暂停时间
-XX:NewRatio - -Xmn一同设置,年轻代和老年代的比例
-Xmn - 设置年轻代的固定大小
-XX:G1HeapRegionSize 1MB~32MB 分区大小,每个分区是G1的基本单位
-XX:GCTimeRatio 9 GC与应用程序的耗时比例
-XX:G1MaxNewSizePercent 99 GC与应用程序的耗时比例,CMS默认为99,G1默认为9

G1分区(Region)机制和分代策略

如上面的图所示,G1垃圾回收器采用了内存分区(Region)思路,将整个堆空间分成若干个大小相等的内存区域,这些内存区域的大小一般大小在1MB~32MB之间,可以通过设置启动参数 -XX:G1HeapRegionSize=n 来指定分区的大小。

每次垃圾回收的时候,G1会逐段地回收这些内存区域,而且不要求对象的存储在物理上是连续的,只要在逻辑上是连续的即可。这样的好处在于,分代垃圾收集将关注点聚焦在最近分配的对象上,无需全堆扫描,从而避免了对长生命周期对象的频繁拷贝。并且年轻代和老年代的垃圾回收过程相互独立,有助于降低系统整体的响应时间。

虽然G1内存分区不要求内存分配是非连续的,但它依然逻辑上将内存划分为年轻代和老年代。然而,和CMS等分代的垃圾回收器不同,G1垃圾回收器不是将一大块连续的内存区域划分成年轻代和老年代,而是将每一个分区(Region)划分成Eden区、Survivor区、Huge区和Old区等。

并且和CMS等分代垃圾回收器不同的是,对于G1收集器来说,当现有年轻代分区被占满时,JVM会为年轻代动态分配新的空闲分区。

年轻代的整体内存大小会在初始空间(通过参数-XX:G1NewSizePercent设置,默认整堆的5%)和最大空间(通过参数-XX:G1MaxNewSizePercent设置,默认60%)之间动态变化。这个动态变化的过程由目标暂停时间(通过参数-XX:MaxGCPauseMillis设置,默认200ms)、需要扩缩容的大小以及分区的已记忆集合(RSet)共同决定。总结下上面说的,G1垃圾回收器的分区和分代机制主要有下面3个特点:

  • 分区(Region)大小可调: G1垃圾回收器可以设置分区(Region)大小范围为1MB~32MB,且必须是2的幂,默认情况下,整个堆被划分为2048个分区,但可以根据应用程序的需求进行调整。

  • 逻辑连续性: G1垃圾回收器并不强制要求对象在物理上的存储是连续的,而是以逻辑上的连续性为目标,这样一来可以更灵活的分配内存,使对象的存储不受物理位置的限制。

  • 动态分代: 每个分区并不固定地为某个代服务,分区(Region)可以被划分成Eden区、Survivor区、Huge区和Old区等。并且可以按需在年轻代和老年代之间切换,G1可以根据实际情况调整内存分区的使用。

G1垃圾回收器通过引入分区设计和分代策略,以逻辑上的连续性为基础,克服了传统垃圾回收器对物理连续性的依赖。这种设计使得对象的存储更加灵活,同时允许动态调整分区的使用,为应对不同应用场景提供了更好的适应性。

G1 卡片(Card)标记机制

如上图所示,在G1垃圾回收器中,每个分区内部进一步被划分为若干大小为512 Byte的卡片(Card)。这些卡片用于标识堆内存的最小可用粒度。所有分区的卡片记录在全局卡片表中(Global Card Table),而分配的对象将占用物理上连续的若干个卡片,总结起来主要有以下几点:

  • 卡片(Card): G1将每个分区(Region)进一步划分为大小为512 Byte的卡片,这是最小的标记单位,对象的分配和回收都以卡片为基本单位进行,这个卡片的大小是可以进行设置和调整的。

  • 全局卡表(Global Card Table): 所有分区的卡片状态会被记录在全局卡片表中,这个表维护了对每个卡片的引用状态,用于在垃圾回收过程中准确地追踪对象引用关系。

  • 对象分配物理连续性: 分配的对象会占用物理上连续的若干个卡片,这种设计有助于提高内存的利用率,并简化对内存的管理。

  • 引用查找: 对分区(Region)内对象引用的查找通过记录卡片(Card)的方式实现,G1维护了一个叫做RSet(Remembered Set)的数据结构,用于记录在分区内对象的引用关系。

  • 回收处理: 每次对内存的回收都涉及对指定分区的卡片进行处理。通过检查RSet中的信息,G1能够确定哪些对象是不再被引用的,从而进行相应的回收操作。

G1垃圾回收器利用卡片标记机制,将堆内存划分为最小的可标记单位,实现了对对象引用关系的准确追踪。这种分区和卡片的设计有助于提高垃圾回收的效率,同时为更精细的内存管理提供了基础。

G1 堆(Heap)空间调整机制

G1垃圾回收器提供了一套灵活的堆空间调整机制,通过自动调整堆空间大小来适应不同的应用场景,可以指定堆空间的大小,也可以设置堆空间的比例来调整GC的频率。

  • 指定堆空间大小: 通过标准的JVM参数 -Xms(初始堆大小)和 -Xmx(最大堆大小)可以手动指定堆空间的大小。

  • 自动调整: G1在发生年轻代收集或混合收集时,会根据GC与应用的耗费时间比进行自动调整堆空间大小。这个比例由目标参数 -XX:GCTimeRatio 决定,默认值为9。G1根据应用和GC的相对耗时,动态调整堆空间,以减少GC的频率和相应的时间开销。

  • GC频率控制: 如果GC频率过高,G1可以通过增加堆的尺寸来减少GC的频率,从而降低GC占用的时间。这是通过自动调整堆空间大小实现的。

  • 空间不足处理: 当发生对象空间分配或转移失败时,G1会首先尝试增加堆空间。如果扩容成功,问题得以解决;如果扩容失败,G1会触发一次担保的Full GC,Full GC后,G1重新计算堆尺寸,调整堆空间大小。

通过这套机制,G1垃圾回收器在运行时可以动态适应不同的工作负载,提高垃圾回收的效率,同时降低GC对应用程序的影响。

本地分配缓冲 Local allocation buffer (Lab)

在JVM中一个对象被创建之后,JVM首先要做的事就是给这个对象分配一块儿内存空间。但是,执行这个内存分配工作

的也是一个线程,那么只要涉及到多线程就可能会出现线程安全问题,于是就有了LAB(本地分配缓冲)的概念。

LAB(本地分配缓冲,Local allocation buffer)的意思是每个线程都有自己独立的一个分区(Region)来进行内存分配,这样线程之前的内存互不打扰,就保障了线程的安全。

结合G1垃圾回收器来说,由于采用了分区(Region)的思想,所以每个线程都可以"认领"某个分区用于线程本地的内存分配,而无需顾忌内存空间是否连续。每个应用线程和GC线程都会独立的使用某个分区,从而减少同步所耗费的时间,提升GC效率,这个分区被称为"本地分配缓冲区"(Lab),除了Lab之外还有TLAB、PLAB和GCLAB等概念,如下:

  • TLAB(Threads Local Allocation Buffer,线程本地分配缓冲区): 每一个应用线程(App thread)都可以独占一个本地缓冲区(即Region)来创建对象,TLAB通常属于 Eden 空间,因为大部分对象会首先在 Eden 区被分配(大型对象除外)。

  • **GCLab(**GC Local Allocation Buffer,GC 本地缓冲区): 每个GC线程在进行垃圾收集时,同样可以独占一个本地缓冲区(GCLAB,即Region)用于转移对象,在每次回收过程中,对象会被复制到 Survivor 空间或老年代空间。

  • **PLAB(**Promotion Local allocation buffer,晋升本地缓冲区): 对于从 Eden 或 Survivor 空间晋升到 Survivor 或老年代空间的对象,即Old对象,同样有GC线程独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

这种分区和本地缓冲区的设计使得每个线程有了自己独立的Region,有助于提高并发性和减少同步开销,从而提升垃圾收集的效率。

Remember Set(RSet)和Collection Set(CSet)

前面说过,对于G1收集器而言内存区域会被划分成Eden Region、Survivor Region、Old Region和Humongous Region等不同分代的区域,而这些区域内部又会按照512 Bytes的大小为一个Card,进一步被划成为多个Card。

G1收集器在分配对象的时候,是按照完整Card的来进行分配的,也就是说一个对象可以占用1个、2个甚至多个cards,即使这些Cards没有被完全占满,也会分配整数个Cards给这个对象使用。那么这样一来,当G1垃圾回收器需要回收某一个对象的时候就只需要回收对象对应的Cards就可以了,这样既高效也不会影响到其它对象的正常使用。

为什么需要RSet?

G1回收器在对某个Region进行垃圾回收的时候,首先会使用的"根可达算法"和"三色标记"算法从GC Roots开始进行垃圾标记。在垃圾标记的过程中,由于晋升等原因,某些Region当中对象可能会被移动到其它Region,但是G1收集器中对对象的引用是到Region中的Cards级别的。

在G1 垃圾收集器中的对象是可以晋升的,和其它垃圾收集器类似当年轻代的对象存活超过一定时间之后,就可以从年轻代晋升为老年代。如上图所示,假设GC Roots分别引用了Eden Region 中的A对象和Survivor Region中的B对象,而在GC过程中这两个对象由于晋升等原因被移动到了Old Region。而此时,对象A和B依旧还是引用了处于Eden和Survivor中的C、D对象,这也就发生了跨区域(Region)的引用。

除了上面所说的从老年代到新生代的引用之外,根据G1的设计思路,还存在以下几种引用情况:

  1. 分区内部有引用关系
  2. 新生代分区到新生代分区之间有引用关系
  3. 新生代分区到老生代分区之间有引用关系
  4. 老生代分区到新生代分区之间有引用关系
  5. 老生代分区到老生代分区之间有引用关系

另外,G1垃圾回收器也是分代垃圾回收器,也存在着新生代垃圾回收 (Young GC,回收Eden区、Survivor区)、混合回收 (Mixed GC,Eden区、Survivor 区、Old 区)和Full GC(Eden区、Survivor区、Old区、Huge区,Meta Space等)。

那么,假设Young GC发生的时候,就按照上图的例子,如果我们发现处于Eden区或者Survivor区的对象被Old区或者其它区域的对象所引用,这种时候如何才能将当前区域的对象成功标记为存活对象呢?

最简单的办法当然是整体扫描所有区域来得到其它区域对当前区域的引用,那就相当于每次Young GC都要进行一次Full GC才能找到所有引用关系,保证不发生漏标。但是,这种方法无异于高射炮打蚊子,得不偿失,会导致G1垃圾回收器效率极低。

所以,如果对于当前Region中的某个对象,我们知道它被什么对象所引用,或者换句话说有一个当前对象的**反向指针。这样以来,**我们就能够更快速的判断当前区域中的对象是否被GC Root所引用,是否应该被标记成存活对象。此时我们的主角就呼之欲出了------RSet。

RSet作为用于记录引用关系的数据结构,在有效地追踪这些引用。通过精确地维护RSet,G1能够更有效地执行垃圾回收,从而最大程度地减少对可用空间的影响。这种详细的引用关系分析有助于优化垃圾回收算法,提高Java应用程序的性能和资源利用率。

RSet的实现机制

由于G1垃圾回收器的回收粒度是按照区域(Region进行的),为了记录Region之间对象引用关系,当Region初始化的时候,JVM会拿出这个Region的一部分区域用来初始化一个RSet(Remembered Set,已记忆集合),这个集合的作用是记录和跟踪其它Region指向该Region中对象的Card的引用。在进行垃圾标记的时候,除了从GC Roots开始遍历,还会从RSet开始遍历,确保该区域中所有存活的对象都会被标记到。

RSet是一种典型的空间换时间的策略,对于Old->Young、Old-Old的跨代对象引用,只需要扫描RSet即可。RSet中记录了其它Region钟对象引用本Region中的对象,属于Point-Into(谁引用了我),而Card Table则属于Point-Out(我引用了谁)。但是,由于RSet是属于每一个Region的,如果单个Region中被引用的对象过多,对内存的开销就会太大,因此引入了我们接着需要介绍的节省RSet空间的机制------PRT机制。

Per Region Table(PRT)机制

**RSet内部采用Per Region Table(PRT)**来记录分区的引用情况。由于RSet的记录会占用分区的空间,当一个分区变得非常"热门"时,RSet占用的空间会增加,从而降低了分区的可用空间。为了应对这个问题,G1采用了改变RSet密度的策略,即在PRT中使用三种不同的模式来记录引用:

RSet储存状态 描述 实现方式描述
稀疏模式(Sparse) 对于不那么热门的Region,直接记录引用对象的Card的索引,可以直接通过RSet找到对应对象,空间耗费最大,效率最高。 通过哈希表方式实现。数组的Key是当前Region的地址,而值是Card地址数组。
细粒度模式(Fine-grained) 记录引用对象的Region索引,可以通过RSet找到对应的Region. 通过Region地址链表实现,维护当前Region中所有Card的BitMap集合。当Card被引用时,对应的Bit被设置为1。同时,维护一个对应Region的索引数量,用于跟踪引用情况。
粗粒度模式(Coarse-grained) 只记录引用情况,每个分区对应一个比特位,只记录了引用的存在与否,需要通过整堆扫描才能找出所有引用,因此扫描速度是最慢的。 通过BitMap来表示所有Region。如果有其他Region对当前Region有指针引用,就设置其对应的Bit为1,否则标记为0。

这种灵活的模式选择允许G1根据分区的特性和使用情况动态调整RSet的记录密度,以平衡空间占用和引用追踪的性能。这种策略的实施有助于在分区受欢迎的情况下最大限度地减少对可用空间的影响。

RSet记录的引用类型

同时,由于G1垃圾回收器在YGC的时候会对所有的Eden区和Survivor区域都进行扫描和回收,因此,从年轻代到年轻代的引用是无需记录的。更多的引用关系如下:

引用关系 是否记录在RSet中 说明
分区内部有引用关系 不记录 回收是针对一个分区进行的,回收时会遍历整个分区,无需记录分区内部引用关系。
新生代分区->新生代分区 不记录 G1回收器在Young GC的时候会全量处理所有新生代分区,无需额外记录这一引用关系。
新生代分区->老生代分区 不记录 YGC针对的是新生代分区,混合GC使用新生代分区作为根,FGC处理所有分区,无需额外记录这一引用关系。
老生代分区->新生代分区 记录 YGC时有两种根,一是栈空间/全局空间变量的引用,另一是老生代分区到新生代分区的引用,需要记录。
老生代分区->老生代分区 记录 在混合GC时可能只有部分分区被回收,必须记录引用关系以快速找到活跃对象。

如上表所示,由于G1回收器不同类型的GC所处理的区域不同,并不是所有其它区域对本区域的引用关系都需要被记录的,简单来说只有"老年代分区到新生代分区"的引用、"老年代分区到老年代"分区的引用需要被RSet记录下来。

CSet(收集集合,Collection Set)

G1收集器中的CSet(收集集合,Collection Set)是一组被选中进行垃圾回收的Region的集合。G1收集器是以Region为单位的,并且分为YGC和Mixed GC,这两种GC方式都是需要STW的,但是G1收集器的一个优势就是它是增量式的垃圾回收器,可以根据设置的回收停顿时间来动态调整每次的回收量。CSet就可以很好的支持增量回收的特性,其中存储的就每次YGC或者Mixed GC时需要回收清理的所有Region。当回收的时,CSet中的所有Region都将被释放,区域存活的对象将被移动到分配的空闲分区(Free Region)中。

由于G1收集器分为Young GC和Mixed GC两种模式,因此存在着两种不同的CSet分别在这两种模式下使用:

  • Young GC中的CSet: 在Young GC中,CSet包含所有Young区域,即Eden和Survivor分区。当Eden区域满时,触发Young GC,CSet中的对象将被移动或复制到Survivor分区或老年代。这个过程确保Young GC时只处理一小部分内存,减小了每次垃圾回收的暂停时间。

  • **Mixed GC中的CSet:**在Mixed GC中,CSet包括一部分老年代区域,这些区域被选中进行部分垃圾回收。与Young GC不同,Mixed GC关注的是整个堆的内存回收,而不仅仅是年轻代的部分。G1在Mixed GC过程中会优先选择收益高的老年代区域,以提高垃圾回收的效率。

通过使用CSet,G1收集器可以更灵活地控制垃圾回收的范围,同时通过优先处理收益高的区域,提高了内存回收的效率。这种策略使得G1可以在更可控的停顿时间内实现高效的垃圾回收。

RSet的维护

由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。在G1中,RSet的维护主要来源两个方面:写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)

屏障(Barrier)

屏障(Barrier)是指在原生代码片段中,当某些语句被执行时,屏障代码也会被执行。而G1主要在赋值语句中,使用写前屏障(Pre-Write Barrier)和写后屏障(Post-Write Barrier),如下:

  • 写前屏障 (Pre-Write Barrier):即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象。那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用。此时,JVM需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。

  • 写后屏障 (Post-Write Barrier):当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用。那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后屏障发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。

事实上,写屏障的指令序列开销非常昂贵,应用吞吐量也会根据屏障复杂度而降低。

始快照算法(Snapshot at the Beginning,SATB)

SATB是由Taiichi Tuasa 提出的增量式完全并发标记算法,主要应用于标记-清除垃圾收集器的并发标记阶段。SATB 针对 G1 的分区块堆结构设计,同时解决了 CMS(Concurrent Mark-Sweep)的一个主要问题,即重新标记暂停时间长导致的潜在风险。

SATB 的核心思想是创建一个对象图,类似于堆的逻辑快照,以确保在并发标记阶段能够鉴别出所有的垃圾对象。当赋值语句发生时,应用会改变其对象图,因此 JVM 需要记录被覆盖的对象。为此,写前屏障会在引用变更前,将值记录在 SATB 日志或缓冲区中。每个线程都有一个独占的 SATB 缓冲区,初始时有 256 条记录空间。当空间用尽时,线程会分配新的 SATB 缓冲区继续使用,而旧的缓冲区则加入全局列表。

在并发标记阶段,标记线程会定期检查和处理全局缓冲区列表的记录。然后,根据标记位图分片的标记位,扫描引用字段来更新 RSet(Remembered Set)。这个过程也被称为并发标记/SATB写前屏障。

并发优化线程 (Concurrence Refinement Threads)

G1 中使用基于 Urs Holzle 的快速写屏障,将屏障开销缩减到 2 个额外的指令。屏障将会更新一个 card table type 的结构来跟踪代间引用。当赋值语句发生后,写后屏障会先通过 G1 的过滤技术判断是否是跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与 SATB 类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。

并发优化线程 (Concurrence Refinement Threads) 专注于扫描日志缓冲区记录的卡片,以维护更新 RSet。可以通过设置参数 -XX:G1ConcRefinementThreads 来控制并发优化线程的最大数量,默认等于 -XX:ParallelGCThreads

并发优化线程一直保持活跃,一旦发现全局列表中有记录存在,就开始并发处理。如果记录增长迅速或者处理不及时,G1 会使用分层的方式调度,以使更多的线程处理全局列表。如果并发优化线程无法跟上缓冲区数量,Mutator 线程 (Java 应用线程) 可能会挂起应用并被加入来协助处理,直到全部处理完成。因此,需要尽量避免出现这种情况。

G1的垃圾回收流程

G1的GC模式

G1提供了两种GC模式,分别是Young GC(即YGC)和Mixed GC,它们都是完全停顿("Stop The World")的垃圾回收方式,需要暂停用户线程。而Full GC实际上是采用了Serial Old的GC方式,不是G1提供的GC模式,如下:

  • Young GC(年轻代GC):选择所有年轻代中的Region进行回收,通过控制年轻代的Region数量,即年轻代内存大小,来控制Young GC的时间开销。

  • Mixed GC(混合GC):同样选择所有年轻代中的Region,加上根据全局并发标记(global concurrent marking)统计的若干老年代Region进行回收。在用户指定的开销目标范围内,尽可能选择收益高的老年代Region。

  • **Full GC:**Mixed GC并非完全回收堆内存的Full GC,它只回收部分老年代的Region。如果Mixed GC无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,系统会使用Serial Old GC(Full GC)来收集整个GC heap。

以上3种GC模式中,Young GC和Mixed GC,是G1回收空间的主要活动。当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发Young GC进行垃圾回收;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1就会根据一定的条件(参考后面内容)来触发Mixed GC和Full GC。

GC 工作线程数(-XX:ParallelGCThreads)

JVM可以通过参数-XX:ParallelGCThreads来指定GC工作的线程数量。该参数的默认值并非固定,而是根据当前的CPU资源进行计算。如果用户没有指定,并且CPU核数小于等于8,则默认与CPU核数相等;如果CPU核数大于8,则JVM会经过计算得到一个小于CPU核数的线程数。用户也可以手动指定线程数,例如与CPU核数相等。

Young GC核心流程

Young GC的核心流程就是采用STW的方式对所有的Young Region进行回收,将需要晋升的对象复制到老年代,这个过程不仅需要遍历GC Roots 还需要对CSet进行扫描。

当应用程序刚开始运行的时候,堆内的可用空间较多,当Young Region满的时候,就会触发Young GC。这个过程中,G1收集器会将所有的Young Region,即Eden区,放到CSet当中。Eden分区中存活的对象将被复制到Survivor分区。原有的Survivor分区中存活的对象将根据存活次数(年龄)的任期阈值分别晋升到PLAB(Parallel Live Allocation Buffer)、新的Survivor区,或Old区,原有的Eden分区将被整体回收。

具体来说,当JVM无法将新对象分配到Eden区域时,会触发Young GC,也称为"evacuation pause",这是一个用户线程完全暂停的过程,虽然其中部分步骤是并行执行的,但整个过程会导致STW,如下:

  • **1. 选择收集集合(Choose CSet):**G1会在遵循用户设置的GC暂停时间上限的基础上,选择一个最大Eden区域数,将这个数量的所有Eden区域作为收集集合。

  • **2. 根处理(Root Scanning):**从GC Roots开始遍历,查找从roots引用直达到CSet的对象(存活对象),并将它们移动到Survivor区域。同时将存活对象引用的对象加入标记栈(Mark Stack),这一阶段确保CSet中存活的对象被正确复制。

  • **3.RSet扫描(Scan RS):**在RSet扫描之前会先更新RSet(Update RS),因为RSet是先写日志,再通过一个Refine线程进行处理日志来维护RSet数据的。这里的更新RSet就是为了保证RSet日志被处理完成,RSet数据完整才可以进行扫描。然后,遍历RSet,将可直达到CSet的对象(存活对象)移动到Survivor区,同时将存活对象引用的对象加入标记栈。

  • **4. 移动(Evacuation/Object Copy):**遍历标记栈,将栈内的所有对象(存活对象)复制到Survivor区域。这一步是整个"evacuation pause"的核心,确保所有需要移动的对象都被正确复制。这个过程的目标是将年轻代的存活对象转移到Survivor区域,并确保相关引用关系得以更新。整个"evacuation pause"是一个短暂的停顿,但会保证内存中的对象关系得以正确维护。

  • **5. 收尾步骤:**年龄超过晋升阈值的对象会直接移动到老年代区域。执行一些收尾工作,如Redirty(与并发标记配合使用)、Clear CT(清理Card Table)、Free CSet(清理回收集合)等,这些操作通常耗时很短。

Young GC还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。Young GC会将晋升对象尺寸总和、对象年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量-XX:TargetSurvivorRatio(默认50%)、最大任期阈值-XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。

Mixed GC核心流程------混合收集周期(Mixed GC Cycle)

Young GC和Mixed GC是G1回收内存的主要活动。当应用程序启动时,堆内存可用空间相对较大,此时只有在年轻代填满时才会触发Young GC。随着应用程序的运行,老年代内存逐渐增长。当老年代占整个堆的比例达到初始化堆占用百分比阈值(InitiatingHeapOccupancyPercent,默认为45%)时,G1开始准备进行老年代的收集。

但是G1收集器不会直接进行Mixed GC的收集阶段,而是先通过"并发标记周期"(Concurrent Marking Cycle)来标记出高收益的老年代分区,确定下次Mixed GC的CSet(Choose CSet),之后再执行混合收集周期(Mixed GC Cycle)。

并发标记周期 (Concurrent Marking Cycle)

当不断的Young GC达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,就会触发并发标记周期,它的执行过程类似于CMS(Concurrent Mark-Sweep),执行过程包括以下四个步骤:

  1. 初始标记(Initial Mark,STW):和CMS一样,这个阶段是需要STW的,只标记从GC Root开始直接可达的对象。

  2. 并发标记(Concurrent Marking):并行标记线程与应用程序线程一起执行,标记整个heap中的对象,并收集各个Region的存活对象信息。

  3. 最终标记(Remark,STW):标记在并发标记阶段发生变化的对象,准备进行回收,这个阶段主要是重新标记在并发标记阶段产生的新垃圾,需要STW。

    另外,由于G1采用的三色标记算法,在并发标记阶段可能会出现"多标"和"漏标"的情况。相对来说"多标"是可以容忍的,只需要等到下次GC的时候进行处理即可;但是"漏标"会导致正常使用的对象被释放,因此是不能容忍的。在CMS中会采用"写屏障 + 原始快照" 的方法来处理漏标问题,而这个方法需要将发生变化的对象进行重新标记。

  4. 筛选回收(Cleanup):筛选回收环节会对Old Region的回收价值和成本进行排序,根据用户指定的停顿时间等来确定回收性价比最高的Region。这个阶段和CMS不同,是需要STW的。

初始标记(Initial Mark,STW)

初始标记(Initial Mark)的任务是标记所有直接可达的根对象,如原生栈对象、全局对象和JNI对象。由于根是对象图的起点,这个阶段需要暂停Mutator线程(Java应用线程),即需要一个STW的时间段。

实际上,当达到IHOP阈值时,G1并不会立即启动并发标记周期,而是等待下一次年轻代收集。这时,它利用年轻代收集的STW时间段来完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停期间,分区的NTAMS都被设置到分区顶部Top。初始标记是并发执行的,直到所有分区都得到处理。

初始标记暂停结束后,年轻代收集已完成对象复制到Survivor的工作,应用线程开始活跃。为了保证标记算法的正确性,所有新复制到Survivor分区的对象都需要进行扫描并标记为根。这个过程被称为根分区扫描(Root Region Scanning),同时扫描的Survivor分区也被称为根分区(Root Region)。

根分区扫描必须在下一次年轻代垃圾收集启动之前完成,因为在并发标记的过程中可能会被若干次年轻代垃圾收集打断。这确保了每次GC都有准确的存活对象集合。

并发标记(Concurrent Mark)

在并发标记阶段,并发标记线程与应用线程并发执行,并发标记阶段通过参数-XX:ConcGCThreads(默认为GC线程数的1/4,即-XX:ParallelGCThreads/4)控制启动数量。

每个线程负责扫描一个分区,从而标记出存活对象图。在这个过程中,处理Previous/Next标记位图,扫描标记对象的引用字段。并发标记线程定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。若开启参数-XX:+ClassUnloadingWithConcurrentMark,当一个类如果在Final Mark阶段不可达,将直接卸载。

标记任务必须在堆满之前完成扫描,否则会触发担保机制,经历一次长时间的串行Full GC。

存活数据计算(Live Data Accounting)是标记操作的附加产物,一旦对象被标记,将计算其字节数并记录到分区空间。只有NTAMS以下的对象会被标记和计算,标记周期结束时,Next位图将被清空,为下次标记周期做准备。

最终标记(Final Mark)

重新标记(Remark)是标记阶段的最后一步。在这个阶段,G1需要进行一次短时间的停顿,来处理剩余的SATB(并发标记产生的存活对象记录)日志缓冲区和所有的更新。

其目标是找出所有未被访问的存活对象,并安全地完成存活数据计算。这个阶段也是并行执行的,可以通过参数-XX:ParallelGCThread来设置在GC暂停时可用的并行GC线程数。

与此同时,引用处理也是重新标记阶段的一部分,对于所有强引用、软引用、弱引用、虚引用以及最终引用的对象,都会在引用处理中产生一些额外的开销。

筛选回收(Clean Up)

筛选回收的阶段,同样是一个STW(Stop-The-World)的过程,并且是并发执行的,主要会进行以下主要操作:

  • RSet梳理: 启发式算法会根据活跃度和RSet尺寸对分区进行不同等级的定义。同时,RSet的梳理也有助于发现无用的引用。可以通过参数-XX:+PrintAdaptiveSizePolicy来开启打印启发式算法决策的详细信息。

  • 整理堆分区: 为Mixed GC Cycle识别回收效益高(基于释放空间和暂停目标)的Old Region集合。

  • 识别所有空闲分区: 即发现无存活对象的分区,这些分区在筛选回收阶段可以直接回收,无需等待下次Mixed GC Cycle。

筛选回收阶段结束之后,就可能会启动Mixed GC Cycle,Mixed GC Cycle会根据该过程中整理出的回收效益高的分区来决定是否要将这些Region加入CSet并进行回收。

混合回收周期(Mixed GC Cycle)

当G1发起并发标记周期之后,并不会马上开始Mixed GC Cycle,G1会先等待下一次Young GC,然后在该收集阶段中,确定下次混合收集的CSet(Choose CSet),之后才会决定是否开启混合回收周期。

单次的Mixed GC与Young GC在操作上别无二致,可以参考前面的Young GC的流程。但是Mixed GC为了减小垃圾回收的停顿时间,Old Region可能无法在一次Mixed GC中被完全处理。

因此,G1会启动连续多次的Mixed GC,这一系列的Mixed GC被称为混合收集周期(Mixed Collection Cycle)。在Mixed GC Cycle中,G1会进行一系列的计算和决策,来确定单次收集的分区数量以及是否结束Mixed GC:

  • 计算每次加入到CSet中的分区数量;
  • 确定混合收集进行的次数;
  • 在上一次Young GC以及接下来的Mixed GC中,确定下一次加入CSet的分区(Choose CSet)的Region;
  • 确定是否结束Mixed GC Cycle,以及是否启动Full GC;

这些计算和决策是为了优化整个混合收集周期,使得在每次垃圾回收中都能有效地处理老年代的分区,同时控制停顿时间,以提高应用程序的响应性。

Young GC、Mixed GC 和 Full GC的触发时机

  • Young GC触发时机:

    Young GC的触发时机是当年轻代空间逐渐填满,无法再Eden区域分配新的对象时。

  • Mixed GC****触发时机:

    Mixed GC的触发取决于一些参数的设定,这些参数包括:

参数 含义
G1HeapWastePercent 在全局并发标记结束后,系统检查Old Regions中即将被回收的垃圾百分比,只有达到此参数设定的百分比时,下次才会触发Mixed GC
G1MixedGCLiveThresholdPercent Old Region中存活对象的占比,只有在此参数设定的阈值以下,Region才会被选入CSet(Collection Set,回收集)
G1MixedGCCountTarget 一次全局并发标记后,最多执行Mixed GC的次数
G1OldCSetRegionThresholdPercent 一次Mixed GC中可选入CSet的最多老年代Region数量的百分比
  • Full GC****触发时机:

    在G1垃圾收集器中,当堆空间无法分配新的分区时,即无法满足对象的分配需求时,G1会启动担保机制,执行一次完全暂停(STW)的、Serial Old单线程的Full GC。Full GC的过程涉及整个堆的标记、清理和压缩,最终留下只包含存活对象的堆。Full GC发生时,G1会在日志中记录 "to-space-exhausted" 和 "Evacuation Failure",以下是触发Full GC的一些场景,:

    • 从Young Region拷贝存活对象时:当G1尝试将Young Region的存活对象拷贝到其他分区时,如果找不到可用的Free Region,就会触发Full GC。
    • 从Old Region转移存活对象时:在尝试将Old Region的存活对象转移到其他分区时,如果找不到可用的Free Region,同样会触发Full GC。
    • 分配巨型对象时:当G1需要为巨型对象分配内存空间时,如果在Old Region找不到足够的连续分区,就会触发Full GC。

    这些情况下,Full GC是G1的一种应对措施,但由于Full GC的代价昂贵,会导致暂停时间较长,因此应该尽量避免触发Full GC。

总结

G1是一款卓越的垃圾收集器,适用于大型堆内存应用,并简化了性能调优的工作。通过主要参数设置初始堆、最大堆以及最大允许的GC暂停目标,可以获得良好的性能。尽管G1对内存空间的浪费较高,但通过"首先收集尽可能多的垃圾"的设计原则,能够及时发现过期对象,保持内存占用在合理水平。

G1的垃圾收集过程包括初始标记、并发标记、重新标记、清除、转移回收等步骤,同时以一个串行收集器做担保机制。然而,与其他垃圾收集器的过程描述不同,G1的设计原则是"Garbage First",不等待内存耗尽开始垃圾收集,而是在内部采用启发式算法,在老年代找出高收集收益的分区进行收集。

G1还能根据用户设置的暂停时间目标自动调整年轻代和总堆大小,使得暂停目标越短,年轻代空间越小、总空间越大。采用内存分区的思路,G1将内存划分为相等大小的内存分区,在回收时以分区为单位进行操作,将存活的对象复制到另一个空闲分区中。由于操作单位相等,G1天然是一种压缩方案(局部压缩)。

尽管G1是分代收集器,但内存分区不存在物理上的年轻代与老年代的区别,也无需独立的survivor(to space)堆。G1只有逻辑上的分代概念,每个分区都可能在不同代之间前后切换。

G1的收集过程是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集)。这种方式即使在堆内存很大时,也可以限制收集范围,从而降低停顿。

相关推荐
程序员岳焱8 分钟前
Java 与 MySQL 性能优化:MySQL 慢 SQL 诊断与分析方法详解
后端·sql·mysql
龚思凯14 分钟前
Node.js 模块导入语法变革全解析
后端·node.js
天行健的回响17 分钟前
枚举在实际开发中的使用小Tips
后端
wuhunyu22 分钟前
基于 langchain4j 的简易 RAG
后端
techzhi22 分钟前
SeaweedFS S3 Spring Boot Starter
java·spring boot·后端
写bug写bug1 小时前
手把手教你使用JConsole
java·后端·程序员
苏三说技术2 小时前
给你1亿的Redis key,如何高效统计?
后端
JohnYan2 小时前
工作笔记- 记一次MySQL数据移植表空间错误排除
数据库·后端·mysql
程序员清风2 小时前
阿里二面:Kafka 消费者消费消息慢(10 多分钟),会对 Kafka 有什么影响?
java·后端·面试
CodeSheep3 小时前
宇树科技,改名了!
前端·后端·程序员