一. 概述

在 G1 垃圾回收器正式引入前,CMS(Concurrent Mark Sweep,并发标记清除)作为 JVM 中主流的垃圾回收器,虽凭借 “低停顿” 特性在互联网、电商等对响应时间敏感的场景中广泛应用,但随着应用规模扩大和内存需求提升,其设计层面的缺陷逐渐暴露,成为影响系统稳定性和性能的关键瓶颈:

  1. 内存碎片问题严重,触发 Full GC 风险高:CMS 的 “清除” 阶段采用标记 - 清除(Mark-Sweep)算法,而非标记 - 整理(Mark-Compact)算法 —— 这意味着它在回收无用对象后,仅会标记出内存中的空闲区域,不会对存活对象进行移动和整理。随着垃圾回收次数增加,内存空间会逐渐被分割成大量不连续的 “碎片”,这些碎片虽然总容量可能满足对象分配需求,但由于单个碎片的空间小于待分配对象的大小,导致新对象无法正常分配,最终触发Full GC。Full GC 会暂停所有用户线程(STW,Stop-The-World),并采用 Serial Old 回收器进行内存整理,其停顿时间通常达到数百毫秒甚至数秒,对于每秒处理数千笔交易的电商系统、实时通信平台等场景,这种级别的停顿会直接导致请求超时、服务不可用,严重影响用户体验。更关键的是,内存碎片的积累具有 “不可逆性”—— 只要应用持续运行,碎片会不断增多,Full GC 的触发频率会逐渐升高,系统稳定性会持续下降。
  2. 不支持大内存场景,扩展性差:CMS 的并发标记和清除阶段依赖 “遍历内存区域” 的方式,内存越大,遍历所需的时间越长,并发回收的效率越低,“并发模式失败” 的概率越高;另一方面,大内存环境下的内存碎片问题会更严重 —— 单次 Full GC 整理大内存的时间可能达到数秒,对系统可用性的影响呈指数级增长。此外,CMS 仅针对老年代进行回收,年轻代仍需依赖 ParNew 等回收器,这种 “分代回收” 的架构导致年轻代和老年代的回收策略无法协同优化。例如,年轻代的 Minor GC 频率过高时,会频繁触发老年代的 “晋升” 操作,加速老年代内存占用,间接增加 CMS 的回收压力,进一步暴露其性能缺陷。
  3. Full GC优化:G1 作为替代 CMS 的回收器,虽然通过 “区域化回收”“预测性停顿” 减少了 Full GC 频率,但在极端情况下仍可能触发,早期 G1、CMS 的 Full GC 依赖串行回收器,单线程处理整堆内存,STW 时间随堆大小线性增长。例如,32GB 堆内存的 Full GC 可能导致 10 秒以上的停顿,这与 G1 “支持大内存” 的设计目标严重冲突。JDK10 通过 JEP 307 引入了 G1 的并行 Full GC 机制,核心改进为:采用多线程并行执行 “标记 - 整理”,线程数由-XX:ParallelGCThreads控制(默认与 CPU 核心数相关),充分利用多核资源。
  4. 使用门槛相对较高:上百种调整参数大大增加了CMS垃圾回收器的使用门槛。

二. 垃圾回收器回收方式

JVM垃圾回收器基于分代管理和回收算法,结合回收的方式,实现了串行回收器、并行回收器、CMS、G1、ZGC和Shenandoah。这些垃圾回收器从程序执行方式的角度可以分为以下3类:

  • 串行执行:应用程序和垃圾回收器交替执行,垃圾回收器执行的时候应用程序暂停执行。串行执行指的是垃圾回收器有且仅有一个后台线程执行垃圾对象的识别和回收。
  • 并行执行:应用程序和垃圾回收器交替执行,垃圾回收器执行的时候应用程序暂停执行。并行执行指的是垃圾回收器有多个后台线程执行垃圾对象的识别和回收,多个线程并行执行。
  • 并发执行:应用程序和垃圾回收器同时运行,除了在某些必要的情况下垃圾回收器需要暂停应用程序的执行,其余的时候在应用程序运行的同时,垃圾回收器的后台线程也运行,如标识垃圾对象并回收垃圾对象所占的空间。

2.1 串行执行

使用单线程进行垃圾回收,在回收时应用程序(mutator)都需要执行暂停(Stop The World,STW)。新生代通常采用复制算法,老生代通常采用标记压缩算法。串行回收典型的执行过程如下图所示:

本文的图例中没有mutator运行的区间都是指STW。实际上串行回收中的老生代回收不仅仅回收老生代,还回收新生代。图中一个箭头表示一个线程,此图中执行垃圾回收过程只有一个箭头,表示只有一个后台线程执行回收任务。深色箭头表示的是垃圾回收工作线程,空心箭头表示应用程序线程。

2.2 并行回收

使用多线程进行垃圾回收,在回收时应用程序需要暂停,新生代通常采用复制算法,老生代通常采用标记压缩算法。并行回收的执行过程如下图所示。
在并发回收时,如果发现内存不足,需要对整个堆进行垃圾回收(也就是我们常说的Full GC,也称为FGC),在Full GC时需要STW,并且是串行回收。

2.3 并发回收(CMS)

整个回收期间划分成多个阶段:初始标记、并发标记、重新标记、并发清除等。在初始标记和重新标记阶段需要暂停应用程序线程,在并发标记和并发清除期间工作线程可以和应用程序并发运行。这个算法通常适用于老生代,新生代可以采用并行复制回收,也可以采用串行复制算法。CMS垃圾回收的执行过程如图所示:

同样,在老生代回收时,因为是并发执行,如果在分配内存时发现内存不足,则需要进行FGC,也需要STW并对整个内存进行串行回收。

JVM领域需要区分并行回收和并发回收:

  • 并行回收(Parallel GC):核心是 “多 GC 线程并行执行,用户线程全程阻塞”—— 回收期间,多个 GC 线程协同工作(如 4 核 CPU 启动 2-4 个 GC 线程),但所有用户线程会被暂停(STW),直到回收完成。其优化方向是 “缩短单次 STW 时间”,通过多核并行提升回收效率。

  • 并发回收(Concurrent GC):核心是 “GC 线程与用户线程并发执行,仅部分阶段 STW”—— 回收的关键阶段(如标记、清除)与用户线程同时运行,仅在必要环节(如初始标记、重新标记)短暂暂停用户线程。其优化方向是 “减少 STW 总时长”,尽可能降低 GC 对业务响应性的影响。

三. G1基本特性

从执行方式来看,垃圾回收器的发展经历了最初期的串行执行,到并行执行用于提高执行效率,再到目前主流的并发执行用于减少垃圾回收器停顿时间。第一款成熟的并发执行垃圾回收器是CMS。CMS是一款非常成功的垃圾回收器,是使用最多和最广的垃圾回收器,但是其复杂性(有上百个参数)给程序员的使用带来了不便,所以需要设计一款简单的垃圾回收器来替代CMS,G1应运而生。

G1是从JDK 7 Update 4及后续版本开始正式提供的。G1致力于在多CPU和大内存服务器上对垃圾回收提供软实时目标(soft real-time goal)和高吞吐量(high throughput)。目前G1已经相当成熟,从众多的测评结果上看,也达到了G1最初的设计目标。从JDK 9开始G1作为默认的垃圾回收器,目前已经有不少公司开始在生产环境中逐步使用G1。

3.1 实时性

G1 最大的特征是非常重视实时性。本节首先会介绍一般意义上的实时性,然后再探讨 G1GC 中的实时性是什么样的。

处理实时性的要求是,它必须能在最后期限(deadline)之前完成。最后期限可以自由指定。如果指定的期限较短,那么程序就要保证在短时间内完成处理;相反,如果指定的期限较长,那么程序只要能保证在这个较长的时间内完成处理就可以了。另外,即使同为实时程序,如果处理内容不同,最后期限的重要性也会很不一样。有些处理只要超出最后期限一次,就会带来致命的问题,而有些处理稍微打破几次最后期限也不会有太大的问题。这两种处理分别称为“硬实时性(hard real-time)处理”和“软实时性(soft real-time)处理”。

硬实时性的处理,多存在于保护人类免于受伤、远离危险,以安全为第一位的场景中。例如,医疗机器人控制系统、航空管制系统等都会要求硬实时性。如果这类系统中的处理超出了最后期限,很可能出现致命的问题。而且,硬实时性的处理必须在处理开始后的很短时间内完成。

软实时性处理多用于稍微超出几次最后期限也没什么问题的系统中,例如网络银行系统。用户总会期待所有的交易都能完美地处理好,但是稍微超出几次最后期限,比如交易完成界面的展示慢了一些,应该也不会构成致命的问题。

软实时性的处理可以超出最后期限,但超出期限的频率很重要。只有超出频率在用户能够容忍范围之内的处理,我们才能说它具备软实时性。

3.2 可预测性

对于实时处理来说,真正重要的特征是“可预测性”。

实时处理必须尽力保证不超出最后期限。因此相比高速性,可预测性更重要一些。

这里所说的可预测性,指的是“可以预测处理大约会耗费多长时间”。即使处理速度再快,如果无法在执行前预测出需要的时间,处理也是没有使用价值的,因为该处理存在随时超出最后期限的可能。如果能够预测出大致的处理时间,就可以据此来评估是否会超出最后期限。如果有超出期限的可能,就可以事先采取应对措施,例如对处理内容进行分解。

因此,在保证实时性方面,可预测性是一个重要的因素。

3.3 G1中的实时性

G1 具有软实时性。为了实现软实时性,它具备以下两个功能:

  1. 设置期望暂停时间(最后期限):是支持用户自定义 mutator 暂停时间的功能。G1 具有软实时性,因此会尽力保证处理不超过该暂停时间。
  2. 可预测性:用来预测下次 GC 会导致 mutator 暂停多长时间的功能。根据预测出来的结果,G1 会通过延迟执行 GC、拆分 GC 目标对象等手段来遵守设置的期望暂停时间。通过这种方式,能够尽量减少超出用户期望暂停时间的频率,从而实现软实时性。

四. G1基本原理

CMS等一些前辈垃圾回收器针对堆空间的管理方式都是连续的,连续的内存将导致垃圾回收时收集时间过长,并会随着堆内存的扩大而不断劣化,停顿时间不可控。

G1将堆拆成一系列的分区(heap region),将整个堆内存(新生代 + 老年代)划分为多个大小相等且固定的内存区域(Region),每个 Region 的大小可通过 -XX:G1HeapRegionSize 参数配置(取值范围为 1MB~32MB,必须是 2 的幂次方,默认由堆内存总大小自动计算,如 16GB 堆默认划分为 2048 个 8MB 的 Region)。这样在一个时间段内,大部分垃圾回收操作就只是针对一部分分区执行,而不是整个堆或整个(老年)代,从而满足在指定的停顿时间内完成垃圾回收的动作。G1内存分区如图所示:

在G1里,新生代就是一系列的内存分区,这意味着不用再要求新生代是一个连续的内存块。类似地,老生代也是由一系列的分区组成。在JVM运行时,从内存管理角度不需要预先设置分区是老生代分区还是新生代分区,而是在内存分配时决定:当新生代需要空间时,则分区被加入新生代中;当老生代需要内存空间时,则分区被加入老生代中。事实上,G1通常的运行状态是:映射G1分区的虚拟内存随着时间的推移在不同的代之间切换。例如,一个G1分区最初被指定为新生代,经过一次新生代的回收之后,整个新生代分区都被划入待使用的分区中,那它既可以作为新生代分区使用,也可以作为老生代分区使用。很可能在完成一个新生代回收之后,一个新生代的分区在未来的某个时刻被用于老生代分区。同样,在一个老生代分区完成回收之后,它就成为待使用分区,在未来某个时候作为一个新生代分区使用。

G1新生代的回收方式是并行回收,采用复制算法。与其他JVM垃圾回收器一样,一旦发生一次新生代回收,所有的新生代区都会被回收。这就是我们常说的新生代回收(Young GC,YGC)。但是G1和其他垃圾回收器的不同之处在于:

  1. G1会根据预测时间动态地改变新生代的大小。需要注意的是其它垃圾回收器新生代的大小也可以动态地变化,但这个变化主要是依据内存的使用情况进行的。G1则是以预测时间为导向的,根据内存的使用情况,调证新生代分区的数目。

    G1 通过参数限制新生代占堆内存的比例,间接约束了新生代分区的最大 / 最小数目:

    • XX:G1NewSizePercent:新生代最小占比(默认 5%),即新生代分区数至少为「总 Region 数 × 5%」;
    • XX:G1MaxNewSizePercent:新生代最大占比(默认 60%),即新生代分区数最多为「总 Region 数 × 60%」。

    例如:总 Region 数为 1000 个,新生代分区数会在 50~600 个之间动态调整。这两个参数确保新生代不会过小(避免频繁 Minor GC)或过大(避免单次 Minor GC 停顿过长)。

    G1 的核心设计目标是 “可预测的停顿时间”,通过-XX:MaxGCPauseMillis参数指定(默认 200ms)。该目标直接决定新生代分区数的动态调整逻辑:

    • 若设置的暂停时间目标较严格(如 50ms),G1 会减少新生代分区数(缩小新生代大小),因为较小的新生代回收速度更快,更容易满足短停顿目标;
    • 若暂停时间目标较宽松(如 500ms),G1 会增加新生代分区数(扩大新生代大小),减少 Minor GC 的触发频率,提升吞吐量。

    例如:应用设置MaxGCPauseMillis=100ms,若某次 Minor GC 实际停顿 120ms(超时),G1 会在下一轮减少新生代分区数(如从 300 个减至 250 个),以缩短回收时间。

  2. G1老生代的垃圾回收方式与其他JVM垃圾回收器对老生代处理有着极大的不同。G1老生代的回收不会为了释放老生代的空间而对整个老生代进行回收。相反,在任意时刻只有一部分老生代分区会被回收,并且这部分老生代分区将在下一次增量回收时与所有的新生代分区一起被回收,这就是我们所说的混合回收(Mixed GC)。在选择老生代分区时,优先考虑垃圾多的分区。

老生代分区的选择涉及G1的并发标记算法,这个过程称为“并发标记阶段”。并发标记是指并发标记线程和应用程序线程同时运行,它有4个典型的子阶段:初始标记、并发标记、再标记和清理四个子阶段。

  1. 初始标记子阶段

负责标记所有从根集合直接可达的对象,根集合是对象图的起点,初始标记需要将应用程序线程暂停,也就是需要一个STW的时间段。在混合回收中的初始标记子阶段和新生代的初始标记几乎一样。实际上混合回收的初始标记子阶段是借用了新生代回收的结果,即新生代垃圾回收后的新生代Survivor分区作为根,所以混合回收一定发生在新生代回收之后,且不需要再进行一次初始标记。这就是所谓的“借道”。

  1. 并发标记子阶段(并发执行)

当YGC执行结束之后,如果发现满足并发标记的条件,并发线程就开始进行并发标记。根据新生代的Survivor分区开始并发标记。并发标记的时机是在YGC后,只有内存消耗达到一定的阈值后才会触发。在G1中,这个阈值通过参数 InitiatingHeapOccupancyPercent 控制(默认值是45,表示的是当已经分配的内存加上本次将分配的内存超过内存总容量的45%时就可以开始并发标记)。多个并发标记线程启动,每个线程每次只扫描一个分区,从而标记出存活对象。在标记的时候还会计算存活对象的数量,同时会计算存活对象所占用的内存大小,并计入分区空间。

并发标记子阶段会对所有分区的对象进行标记。这个阶段并不需要STW,故标记线程和应用程序线程并发运行。使用Snapshot-At-The-Beginning(SATB)算法进行并发标记。

  1. 再标记子阶段(并行执行)

再标记是最后一个标记阶段。在该阶段中,G1需要一个STW的时间段,找出所有未被访问的存活对象,同时完成存活内存数据计算。引入该阶段是为了能够达到结束标记的目标。要结束标记过程,需要满足3个条件:

  • 从根(survivor)出发并发标记子阶段已经标记出所有的存活对象。
  • 标记栈是空的。
  • 所有的引用变更对象都被处理了。这里的引用变更对象包括新增空间分配的对象和引用变更对象,新增空间所有对象被认为都是活跃的(即使对象已经“死亡”也没有关系,在这种情况下只是增加了一些浮动垃圾),引用变更处理的对象通过一个队列记录,在该子阶段会处理这个队列中所有的对象。

前两个条件是很容易满足的,但是满足最后一个条件是很困难的。如果不引入一个STW的再标记过程,那么应用会不断地更新引用,也就是说,会不断地产生新的引用变更,因而永远无法达成完成标记的条件。

  1. 清理子阶段(并行执行)

再标记子阶段之后是清理子阶段,该子阶段也需要一个STW的时间段。清理子阶段主要执行以下操作:

  • 统计存活对象,统计的结果将会用来排序分区,以用于下一次的垃圾回收时分区的选择。
  • 交换标记位图,为下次并发标记做准备。
  • 把空闲分区放到空闲分区列表中。这里的空闲分区指的是全都是垃圾对象的分区,如果分区中还有活跃对象,则不会释放,真正释放的动作发生在混合回收中。

该阶段比较容易引起误解的地方在于,清理子阶段并不会清理垃圾对象,也不会执行存活对象的复制。也就是说,在极端情况下,该阶段结束之后,空闲分区列表将毫无变化,JVM的内存使用情况也毫无变化。

并发标记阶段完成之后,在下一次进行垃圾回收的时候就会回收垃圾比较多的老生代分区。这时进行的垃圾回收称为混合回收,混合回收和YGC最大的区别就是混合回收不仅仅回收所有的新生代分区,也回收部分垃圾多的老生代分区,所以JVM在实现混合回收时重用了YGC所有的代码,两者的不同之处就在于是否回收老生代分区。整个G1垃圾回收的过程如下图所示:

tips:并发标记阶段中还可以发生YGC(可以是一次YGC,也可以是多次YGC),但为了简化并未体现;另外,在图中混合回收也可能发生多次,因为G1对停顿时间是有要求的,G1会根据预测的停顿时间决定一次回收老生代分区的数目,所以可能需要多次混合回收,才能完成并发标记阶段识别的垃圾比较多的老生代分区的回收。

最后,同样在垃圾回收过程或者并发执行过程中,当内存不足需要进行Full GC时,也需要STW对整个内存进行串行回收。在JDK 10中对FGC做了改进,把串行回收改进成并行回收,注意是并行的Full GC,而不是并发回收。

前文已介绍 G1 通过 “区域化堆内存(Region)” 和 “混合回收(Mixed GC)” 实现可预测停顿,但 “部分回收” 设计需先解决两个核心问题,这也正是后续RSet(记忆集)SATB(初始快照算法) 章节要深入探讨的内容:

  1. 如何快速定位外部引用?
    G1 回收部分 Region(如某老年代 Region)时,需确认该 Region 内对象是否被其他非目标 Region(如新生代、其他老年代 Region)引用。若直接遍历全堆查找,会违背 “部分回收控停顿” 的初衷,需一种结构提前记录跨 Region 引用 —— 这是 RSet 要解决的问题。
  2. 如何保证并发标记准确?
    并发标记阶段,用户线程会动态修改对象引用(如变更、删除引用),若不处理这种 “边标记边修改”,易出现漏标(丢失存活对象)或多标(内存泄漏)。需一种机制确保标记快照与内存实际状态一致 —— 这是 SATB 要解决的问题。

五. RSet(记忆集)

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

  • 卡片(Card): G1将每个分区(Region)进一步划分为大小为512 Byte的卡片,这是最小的标记单位,对象的分配和回收都以卡片为基本单位进行,这个卡片的大小是可以进行设置和调整的。
  • 全局卡表(Global Card Table): 所有分区的卡片状态会被记录在全局卡片表中,这个表维护了对每个卡片的引用状态,用于在垃圾回收过程中准确地追踪对象引用关系。
  • 对象分配物理连续性: 分配的对象会占用物理上连续的若干个卡片,这种设计有助于提高内存的利用率,并简化对内存的管理。
  • 引用查找: 对跨分区(Region)对象引用的查找通过记录卡片(Card)的方式实现,G1维护了一个叫做RSet(Remembered Set)的数据结构,用于记录在分区内对象的引用关系。
  • 回收处理: 每次对内存的回收都涉及对指定分区的卡片进行处理。通过检查RSet中的信息,G1能够确定哪些对象是不再被引用的,从而进行相应的回收操作。

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

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

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

5.1 为什么需要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都要进行一次Full GC才能找到所有引用关系,保证不发生漏标。但是,这种方法无异于高射炮打蚊子,得不偿失,会导致G1垃圾回收器效率极低。

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

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

5.2 实现方式

由于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机制

5.2.1 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的记录密度,以平衡空间占用和引用追踪的性能。这种策略的实施有助于在分区受欢迎的情况下最大限度地减少对可用空间的影响。

5.2.2 RSet记录的引用类型

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

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

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

5.2.3 RSet 如何维护

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

img

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

  • 写前屏障 (Pre-Write Barrier):即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象。那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用。此时,JVM需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。
  • 写后屏障 (Post-Write Barrier):当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用。那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后屏障发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。

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

六. SATB(初始快照算法)

6.1 并发标记阶段的漏标问题

漏标问题指的是原本应该被标记为存活的对象,被遗漏标记为黑色,从而导致该垃圾对象被错误回收。 例如下图中,假设我们现在遍历到了节点 E,此时应用执行如下代码。这时候因为 E 对象没有引用了 G 对象,因此扫描 E 对象的时候并不会将 G 对象标记为黑色存活状态。但由于用户线程的 D 对象引用了 G 对象,这时候 G 对象应该是存活的,应该标记为黑色。但由于 D 对象已经被扫描过了,不会再次扫描,因此 G 对象就被漏标了。

1
2
3
var G = objE.fieldG; 
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; // 黑色D 引用 白色G

漏标问题就非常严重了,其会导致存活对象被回收,会严重影响程序功能。

6.2 G1解决方式

G1通过引入 SATB 算法,即破坏第二个条件:「所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用」

既然灰色对象在扫描完成前删除了对白色对象的引用,那么我是否能在灰色对象取消引用之前,先将灰色对象引用的白色对象记录下来。随后在「重新标记」阶段再以白色对象为根,对它的引用进行扫描,从而避免了漏标的问题。通过这种方式,原本漏标的对象就会被重新扫描变成灰色,从而变为存活状态。

这种方式有个缺点,就是会产生浮动垃圾。 因为当用户线程取消引用的时候,有可能是真的取消引用,对应的对象是真的要回收掉的。这时候我们通过这种方式,就会把本该回收的对象又复活了,从而导致出现浮动垃圾。但相对于本该存活的对象被回收,这个代价还是可以接受的,毕竟在下次 GC 的时候就可以回收了。

SATB 算法为了解决漏标问题,在修改引用 objE.fieldG 之前插入了一个 Write Barrier,记录下被覆写之前的引用地址。这些地址最终也会被 Marking 线程处理,从而确保了所有在 Marking 开始时的引用一定会被标记到。这个 Write Barrier 伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
void pre_write_barrier(oop* field, oop new_value) {
// 1. 获取要被覆盖的旧值(即将被断开引用的对象)
oop old_value = *field;

// 2. 过滤条件:只有在并发标记阶段且旧值不为空时才需要记录
if (is_concurrent_marking_active() && old_value != null) {
// 3. 核心操作:将旧值(即将丢失引用的对象)压入一个线程局部的缓冲区
satb_mark_queue_buffer.push(old_value);
}

// 4. 然后才执行真正的赋值操作:self.field = new_value;
}

步骤解释:

  1. 捕获旧引用:在将字段指向新对象之前,先记住它原来指向谁(old_value)。
  2. 条件判断:只有在并发标记正在进行中,并且旧引用确实指向某个对象时,才有必要记录。
  3. 记录关键信息:将 old_value 这个对象压入一个叫做 SATB 标记队列 的缓冲区中。这个操作的含义是:“注意!这个对象(old_value)在标记开始时是被人引用的,现在它的一个引用要被删除了。为了保证快照的完整性,我必须把它记下来,确保它最终会被标记。”
  4. 执行赋值:然后才进行真正的赋值操作,更新引用关系。

现在我们将写屏障融入到整个并发标记阶段来看:

  1. 初始标记(Initial Mark)(STW):
    • 暂停所有应用线程(STW)。
    • 从 GC Roots 出发,标记所有直接可达的对象。速度很快。
    • 此时,逻辑上的“快照”已经形成。
  2. 并发标记(Concurrent Marking)
    • 恢复应用线程,GC 线程与应用线程并发执行。
    • GC 线程继续从初始标记标记到的对象开始,深度遍历对象图,标记所有存活对象。
    • 与此同时,应用线程不断运行,修改引用关系。每次有引用被断开时,写屏障都会将被断开引用的对象(old_value)记录到线程本地的 SATB 队列中。
  3. 最终标记(Remark)(STW):
    • 再次暂停所有应用线程。
    • 处理 SATB 队列:GC 线程会遍历所有应用线程的 SATB 标记队列,将队列中记录的所有对象作为新的根,重新扫描一遍。
    • 标记这些在并发阶段可能被“遗漏”的对象。因为它们是快照的一部分,必须被标记。
    • 这个过程完成后,标志着整个“快照”中所有的存活对象都已被标记。
  4. 清理阶段(Cleanup)
    • 根据最终标记的结果,统计出每个区域的存活对象比例,识别出完全空闲的区域(Eden/Survivor 区)和需要被回收的旧区域(Old Region)。
    • 真正回收垃圾是在后续的 复制/清理阶段(Evacuation) 进行的。

6.3 SATB 的优势与代价

优势:

  • 强不变式:完美解决了并发标记期间的“对象消失”问题,保证了收集的正确性。
  • 效率:相对于另一种解决方案(CMS增量更新算法),SATB 在处理大量引用插入的场景(如新建对象)时更有优势,因为它不需要处理新的引用关系。它只关心“旧的、可能丢失的”引用。
  • 减少停顿:将大部分标记工作并发化,只在初始标记和最终标记时有很短的 STW 停顿。

代价:

  • 浮动垃圾:SATB 是“过犹不及”的策略。它会将一些在标记开始后已经“死亡”的对象(即被断开了引用,但被写屏障捕获的对象)仍然标记为存活。这些对象要等到下一次 GC 才能被回收,这就是“浮动垃圾”。这是为了保证正确性而做出的合理牺牲。
  • 运行时开销:每次写入引用字段都会执行写屏障代码,虽然 JVM 做了极致优化(例如使用线程本地队列、批量处理等),但这仍然会带来一定的性能开销,通常会使应用吞吐量降低几个百分点。

七. G1常用配置

参数名 说明
-XX:G1HeapRegionSize=n 设置Region大小,并非最终值
-XX:MaxGCPauseMillis 设置G1收集过程目标时间,默认值200ms,不是硬性条件
-XX:G1NewSizePercent 新生代最小值,默认值5%
-XX:G1MaxNewSizePercent 新生代最大值,默认值60%
-XX:ParallelGCThreads STW期间,并行GC线程数
-XX:ConcGCThreads=n 并发标记阶段,并行执行的线程数
-XX:InitiatingHeapOccupancyPercent 设置触发标记周期的 Java 堆占用率阈值。默认值是45%。这里的java堆占比指的是non_young_capacity_bytes,包括old+humongous

八. 说明

因笔者能力所限,本文撰写过程中参阅并转引了大量相关文章与书籍资料。

《深入Java虚拟机JVM G1GC的算法与实现》

《新一代垃圾回收器ZGC设计与实现》

深入解析G1垃圾回收器 - Booksea - 博客园

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