JAVA-GC浅析(二)G1回收器

47 阅读13分钟

G1的内存布局

G1的内存布局不再像之前的收集器那样将整个内存区域划分成固定大小和固定数量的新生代和老年代区,而是将整个内存划分为大小相同的多个Region块,每个Region都可以是Eden区,Survivor区,或者Old区,并且新生代和老年代区域并没有明确的界限,都只是若干个region的动态集合。G1会根据Region实际扮演的区使用不同的收集策略,这也是G1不同于之前的收集器最大的点,即收集时针对所有Region,不再只针对新生代或者老年代(这就是G1的Mixed-GC模式),因此G1也无需和其他收集器搭配使用。

此外,G1除了传统的Eden区,Survivor区,Old区,还增加了一种名为Humongous(巨大无比的)的区,顾名思义,该区专门用来存放巨大的对象,其标准是占到一半Region的大小,每个Region的大小可以通过参数 -XX:G1HeapRegionSize设定,取值范围为1~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在多个连续的Humongous区之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

G1-内存布局.drawio.png

G1的两种回收模式

Young GC

采用标记-复制算法回收所有的新生代region,G1的YGC全程需要暂停用户线程,GC多线程并行执行。每次YGC后,Eden和Survivor的大小会被重新计算,计算过程中用户设置的停顿时间目标也会被考虑进去,如果需要的话,它们的大小可能调大,也可能调小。

流程:

  1. 暂停用户线程
  2. 扫描GCRoots,注意这里扫描的GC Roots就是一般意义上的GC Roots,是扫描的直接指向young代的对象,那如果GC Root是直接指向老年代对象的,则会直接停止在这一步,不往下扫描了(因为没有必要往下扫描了,进行的是Young GC,如果老年代对象引用了新生代,后续可以通过扫描RSet来发现)
  3. 更新Rset,确保老年代对年轻代引用的正确性(下面会具体说明如何更新RSet)
  4. 扫描RSet,扫描所有Rset中Old区到young区的引用
  5. 拷贝对象到survivor区域或者晋升到老年代,这个过程中会通过写屏障更新RSet,为后续可能的Mixed GC做好准备
  6. 处理引用队列,软引用,弱引用,虚引用

特点:执行代价低,频率高

Mixed GC

选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region

流程:

Mixed GC步骤主要分为两步:

(1)全局并发标记(global concurrent marking)

(2)拷贝存活对象(evacuation)

全局并发标记步骤:

(1)初始标记

这个阶段会标记从GC Root开始直接可达的对象,其实就是在young gc执行时暂停用户线程这段时间内执行的初始标记,充分利用了这段代价高昂的STW时间。

(2)根分区扫描

会扫描survivor区域(survivor分区就是根分区),将所有被survivor区域对象引用的老年代对象标记。这也是上一步需要young gc的原因,处理跨代引用时需要知道哪些old区对象被S区对象引用。这个过程因为需要扫描survivor分区,所以不能发生young gc,如果扫描过程中新生代被耗尽,那么必须等待扫描结束才可以开始young gc。这一步耗时比较短,因为进行了一轮young gc之后,survivor分区存储的对象数量比较少。

(3)并发标记

从根分区可直达对象开始对堆中对象进行可达性分析(使用RSet进行辅助),找出各个region的存活对象信息,耗时较长,同时,并发标记过程中,大概计算每个区域的对象存活的比例和计算回收垃圾的时间。

(4)重新标记

该阶段需要暂停所有用户线程,修复并发标记阶段由于用户线程并发执行导致标记状态发生变化的对象。G1这个remark与CMS的remark有一个本质上的区别,那就是只需要扫描SATB buffer,而CMS的remark需要重新扫描dirty card外加整个根集合(这时候年轻代也是根集合的一部分),而此时整个年轻代(不管对象死活)都会被当作根集合的一部分,因而CMS remark有可能会非常慢(所以,很多CMS优化建议配置参数:-XX:+CMSScavengeBeforeRemark,即在最终标记之前执行一次YGC,减轻最终标记的压力)

(5)清理阶段(Cleanup)

该阶段需要暂停所有用户线程,对各个region的回收价值和成本进行排序,根据用户期待的GC停顿时间指定回收计划,选中部分old region和全部的young region,这些被选中的分区称为Collection Set(Cset),同时回收掉这些完全空闲的Region,并将空间返回到可分配Region集合中。这个阶段不会有对象的拷贝动作

Evacuation阶段

该阶段需要暂停所有用户线程,这个阶段会根据CSet将存活的对象拷贝到全新的还未使用的Region中,然后回收掉之前的region。这一步是采用多线程复制清除,整个过程会STW。这也是G1的优势之一,只要还有一块空闲的region,就可以完成垃圾回收。而不用像CMS那样必须预留太多的内存。

耗时分析:

四个STW过程中,初始标记因为只标记GC Roots,耗时较短。重新标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。

GC过程中解决跨区引用的问题

当对新生代region进行垃圾回收时,如果某个新生代的region中的对象被某个老年代region中的对象所引用,那么这个对象就不能标记为可回收对象,亦或者进行Mixed回收时,老年代region中某些对象被survivor区域的对象引用。那么G1如何知道这个对象被跨区引用了呢,总不能扫描整个老年代/Survivor region,这样操作的效率过于低下,那么G1是如何解决这个问题的?答案是利用Card Table和Rset这两个数据结构。

Card Table

G1-Card Table.drawio.png

G1将整个内存区域分为若干个region,而每个region在逻辑上又分为若干个512字节的card page,每个card page对应card table中的一个元素,card table是一个全局性的结构,实际上就是一个字节数组,每个元素大小为1字节[1]

Remember Set

每个region都有一个对应的Remember Set(以下简称RSet),本质上是一个map,key是引用本region的其他region的起始地址,value,value里面存储的元素是引用方的对象所对应的Card PageCard Table中的下标。如下图所示,region2中的对象被region1和region3中的某些对象引用,region2的RSet中就会记录下region1和region3的起始地址以及引用对象所在的card page的索引[2]

G1-Rember Set.drawio.png

如此一来,当进行可达性分析时可以直接通过RSet来判断对象是否被跨region跨代使用。

RSet的更新流程

在G1垃圾回收器中,RSet(Remembered Set)的更新通过异步队列机制实现,核心是写屏障触发变更记录 + 脏卡片队列(DCQ)缓冲 + Refine线程消费更新,确保GC时引用关系准确,同时避免同步更新带来的性能损耗。以下是具体机制:


1)更新触发:写屏障记录变更

当程序修改对象引用关系时(如obj.field = newObj),G1的​​写后栅栏(Post-Write Barrier)​​ 会被触发:

  1. 检查引用双方是否在不同Region(跨Region引用才需记录)。
  2. 若跨Region,将​​被引用对象所在的卡片(Card,512B内存块)标记为脏卡片(Dirty Card)​​。
  3. 将该脏卡片信息​​写入线程本地的Dirty Card Queue(DCQ)​​ 缓冲队列。

​关键设计​​:写屏障仅记录卡片信息而非直接更新RSet,避免了同步锁竞争。


2)缓冲队列:DCQ的异步积累

  1. ​DCQ(Dirty Card Queue)​

    • 每个线程拥有独立的DCQ,用于临时存储脏卡片。
    • 当本地DCQ满时,会将其加入​​全局DCQ集合(DCQS)​​ 。
  2. ​避免同步开销​
    引用变更无需等待RSet更新,程序继续执行,GC暂停时间显著降低。


3)异步消费:Refine线程更新RSet

由后台的​​Refine线程池​​消费DCQ并更新RSet:

  1. ​线程池规模​
    默认线程数为G1ConcRefinementThreads + 1,可动态调整。

  2. ​消费逻辑​

    • 从DCQS中获取脏卡片,解析其指向的Region及卡片内对象。
    • 更新​​被引用对象所在Region的RSet​​,记录引用来源Region的卡片信息。
  3. ​弹性扩展​

    • 若DCQ积压严重(高并发场景),​​应用线程(Mutator)或GC线程​​会协助消费。
    • 极端情况下,GC线程在回收前会强制处理剩余DCQ。

⚠️ ​​临界点处理​​:若GC开始时仍有未处理的DCQ,回收线程将阻塞直到RSet更新完成,确保引用关系准确。


4)更新时机:GC前必须完成

RSet的更新​​不要求实时​​,但需在​​垃圾回收前完成​​:

  1. ​必要性​
    回收阶段依赖RSet定位跨代引用,未更新的RSet会导致漏标或误标(如已失效的引用未被移除)。
  2. ​时间窗口​
    G1利用两次GC间的空闲时间异步更新,大幅减少STW时间。

解释

[1] 之所以使用byte数组而不是bit数组主要是速度上的考量,现代计算机硬件都是最小按字节寻址的,没有直接存储一个bit的指令,所以要用bit的话就不得不多消耗几条shift+mask指令。具体可见HotSpot应用写屏障实现记忆集的原始论文《A Fast Write Barrier for Generational Garbage Collectors》.

[2] 事实上RSet随着被引用数量的变化,分别有三种不同的数据结构,即

//https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/gc_implementation/g1/heapRegionRemSet.hpp

class OtherRegionsTable VALUE_OBJ_CLASS_SPEC {
  //粗粒度PRT 
  BitMap      _coarse_map;
  //细粒度PRT 
  PerRegionTable** _fine_grain_regions;
  //稀疏PRT
  SparsePRT   _sparse_table;
}

具体细节可参考 G1 Rset源码分析

垃圾回收过程中的若干问题

用户线程与GC线程并发时带来的问题

在Mixed GC的并发标记阶段,用户线程同时也在运行,那么可能会新增新的对象,修改原有对象的引用关系,那么G1分别是如何处理这种情况的?

新增对象

每个Region会有bottom和top两个指针:

bottom指针:指向Region的第一个分配对象所在的card在CardTable的位置;

Top指针:指向Region最新分配对象的Card在CardTable的位置

(1)在并发标记开始前,将bottom指针赋值给prevTAMS指针,top指针赋值给nextTAMS指针

(2)并发标记进行期间,当region中分配新对象时,新对象都会分配在nextTAMS之后,这导致top指向的位置也往后移动,nextTAMS和top之间的对象都是被认为隐式存活

简而言之,并发标记阶段中的新对象会直接被标记为存活对象,但是新对象可能后续不被任何对象引用,这样便产生了所谓的浮动垃圾。

引用变更

这里要先提到三色标记法。

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

最终所有对象要么是存活的黑色对象,要么是白色的需要回收的对象。

但是可能出现如下问题,导致错标和漏标的情况:

G1-三色标记法 .drawio.png

左侧的图,是当所有对象都扫描完毕,都被标为黑色以后,某个对象切断了与另一个对象的引用,按照逻辑,这个对象不被任何其他对象引用应该为白色,但由于黑色的对象不会再重复扫描,因此这个本该被回收的对象存活了下来。是为漏标,不过这种情况下顶多是产生了浮动垃圾,下次回收可以回收掉。但如果发生了右侧图这种情况,就麻烦了。 右侧图是当正在扫描某个灰色对象时,用户切断了其与一个白色对象的引用关系,并让一个黑色对象与之建立了引用关系,此时这个白色对象到最后都还是白色对象会被回收(黑色对象不会重复扫描),那么黑色对象根据引用使用白色对象时就必然会出现异常。

G1使用SATB(Snapshot at the begining)解决错标的问题,使用写前屏障,当引用发生变化前,会在引用赋值操作(如B.c = null)之前被触发,将即将被改变引用的对象放入SATB待处理队列中。每个线程都有自己的SATB队列,但最终这些队列会被汇总到一个全局的SATB队列中,并逐一进行处理。对于SATB队列中的对象,它们默认会被按照存活对象来处理,同时还会处理它们引用的其他对象。

参考文档

  1. 深入理解JAVA虚拟机
  2. G1 垃圾收集器常见问题详解
  3. Java Hotspot G1 GC的一些关键技术