G1垃圾收集器(中) - 垃圾回收系列(四)

400 阅读15分钟

G1垃圾收集器(中)

上一篇文章描述了G1垃圾回收器的流程,本文详细深入来了解下,G1垃圾收集器的一些关键技术。

一、跨带引用

回顾一下本系列第一篇文章中,对分代回收理论的介绍:

分代回收理论,确切的说,是一种符合大多数程序运行实际情况的经验:

  1. 绝大多数对象都是朝生夕灭的,创建后很快就不再使用。
  2. 少数非朝生夕灭的对象,通常会存活很长时间,很难回收。

根据以上两条经验,就可以把JVM堆分成两部分,存储以上两种不同特性的对象,也就是我们常说的年轻代(Young Generation)和老年代(Old Generation)。再根据年轻代和老年代的特性,使用不同的算法,进行回收。 但跨带回收有个明显的问题,就是跨代引用,即年轻代可能会引用老年代,老年代也可能会引用年轻代,这就造成,假如我们对某一代回收时,理论上却需要对整个堆进行扫描,才能完成可达性分析。这无疑是非常耗时的,这时就需要引出以上两条经验的推论:

  1. 朝生夕灭的对象与长期存活的对象之间,很少会互相引用。这也符合直觉,存在互相引用关系的两个对象,是应该倾 向于同时生存或者同时消亡的。

跨带引用是较少的,那么我们就可以通过某种数据结构,把跨代引用记录下来,以避免在垃圾回时,扫描整个堆。

🤔那么,使用什么样的数据结构呢

可以比较容易猜想到,使用map记录跨带引用,key为引用的那一侧地址,value为被引用那一侧的地址。当然可以这么做,但这种实现有点小问题:

  1. 占用空间较大;
  2. 实现较为复杂;

实际的实现,都会做些简化

  1. 可以仅记录一个大致位置,比如某块内存,存在跨带引用;
  2. 不需要被引用的究竟是哪一块内存,扫描的时候,从引用发出的位置扫描即可;

🤔跨带引用,一般主要主要关注年轻代被哪些老年代引用,为什么呢?

因为老年代回收时,即使扫描整个年轻代,也不是什么大不了的事情,毕竟年轻代存活的对象很少。反之则不行,因为老年代存活对象很多,为了回收年轻代,而扫描整个老年代,是非常耗时的。

1.1 卡表

在G1垃圾收集器之前,HotSpot虚拟机,采用卡表这种数据结构,记录跨带引用。所谓卡表,就是一个字节(byte)数组。每个字节(byte)映射512字节的内存,如果该字节标记为“脏”,则代表映射的堆的内存上,“可能”存在跨带引用

卡表.png

使用卡表记录跨带引用后,年轻代回收,在做可达性分析扫描时,就可以借助卡表,仅扫描标记为脏的那部分老年代,从而避免扫描整个老年代。

🤔卡表为什么使用byte数组,而不是bit呢

之所以使用byte数组而不是bit数组主要是速度上的考量,现代计算机硬件都是最小按字节寻址的,没有直接存储一个bit的指令,所以要用bit的话就不得不多消耗几条位运算指令。

🤔卡表该如何维护呢

HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态。这里的写屏障有点类似于Spring的AOP,在“引用类型字段赋值”这个操作前后,形成了一个Around通知,可以在这前后做些事情。卡表的维护,主要使用了写后屏障(Post-Write Barrier),也就是在引用赋值之后,做了一些操作。

显然,引用赋值,是一个非常高频的操作,写后屏障执行的代码,需要尽可能的简单高效,否则会严重影响虚拟机的性能。因此维护卡表的代码,并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。简化后的伪代码如下:

CARD_TABLE [this address >> 9] = DIRTY;

这里通过位移操作(相当于除以512),找到卡表映射位置,这也是为什么卡表映射的是512(2的9次方)的这样一个范围的原因。

🤔卡表在什么时候重置呢

卡表在初始化的时候,都是“干净”的,在年轻代GC之后,部分进入幸存区,部分进入老年代,在对象复制的过程中,需要修正指向这些对象的引用,这时就可以对老年代的卡表进行修正。

伪共享

卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

为此,在JDK 7之后,HotSpot引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:

if (CARD_TABLE [this address >> 9] != DIRTY) 
  CARD_TABLE [this address >> 9] = DIRTY;

开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

1.2 记忆集(Remembered Set)

在G1垃圾收集器中,处理跨带回收,变得更复杂了,卡表已经无法满足需求。堆已经不在是简单的分为年轻代、老年代,而是一块块的分区(Region),仅用一个数组,已经无法记录多个分区之前复杂的相互引用。

G1使用记忆集(Remembered Set),简称Rset,记录分区(Region)之间的引用。逻辑上,每个分区(Region)都有一个记忆集,记录其他分区到本分区的引用关系。属于points-into结构(谁引用了我的对象)。而卡表则是一种points-out(我引用了谁的对象)的结构。

🤔为什么记忆集不使用points-out,记录“我引用谁”呢

内存仅划分为年轻代和老年代的时候,其实使用哪种方式,在扫描时,没有多大区别,怎么实现简单,怎么来就可以。而多分区这种内存划分方式,如果还使用points-out这种方式记录,扫描每个分区,都需要遍历其他分区的points-out的数据结构,反之,只需要扫描自己的points-into结构记录对应的分区即可,效率高低,一目了然。

记忆集采用map记录跨分区引用,key为其他的Region的起始地址,value是一个数字集合,每个元素类似于卡表,映射其他分区的512byte的内存地址。

记忆集.png

以上为记忆集的示意图,假设我们有三个分区,分别是编号为A/B/C。整个堆按照每512byte,进行全局编号(与卡表类似)。分区C的记忆集中记录了,分区A的编号0/2的内存、分区B的编号1025号的内存,引用了自己。

如果跨分区引用不断增多,那么Ret会不断“膨胀”,占用非常多的内存。G1为此做了一个优化,假设分区A对分区C的引用不断增多,则分区C的记忆集,存储分区A的卡表索引,会改变自己的存储结构,以节省内存:

  1. 引用较少时,可以采用稀疏表,其实就是一个数组,存储分区A的卡表索引;
  2. 引用超过一定阈值后,会升级为bitMap,使用1个bit代表一个卡页,进而节省空间;
  3. 引用特别多的时候,就不再详细记录分区A的哪块内存引用自己了,仅记录分区A引用了自己。扫描时,对分区A全部扫描即可。

🤔如何维护记忆集呢

也使用写后屏障,来维护记忆集,但与卡表不同的是,记忆集的维护要复杂得多,已经不适合同步维护了。用户线程会在写后屏障中,把赋值信息,放入一个叫做DCQ(DIRTY CARD QUEUE)队列中,后续由GC线程,来处理这些引用。

GC线程,也不会处理所有的引用关系,以下情况,就不需要处理:

  1. 同分区的引用,不需要处理;
  2. 新生代分区,对其他分区(不管是年轻代,还是老年代),都不需要维护,因为每次GC,无论是young-only,还是mixed gc,都会全量回收所有年轻代分区,也就是说会扫描所有年轻代分区,无需再根据记忆集,减少分区扫描。

二、漏标

G1与CMS这种,扫描阶段,与用户线程并发,那么扫描线程,在扫描时,用户线程还可以修改引用。这就可能造成漏标或者误标。

  • 误标:死亡对象,反而被标记到了,无法回收,只能等到下次GC,才能回收掉,这些对象也被称之为浮动垃圾。
  • 漏标:存活的对象,没有被标记到,被错误的回收,无法接受的!!

2.1 三色标记法

三色标记法,是一种可达性分析算法的实现,三种颜色是一种具象的说法,代表的是三种状态(不一定是真的有这样一个颜色属性):

  • 白色:没有被访问过,可达性分析刚开始时,都是白色;而扫描结束时,白色意味着死亡对象。
  • 灰色:被访问过,但关联的引用还没有被扫描过,这是一个过渡颜色,扫描完成后,一定会变成黑色。
  • 黑色:被访问过,且关联的引用都已经扫描完成了。

扫描结束后,就只会剩余两种颜色,黑色和白色,黑色就是存活对象,白色就是死亡对象。

假设我们使用广度优先遍历算法,对堆进行扫描,那么步骤为:

  1. 所有被GC ROOTS直接引用的对象,标记为灰色,进入队列;
  2. 从队列中,获取灰色对象,遍历它关联的引用
    • 如果是白色,放入队列中;
    • 如果是灰色/黑色,忽略;
  3. 不断重复步骤2,直到队列空为止。
  4. 扫描结束后,标记过的(黑色)为存活对象,未标记过的(白色),为死亡对象。

在扫描过程中,如果用户线程停止了,不会有任何问题,如果与用户线程并发,用户线程修改了对象的引用,则有可能发生漏标或者误标的问题。

  1. 误标 : 非常容易产生,只要用户线程,断开了所有指向一个黑色/灰色的对象即可,也就是一个对象,在被扫描后,死亡了。这种对象被称之为“浮动垃圾”,不会有严重后果,只要等到下次回收即可。
  2. 漏标,需要满足两个条件
    1. 黑色对象指向了某个白色对象;
    2. 存在的所有的指向(直接或者间接)该白色对象的灰色对象,在扫描到对其引用之前,删除了对其的引用。

只要破坏了漏标的两个条件之一,就可以避免漏标。

2.2 CMS的方案

CMS采用增量更新(Incremental Update)的方式,如果黑色对象新增了对白色对象的引用,则把黑色对象记录下来,后续重新扫描。

🤔如何记录这种修改呢

没错,又是写屏障,CMS在写屏障中,记录这种修改。

在实际实现中,十分简单粗暴,采用的也是类似卡表的数据结构,在写屏障中,如果A对B发生了引用,不管A是什么颜色,直接对A所在的位置,进行置位。后续根据这些信息,重新扫描。与卡表类似,这并不是一个精确到对象引用的数据结构。因此在重新扫描的过程中,会产生误标,也就是说,会产生浮动垃圾。

2.3 G1的方案

G1采用破坏条件2,也就是灰色对象在删除(修改也是一种删除)引用时,记录下被删除的对象,后续重新扫描。其实在上一篇文章中,已经讲过,G1使用前置写屏障,把被删除的引用,记录到SATB BUFFER中。BUFFER满了之后,会被加入全局链表中,标记线程会周期处理这些被修改的引用。

写前屏障伪代码如下,发生修改后,如果旧值不为null,且处于并发标记阶段,则进入STAB队列,异步处理。

void pre_write_barrier(oop* field) {
    oop old_value = *field; 
    if(old_value != null){
        if($gc_phase == GC_CONCURRENT_MARK){
            $current_thread->stab_mark_queue->enqueue(old_value);
        }
    }
}

三、内存快照(STAB)

SATB(snapshot at the beginning),是并发标记开始时刻的一个逻辑快照,用于辅助并发标记。理论十分简单,并发标记开始时,打一个快照,之后仅扫描该快照内的对象。所有在该快照外的对象,也就是在年轻代回收后新晋升的(或者是大对象),都认为是存活对象,这也被称之为,隐性标记。

实现

所谓的内存快照,其实就是一个指针,在初始标记的时候,使用一个指针指向堆顶,称之为TAMS(top at mark start),一侧是快照,另一侧就是新分配的对象。

具体实现是,使用了两个指针,分别被称之为previous TAMS和next TAMS,看名字就知道,分别是上一次和下一次(也就是接下来的这次)的TAMS。

🤔为什么使用两个?

G1使用bitMap这种数据结构来记录区域内对象的存活状态,默认JVM是8bytes对齐的,因此每个bit映射64bit,因此需要1/64堆大小的存储来映射堆的状态。

而G1使用了两个bitMap,一个prevBitmap记录上一轮(如果存在)并发标记的结果,由于上一轮的并发标记已经完成, 这个bitMap可以直接使用,来判断分区内对象存活的状态。另一个nextBitmap记录下一轮(当前)并发标记的结果。有两个bitMap,自然需要两个指针。每次并发标记结束之后,两个bitMap互换角色。

下图演示了在并发标记过程中的bitMap和TAMS。ABC/DEF分别对应是否存在prevBitmap的场景。注意,物理上,bitMap是映射整个区域的,图中仅展示了到TAMS那部分。

  • A/D : 初始标记,TAMS的在堆顶。
  • B/F : remark状态,也就是标记完成,bottom到NextTAMS之间,就是快照,bitMap状态标记这段内存的存活状态;NextTAMS到Top之前,就是在标记过程中,新增的对象,隐性标记为存活状态。
  • C/E : CleanUp阶段,previous与next交换角色。

bitmap.png

🤔记录上一次的bitMap有什么用么

先讨论下,bitMap有什么用呢?

当然是记录对象的生死,用于垃圾回收啦。其实,除此之外,还可以辅助扫描。在年轻代回收时,可以借助记忆集,发现从老年代指向年轻代的引用,进而避免扫描整个堆。这里的潜台词是,不管老年代的对象状态,都认为是存活的。如果已经存在了一个bitMap可以判断对象的生死,那么已经死亡的对象,就不需要作为根来扫描,进而减少了扫描量。

还记得G1的垃圾回收过程么,标记结束之后,开始mixed GC,每次选择部分老年代,与年轻代一起回收,直到剩余垃圾小于一定比例,由-XX:G1HeapWastePercent控制,默认为5%。也就是说,并不是所有的老年代都会回收。这些未回收的老年的的bitMap,就得以保留,也就是previous bitMap,用于辅助扫描。也可以作为next bitMap的初始化。这就是为什么需要两个bitMap。

引用修改

前文提到,SATB的理论是,仅处理快照内的对象,快照外的,都认为是存活对象,这一切的前提是,快照不能被修改。然而仅使用一个指针,记录的逻辑快照,当然是无法提供这种保证的。因此要记录快照内对象的修改。其实就是上一章讲到的,使用前置写屏障,记录所有删除的对象,后续处理。

🤔* G1使用STAB,而CMS使用增量更新,STAB有什么优势么*

额,不能说有很大优势,只能说,各有优劣。STAB仅扫描快照内对象,快照外都认为是存活的。这样做的优势是,扫描的内存的大小是固定的,可以保证扫描在一定时间内停止(不会因一直有新垃圾的产生,而不断进行扫描),劣势就是会有更多的浮动垃圾。G1的设计者认为保证扫描可以在一定时间内停止,要比尽早回收垃圾,更重要。

参考