JVM原理之GC垃圾回收器G1详解

3,880 阅读59分钟
前言 Garbage First(G1)是垃圾收集领域的最新成果,同时也是HotSpot在JVM上力推的垃圾收集器,并赋予取代CMS的使命。如果使用Java 8/9,那么有很大可能希望对G1收集器进行评估。本文详细首先对JVM其他的垃圾收集器进行总结,并与G1进行了简单的对比;然后通过G1的内存模型、G1的活动周期,对G1的工作机制进行了介绍;同时还在介绍过程中,描述了可能需要引起注意的优化点。笔者希望通过本文,让有一定JVM基础的读者能尽快掌握G1的知识点。 

第一章 概述

G1(Garbage First)垃圾收集器是当今垃圾回收技术最前沿的成果之一。早在JDK7就已加入JVM的收集器大家庭中,成为HotSpot重点发展的垃圾回收技术。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,官方也推荐使用G1来代替选择CMS。G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。

第二章 JVM GC收集器的回顾与比较

从JDK3(1.3)开始,HotSpot团队一直努力朝着高效收集、减少停顿(STW: Stop The World)的方向努力,也贡献了从串行到CMS乃至最新的G1在内的一系列优秀的垃圾收集器。下面就几种典型的组合应用进行简单的介绍。 

串行收集器

串行收集器组合 Serial + Serial Old

开启选项:-XX:+SerialGC

串行收集器是最基本、发展时间最长、久经考验的垃圾收集器,也是client模式下的默认收集器配置。

 串行收集器采用单线程stop-the-world的方式进行收集。当内存不足时,串行GC设置停顿标识,待所有线程都进入安全点(Safepoint)时,应用线程暂停,串行GC开始工作,采用单线程方式回收空间并整理内存。单线程也意味着复杂度更低、占用内存更少,但同时也意味着不能有效利用多核优势。事实上,串行收集器特别适合堆内存不高、单核甚至双核CPU的场合。 

并行收集器

并行收集器组合 Parallel Scavenge + Parallel Old

开启选项:-XX:+UseParallelGC-XX:+UseParallelOldGC(可互相激活)

并行收集器是以关注吞吐量为目标的垃圾收集器,也是server模式下的默认收集器配置,对吞吐量的关注主要体现在年轻代Parallel Scavenge收集器上。

并行收集器与串行收集器工作模式相似,都是stop-the-world方式,只是暂停时并行地进行垃圾收集。年轻代采用复制算法,老年代采用标记-整理,在回收的同时还会对内存进行压缩。关注吞吐量主要指年轻代的Parallel Scavenge收集器,通过两个目标参数-XX:MaxGCPauseMills和-XX:GCTimeRatio,调整新生代空间大小,来降低GC触发的频率。并行收集器适合对吞吐量要求远远高于延迟要求的场景,并且在满足最差延时的情况下,并行收集器将提供最佳的吞吐量。 

并发标记清除收集器

并发标记清除收集器组合 ParNew + CMS + Serial Old

开启选项:-XX:+UseConcMarkSweepGC

并发标记清除(CMS)是以关注延迟为目标、十分优秀的垃圾回收算法,开启后,年轻代使用STW式的并行收集,老年代回收采用CMS进行垃圾回收,对延迟的关注也主要体现在老年代CMS上。

年轻代ParNew与并行收集器类似,而老年代CMS每个收集周期都要经历:初始标记、并发标记、重新标记、并发清除。其中,初始标记以STW的方式标记所有的根对象;并发标记则同应用线程一起并行,标记出根对象的可达路径;在进行垃圾回收前,CMS再以一个STW进行重新标记,标记那些由mutator线程(指引起数据变化的线程,即应用线程)修改而可能错过的可达对象;最后得到的不可达对象将在并发清除阶段进行回收。值得注意的是,初始标记和重新标记都已优化为多线程执行。CMS非常适合堆内存大、CPU核数多的服务器端应用,也是G1出现之前大型应用的首选收集器。

但是CMS并不完美,它有以下缺点:

  1. 由于并发进行,CMS在收集与应用线程会同时会增加对堆内存的占用,也就是说,CMS必须要在老年代堆内存用尽之前完成垃圾回收,否则CMS回收失败时,将触发担保机制,串行老年代收集器将会以STW的方式进行一次GC,从而造成较大停顿时间;
  2. 标记清除算法无法整理空间碎片,老年代空间会随着应用时长被逐步耗尽,最后将不得不通过担保机制对堆内存进行压缩。CMS也提供了参数-XX:CMSFullGCsBeForeCompaction(默认0,即每次都进行内存整理)来指定多少次经历过多少次Full GC之后,进行一次压缩的.

Garbage First (G1)

开启选项:-XX:+UseG1GC

G1垃圾收集器也是以关注延迟为目标、服务器端应用的垃圾收集器,被HotSpot团队寄予取代CMS的使命,也是一个非常具有调优潜力的垃圾收集器。虽然G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。事实上,G1收集与以上三组收集器有很大不同:

  1. G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
  2. G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
  3. G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
  4. G1的收集都是STW的(值得是年轻代收集以及混合收集),但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。

G1的内存模型

分区概念


G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。

卡片:Card

在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度,所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。

:Heap

G1同样可以通过-Xms/-Xmx来指定堆空间大小。当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。

分代模型

在这里插入图片描述

分代:Generation

分代垃圾收集可以将关注点集中在最近被分配的对象上,而无需整堆扫描,避免长命对象的拷贝,同时独立收集有助于降低响应时间。虽然分区使得内存分配不再要求紧凑的内存空间,但G1依然使用了分代的思想。与其他垃圾收集器类似,G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。如下图:


G1 Regions

注:图片来自G1: One Garbage Collector To Rule Them All

其中E代表的是Eden,S代表的是Survivor,H代表的是Humongous,剩余的深蓝色代表的是Old(或者Tenured),灰色的代表的是空闲的region。
每一个分配的Region,都可以分成两个部分,已分配的和未被分配的。它们之间的界限被称为top。


整个年轻代内存会在初始空间-XX:G1NewSizePercent(默认整堆5%)与最大空间-XX:G1MaxNewSizePercent(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis(默认200ms)、需要扩缩容的大小以及分区的已记忆集合(RSet)计算得到。当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。

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

值得注意的是,由于分区的思想,每个线程均可以"认领"某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区(Lab)。

其中,应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

分区模型

在这里插入图片描述

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

巨型对象:Humongous Region

一个大小达到甚至超过分区大小一半的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。

已记忆集合:Remember Set (RSet)

在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,在每个分区记录了一个已记忆集合(RSet),内部类似一个反向指针,记录引用分区内对象的卡片索引。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。

事实上,并非所有的引用都需要记录在RSet中,如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象,当然不用落入RSet中;同时,G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区(an RSet’s owning region)。

Per Region Table(PRT)

RSet在内部使用Per Region Table(PRT)记录分区的引用情况。由于RSet的记录要占用分区的空间,如果一个分区非常"受欢迎",那么RSet占用的空间会上升,从而降低分区的可用空间。G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:

  • 稀少:直接记录引用对象的卡片索引(Region内的Card的索引)
  • 细粒度:记录引用对象的分区索引(Region索引)
  • 粗粒度:只记录引用情况,每个分区对应一个比特位

由上可知,粗粒度的PRT只是记录了引用数量(每个分区的引用卡片数),需要通过整个分区才能找出所有引用,因此扫描速度也是最慢的。

收集集合 (CSet)


在这里插入图片描述
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

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

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

年轻代收集集合:CSet of Young Collection

应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。

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

混合收集集合:CSet of Mixed Collection

年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。

为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆内可收集空间达到此阈值时,不再启动新的混合收集,而是根据标记阶段得到的Cset,拆分为多次Mixed GC(基于之前并发标记周期的标记分级结果),当多次GC完成后,在根据IHOP和堆废物比例来确定是否再次开启Mixed GC。

G1的活动周期

G1垃圾收集活动汇总(图是精髓)


在这里插入图片描述

RSet的维护

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

栅栏: Barrier


在这里插入图片描述

我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。

写前栅栏 Pre-Write Barrrier

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

写后栅栏 Post-Write Barrrier

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

起始快照算法:Snapshot at the beginning (SATB)

Taiichi Tuasa贡献的增量式完全并发标记算法起始快照算法(SATB),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。

SATB会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲区则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。

SATB抽象的说就是在一次GC开始的时候是活的对象就被认为是活的,此时的对象图形成一个逻辑“快照”(snapshot);然后在GC过程中新分配的对象都当作是活的。其它不可到达的对象就是死的了。 

很容易知道哪些对象是一次GC开始之后新分配的:每个region记录着两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。在TAMS以上的对象就是新分配的,因而被视为隐式marked。 

但是在并发GC里,collector(垃圾回收程序)一边动mutator(应用程序)也一边动,如果collector并发mark的过程中mutator覆盖了某些引用字段的值而collector还没mark到那里,那collector不就得不到完整的snapshot了么? 

为了解决这个问题就有了SATB write barrier。G1 GC具体使用的是“湯浅”(Yuasa)式的SATB write barrier的变种。它的相关论文是:
Real-time garbage collection on general-purpose machines, Taiichi Yuasa(湯淺 太一

Write barrier是对“对引用类型字段赋值”这个动作的环切,也就是说赋值的前后都在barrier覆盖的范畴内。在赋值前的部分的write barrier叫做pre-write barrier,在赋值后的则叫做post-write barrier。
在HotSpot VM里,在引入G1 GC之前,其它GC都只用了post-write barrier,所以它在源码里没有特别的前后缀;而G1 GC特有的pre-write barrier则在源码里有_pre的后缀,可以留意一下。

void oop_field_store(oop* field, oop value) {  
  pre_write_barrier(field);  
  *field = value; // the actual store  
  post_write_barrier(field, value);  
}  

Pre/post-write barrier跟SATB有啥关系呢?

前面提到SATB要维持“在GC开始时活的对象”的状态这个逻辑snapshot。除了从root出发把整个对象图mark下来之外,其实只需要用pre-write barrier把每次引用关系变化时旧的引用值记下来就好了。这样,等concurrent marker到达某个对象时,这个对象的所有引用类型字段的变化全都有记录在案,就不会漏掉任何在snapshot里活的对象。当然,很可能有对象在snapshot中是活的,但随着并发GC的进行它可能本来已经死了,但SATB还是会让它活过这次GC。

所以在G1 GC里,整个write barrier+oop_field_store是这样的: 

void oop_field_store(oop* field, oop new_value) {  
  pre_write_barrier(field);             // pre-write barrier: for maintaining SATB invariant  
  *field = new_value;                   // the actual store  
  post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference  
}  

按照湯浅式SATB barrier的设计,pre-write barrier里面的抽象逻辑应当如下:

void pre_write_barrier(oop* field) {  
  if ($gc_phase == GC_CONCURRENT_MARK) { // SATB invariant only maintained during concurrent marking  
    oop old_value = *field;  
    if (old_value != null && !is_marked(old_value)) { // 剔除null原值和已经被标记处理过的情况
      mark_object(old_value);  
      $mark_stack->push(old_value); // scan all of old_value's fields later  
    }  
  }  
}  

在每次引用关系发生变化时,旧的引用所指向的对象就会被mark上,其子孙也会被递归mark上,这样就不会漏mark任何对象,snapshot的完整性也就得到了保证。

但实际去看G1的论文和代码,会发现它的pre-write barrier却是类似这样的:

void pre_write_barrier(oop* field) {  
  oop old_value = *field;  
  if (old_value != null) {  
    if ($gc_phase == GC_CONCURRENT_MARK) { // SATB invariant only maintained during concurrent marking  
      $current_thread->satb_mark_queue->enqueue(old_value);  
    }  
  }  
}  

这比原本的湯浅式设计少了些东西:没有检查目标对象是否已经mark,也不去对目标对象做mark和扫描它的字段。
实际上该做的事情还是得做,只是不在这里做而已。后面讲到logging barrier的时候就会展开说明了。

(Pre-write barrier的实际代码有好几个版本,其中最简单明白的版本是: 

// This notes that we don't need to access any BarrierSet data  
// structures, so this can be called from a static context.  
template <class T> static void write_ref_field_pre_static(T* field, oop newVal) {  
  T heap_oop = oopDesc::load_heap_oop(field);  
  if (!oopDesc::is_null(heap_oop)) {  
    enqueue(oopDesc::decode_heap_oop(heap_oop));  
  }  
} 

enqueue动作的实际代码则在G1SATBCardTableModRefBS::enqueue(oop pre_val)。
它判断当前是否在concurrent marking phase用的是:

JavaThread::satb_mark_queue_set().is_active()  

SATBMarkQueueSet只有在concurrent marking时才会被置为active。


CMS的incremental update设计使得它在remark阶段必须重新扫描所有线程栈和整个young gen作为root;G1的SATB设计在remark阶段则只需要扫描剩下的satb_mark_queue。

2、logging write barrier

为了尽量减少write barrier对mutator性能的影响,G1将一部分原本要在barrier里做的事情挪到别的线程上并发执行。
实现这种分离的方式就是通过logging形式的write barrier:mutator只在barrier里把要做的事情的信息记(log)到一个队列里,然后另外的线程从队列里取出信息批量完成剩余的动作。

以SATB write barrier为例,每个Java线程有一个独立的、定长的SATBMarkQueue,mutator在barrier里只把old_value压入该队列中。一个队列满了之后,它就会被加到全局的SATB队列集合SATBMarkQueueSet里等待处理,然后给对应的Java线程换一个新的、干净的队列继续执行下去。

并发标记(concurrent marker)会定期检查全局SATB队列集合的大小。当全局集合中队列数量超过一定阈值后,concurrent marker就会处理集合里的所有队列:把队列里记录的每个oop都标记上,并将其引用字段压到标记栈(marking stack)上等后面做进一步标记

3、"Points-into" remembered set

G1 GC的heap与HotSpot VM的其它GC一样有一个覆盖整个heap的card table。
逻辑上说,G1 GC的remembered set(下面简称RSet)是每个region有一份。这个RSet记录的是从别的region指向该region的card。所以这是一种“points-into”的remembered set。

用card table实现的remembered set通常是points-out的,也就是说card table要记录的是从它覆盖的范围出发指向别的范围的指针。以分代式GC的card table为例,要记录old -> young的跨代指针,被标记的card是old gen范围内的。

G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。
这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。

举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。 

为了维持这种RSet,G1 GC的post-write barrier(注意,和前文的Pre-write barrier不一样)的抽象逻辑需要做下面的事情: 

(暂时忽略hot card的特殊处理,同时忽略evacuation已经开始之后对collection set内的card的特殊处理)

void post_write_barrier(oop* field, oop new_value) {  
  uintptr_t field_uint = (uintptr_t) field;  
  uintptr_t new_value_uint = (uintptr_t) new_value;  
  uintptr_t comb = (field_uint ^ new_value_uint) >> HeapRegion::LogOfHRGrainBytes;  
  
  if (comb == 0) return; // field and new_value are in the same region  
  if (new_value == null) return; // filter out null stores  
  
  // Otherwise, add to remembered set  
  
  // first, add to card table  
  volatile jbyte* card_ptr = card_for(field); // get pointer to the card for this field  
  
  // in generational G1 mode, skip dirtying cards for young gen regions,  
  // -- young gen regions are always collected  
  // if (*card_ptr == g1_young_gen) return;  
  
  if (*card_ptr != dirty_card) {  
    // dirty the card to reduce the work for multiple stores to the same card  
    *card_ptr = dirty_card;  
  
    // clean the card the get ready to do the real work  
    *card_ptr = clean_card;  
  
    // find the memory region representing the card  
    HeapWord* start = $card_table->addr_for(card_ptr);  
    HeapWord* end = start + CARD_SIZE_IN_WORDS;  
    MemRegion* dirty_mem_region(start, end);  
  
    // and find the G1 heap region containing the card  
    HeapRegion* from_region = $g1_heap->heap_region_containing(start);  
  
    // scan all reference fields in dirtied region  
    foreach (oop from_obj in dirty_mem_region) {  
      foreach (oop* f in from_obj->oop_fields()) {  
        oop to_obj = *f;  
        HeapRegion* to_region = $g1_heap->heap_region_containing(to_obj);  
        if (from_region != to_region) {  
          CardIdx_t from_card = from_region->card_index_for(card_ptr);  
          to_region->remembered_set->add_reference(from_region, from_card);  
        }  
      }  
    }  
  }  
}   

可以看到一个region的RSet是如何与card table里的card关联在一起的。
中间有一处“奇怪”的代码,把card给涂黑然后又马上清掉。这在实际代码里其实是在两个不同线程上做的:在mutator线程上把card给dirty了之后加到一个队列里,然后ConcurrentG1RefineThread从队列里把card拿出来之后再置为clean。上面是为了把整体过程放在一起方便说明所以写成这样。
另外还有一部分注释掉的关于分代式G1模式的代码,这部分代码的作用就是过滤掉从young gen region出发的引用涉及的RSet维护。G1的论文讲解的基本算法是不分代的纯G1(pure garbage-first)只是简单提到了有分代式G1(generational garbage-first)。实际在JDK7或以上可以用的只有分代模式的G1,没有可用参数选择纯G1模式。为了贴合原始算法描述,这里就把分代相关的处理列出来但注释掉。

每次向引用类型字段赋值都要经过这么多步骤来更新RSet的话开销实在太大,而实际G1的实现是类似: 

void post_write_barrier(oop* field, oop new_value) {  
  uintptr_t field_uint = (uintptr_t) field;  
  uintptr_t new_value_uint = (uintptr_t) new_value;  
  uintptr_t comb = (field_uint ^ new_value_uint) >> HeapRegion::LogOfHRGrainBytes;  
  
  if (comb == 0) return; // field and new_value are in the same region 同一个分区  
  if (new_value == null) return; // filter out null stores  null值不用处理
  
  // Otherwise, log it  
  volatile jbyte* card_ptr = card_for(field); // get address of the card for this field  
  
  // in generational G1 mode, skip dirtying cards for young gen regions,  
  // -- young gen regions are always collected  年轻代反正会被全分区扫描
  // if (*card_ptr == g1_young_gen) return;  
  
  if (*card_ptr != dirty_card) {  // 不为脏卡
    // dirty the card to reduce the work for multiple stores to the same card  
    *card_ptr = dirty_card;  
    // log the card for concurrent remembered set refinement  
    JavaThread::current()->dirty_card_queue->enqueue(card_ptr);  //加入队列,异步处理
  }  
}  

这是logging barrier在G1 write barrier上的又一次应用。

跟SATB marking queue类似,每个Java线程有一个dirty card queue,也就是论文里说的每个线程的remembered set log;然后有一个全局的DirtyCardQueueSet,也就是论文里说的全局的filled RS buffers。
实际更新RSet的动作就交由多个ConcurrentG1RefineThread并发完成(处理的是post_write_barrier)。每当全局队列集合超过一定阈值后,ConcurrentG1RefineThread就会取出若干个队列,遍历每个队列记录的card并将card加到对应的region的RSet里去。 

总结:

  • Pre-write barrier 是并发标记线程concurrent marker负责异步处理,负责把老的引用压栈递归引用,只有并发标记期间才会执行逻辑 
  • post-write barrier 是并发优化线程ConcurrentG1RefineThread负责异步处理,维护Rset 

二者都是借助logging barrier写入队列,然后后续线程异步处理,主要为了减少赋值时的开销。

SATB和CMS的Incremental update区别?

这些知识在《The Garbage Collection Handbook》中讲解得非常透彻,买本来读读这些问题就全解决了。 展开之前先说下三色标记法

三色标记算法
提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。
黑色:根对象,或者该对象与它的子对象都被扫描
灰色:对象本身被扫描,但还没扫描完该对象中的子对象
白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象

当GC开始扫描对象时,按照如下图步骤进行对象的扫描:
根对象被置为黑色,子对象被置为灰色。

继续由灰色遍历,将已扫描了子对象的对象置为黑色。

遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。

这看起来很美好,但是如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题
我们看下面一种情况,当垃圾收集器扫描到下面情况时:

这时候应用程序执行了以下操作:
A.c=CB.c=null

这样,对象的状态图变成如下情形:

这时候垃圾收集器再标记扫描的时候就会下图成这样:

很显然,此时C是白色,被认为是垃圾需要清理掉,显然这是不合理的。那么我们如何保证应用程序在运行的时候,GC标记的对象不丢失呢?有如下2中可行的方式:
在插入的时候记录对象
在删除的时候记录对象

刚好这对应CMS和G1的2种不同实现方式:
在CMS采用的是增量更新(Incremental update),只要在写屏障(write barrier)里发现要有一个白对象的引用被赋值到一个黑对象 的字段里,那就把这个白对象变成灰色的。即插入的时候记录下来。
在G1中,使用的是STAB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象,它有3个步骤:
1,在开始标记的时候生成一个快照图标记存活对象
2,在并发标记的时候所有被改变的对象入队(在write barrier里把所有旧的引用所指向的对象都变成非白的)
3,可能存在游离的垃圾,将在下次被收集


简单说,SATB与incremental update是用不同的方式保证concurrent marking不漏扫描活对象
回到扫描对象图的基本模型——三色扫描。黑色是自己已标记且字段也全部标记了的对象(collector就不会再访问到它了),灰色是自己已标记但尚有字段未标记的对象(collector正在访问的对象),白色是尚未标记的对象。
黑色和灰色对象都是确定存活的对象。灰色对象的集合构成了当前collector正在扫描的分界面(wavefront)。从分界面的角度看,灰色是正在分界面上,白色是在分界面之前,黑色是在分界面之后。

要不漏扫活对象,最最重要的就是下述两种情况不同时发生:
1、mutator把一个白对象的引用存到黑对象的字段里
2、某个白对象失去所有能从灰对象到达它的引用路径(直接或间接)

黑对象持有了指向白对象的引用。根据定义,collector已经不会再去遍历黑对象的字段,所以发现不了这里还有一个活引用指向这个白对象。如果还有某个灰对象持有直接或间接引用能到达这个白对象,那就没关系;如果从灰对象出发的所有引用到这个白对象的路径都不幸被切断了,那这个白对象就要被漏扫描了。

Incremental update的做法是:只要在write barrier里发现要有一个白对象的引用被赋值到一个黑对象的字段里,那就把这个白对象变成灰色的(例如说标记并压到marking stack上,或者是记录在类似mod-union table里)。这样就强力杜绝了上述第一种情况的发生。

SATB的做法是:把marking开始时的逻辑快照里所有的活对象都看作时活的。具体做法是在write barrier里把所有旧的引用所指向的对象都变成非白的(已经黑灰就不用管,还是白的就变成灰的)。
这样做的实际效果是:如果一个灰对象的字段原本指向一个白对象,但在concurrent marker能扫描到这个字段之前,这个字段被赋上了别的值(例如说null),那么这个字段跟白对象之间的关联就被切断了。SATB write barrier保证在这种切断发生之前就把字段原本引用的对象变灰,从而杜绝了上述第二种情况的发生。

很明显,incremental update write barrier和SATB write barrier都“过于强力”(cms变灰以后会面有可能黑对象的引用再次被置为null,G1的话可能这个置为灰的对象真的是垃圾又被捡回来了),不但足以保证所有应该活的对象都被扫描到,还可能把一些可以死掉的对象也给扫描上了这就是它们的精确度问题,结果就是floating garbage。Yuasa式的SATB write barrier的精度应该是比CMS用的incremental update write barrier低——前者比后者导致的floating garbage更多。 

在CMS remark的上下文里,根集合指stack、register、globals还有整个young gen。

要注意:CMS是一个old gen collector(不是whole heap collector)。既然只收集old gen,它必须把当前处于非收集区域的young gen算作是root。这跟一般的young GC时要把old gen的remembered set部分算作root的道理一样,只不过HotSpot没有用card table来记录young -> old引用(注),所以就干脆扫描整个young gen作为root。

在CMS initial mark的上下文里,根集合并不包括young gen而是只有stack、register、globals这些常规的。这是因为在接下来的CMS concurrent mark阶段CMS会顺着初始的根集合把young gen里的活对象都遍历了。所以从CMS initial mark + concurrent mark结合在一起的角度看,young gen仍然是根集合的一部分(因为被扫描但不被收集)。

但既然initial mark + concurrent mark已经扫过了young gen为啥还要再在remark时再扫?这就是因为CMS使用的incremental update write barrier是一种“grey mutator”做法。

如果把mutator也看作一个虚构的对象,那么它也应该有黑灰白的颜色划分。
所谓black mutator做法就是说mutator一旦被初始标记之后,到并发标记结束之前都不可以接触到白对象的指针,或者要确保接触到的白对象都被grey-protected(破坏条件2);
所谓grey mutator则正好相反,在mutator被初始标记之后,到并发标记结束之前还可以继续接触白对象的指针,只要在标记结束前重新扫描一次完整的根集合即可。。

Incremental update write barrier都是grey mutator做法;SATB write barrier则是black mutator做法。

CMS remark阶段做的就是为了确保grey mutator正确性而重新扫描根集合,同时也要把card table和mod-union table记录下的在old gen里发生了变化的引用也重新扫描一遍。

==============================

前面说了CMS的write barrier非常简单,只是在card table记录一下改变的引用的出发端对应的card。那mod-union table是啥?

其实很简单:card table只有一份,既要用来支持young GC又要用来支持CMS。每次young GC过程中都涉及重置和重新扫描card table,这样是满足了young GC的需求,但却破坏了CMS的需求——CMS需要的信息可能被young GC给重置掉了。

为了避免丢失信息,就在card table之外另外加了一个bitmap叫做mod-union table。在CMS concurrent marking正在运行的过程中,每当发生一次young GC,当young
GC要重置card table里的某个记录时,就会更新mod-union table对应的bit。

这样,最后到CMS remark的时候,当时的card table外加mod-union table就足以记录在并发标记过程中old gen发生的所有引用变化了。

==============================

注:实际上HotSpot VM一般用的post-write barrier非常简单,就是无条件的记录下发生过引用关系变化的card: 

void post_write_barrier(oop* field, oop val) {  
  jbyte* card_ptr = card_for(field);  
  *card_ptr = dirty_card;  
}  

这里既不关心field所在的分代,也不关心val的值,所以其实只要有引用改变,其对应的card都会被记录。也就是说这个card table记录的不只是old -> young引用,而是所有发生了变化的引用的出发端,无论在old还是young。

但是HotSpot VM只使用了old gen部分的card table,也就是说只关心old -> ?的引用。这是因为一般认为young gen的引用变化率(mutation rate)非常高,其对应的card table部分可能大部分都是dirty的,要把young gen当作root的时候与其扫描card table还不如直接扫描整个young gen。 

CMS在整个收集过程中让所有新创建的对象都是黑色的,所以上面例子里p = new Horse()之后p持有的是黑对象的指针,没问题。
也就是说在CMS GC进行中创建的对象在这轮收集都会保证存活。这样虽然会有floating garbage问题,但实现起来比较简单,收集速度受影响较小。 

实际有问题的情况是:

void test() {  
  MyObject q = foo(); // this is white  
  MyObject p = new MyObject(); // this is implicitly black  
  p.someField = q; // black -> white  
}  
  
MyObject foo() {  
  MyObject q = bar(); // this is white  
  return q; // mutator keeps white pointer on stack  
}  
  
MyObject bar() {  
  MyObject p = getGreyObject();  // this is grey  
  MyObject q = p.getWhiteField(); // this is white  
  return q; // mutator keeps white pointer on stack  
}  

类似这样的代码。Mutator可能会从一个灰对象的字段得到一个白对象的引用,然后一直持有那个引用;或者一个新创建的对象(在young gen里)也可能会持有这个白对象的引用。 

public static void main(String[] args) {    
  Animal p = new Dog();    
  p.child = new Dog();  
  p.child.bark();    
  Animal q = p.child;  
  p.child = null;    
  q.bark();    
}  

假设第4行代码执行完毕后CMS开始,initial mark将local1指向的Dog对象标记,然后concurrent mark开始,假设main“先”执行,“Animal q = p.child”使得local2(即局部变量q)指向p.child,但由于这个引用关系是通过astore_2字节码添加的(第三行代码:p.child = new Dog()),且p.child对应的字节码getfiled也没有write barrier,因此刚添加的这个从q到p.child的引用并不会被card table或者mod-union table记录下来,然后执行“p.child = null”使得灰对象p到白对象p.child的引用关系被删除了,但由于是incremental update而非SATB,引用关系的删除也没有记录。 

因此,remark阶段必须重新扫描root,否则q到p.child的引用关系就丢失了

之前以为如果重新扫描根集合,那岂不是initial mark和concurrent mark两个阶段所做的工作都白做了?整个对象关系图又需要重新标记一遍。现在想想其实不是的,因为每个root的扫描过程在触及到一个marked对象的时候就会终止,而concurrent mark阶段正常情况应该已经完成了绝大部分可达对象的标记。

并发优化线程:Concurrence Refinement Threads

G1中使用基于Urs Hölzle的快速写栅栏,将栅栏开销缩减到2个额外的指令。栅栏将会更新一个card table type的结构来跟踪代间引用。

当赋值语句发生后,写后栅栏会先通过G1的过滤技术判断是否是跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与SATB类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。

并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过-XX:G1ConcRefinementThreads(默认等于-XX:ParellelGCThreads)设置。并发优化线程永远是活跃的一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone,G1会用分层的方式调度,使更多的线程处理全局列表。如果并发优化线程也不能跟上缓冲区数量,则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。

并发标记周期:Concurrent Marking Cycle

并发标记周期是G1中非常重要的阶段,这个阶段将会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别所有(可能)存活对象,并计算每个分区的活跃度,从而确定GC效率等级。主要是为了递归标记处SATB对应的所有活对象,这里还隐含了一点,就是一旦一个对象确定死亡不可达以后,后续不可能重新变得可达。

当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,便会触发并发标记周期。整个并发标记周期将由初始标记(Initial Mark)、根分区扫描(Root Region Scanning)、并发标记(Concurrent Marking)、重新标记(Remark)、清除(Cleanup)几个阶段组成。其中,初始标记(随年轻代收集一起活动)、重新标记、清除是STW的,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集。

并发标记线程:Concurrent Marking Threads

SATB是一个逻辑上存在概念,在实际中并没有任何真的实际的数据结构与之对应。叫这个名字,是因为,一旦进入了concurrent marking阶段,那么该在该阶段的运行过程中,即便应用修改了引用,但是因为SATB的写屏障记录下来了原始的值,在遍历整个堆查找存活对象的时候,使用的依然是原来的值。这就是在逻辑上保持了一个snapshot at the beginning of concurrent marking phase。

在处理新创建的对象,G1采用了不同的方式。G1用了两个TAMS(top at mark start)变量了判断新创建的对象。一个叫做previous TAMS,一个叫做next TAMS。位于next TAMS和top之间的对象就是新分配的对象。

并发标记阶段,bitmap和TAMS的作用如图:

一个Region内空间的分配是有序的,可以理解为用指针碰撞的形式分配空间;而整个堆是以空闲列表的形式来维护所有Region的

该图的详细解释如下:

  1. A是第一次marking cycle的initial marking阶段。next bitmap尚未标记任何存活对象,而此时的previous TAMS被初始化为region内存地址起始值,next TAMS被初始化为top。top实际上就是一个region未分配区域和已分配区域的分界点;
  2. B是经过concurrent marking阶段之后,进入了remark阶段。此时存活对象的扫描已经完成了,因此next bitmap构造好了,刚好代表的是当下状态中region中的内存使用情况。注意的是,此时top已经不再与next TAMS重合了,top和next TAMS之间的就是在前面标记阶段之时,新分配的对象;
  3. C代表的是clean up阶段。C和B比起来,next bitmap变成了previous bitmap,而在bitmap中标记为垃圾(也就是白色区域的)的对应的region的区域也被染成了浅灰色。这并不是指垃圾对象已经被清扫了,仅仅是标记出来了。同时next TAMS和previous TAMS也交换了角色;
  4. D代表的是下一个marking cycle的initial marking阶段,该阶段和A类似,next TAMS重新被初始化为top的值;
  5. EF就是BC的重复;

扫描任何region的时候如果碰到指向不在CSet里的region的引用都可以忽略,不只是扫描young gen region可以。
要记住的是,在一次收集中,从非收集区域到收集区域的incoming reference是重要的(需要作为根集合的一部分),而从收集区域到非收集区域的outgoing reference是可忽略的(非收集区域的对象反正不收集,可以当作还活着)。

还有个小细节:在有liveness bitmap指明对象生死情况的地方,G1可以借助liveness bitmap来减少card table引致的floating garbage。具体是:
假如region A的RSet记录着有来自region B的card 123有incoming reference,而这个card 123正好在第n-1轮global concurrent marking已经记录下了对象生死在prev liveness bitmap里,那么在扫描card 123的时候只有prev liveness bitmap说还活着的对象的字段会被扫描,bitmap说已经死掉的对象则不会被扫描。

在并发标记阶段,G1会根据参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4),分配并发标记线程(Concurrent Marking Threads),进行标记活动。每个并发线程一次只扫描一个分区,并通过"手指"指针的方式优化获取分区。并发标记线程是爆发式的,在给定的时间段拼命干活,然后休息一段时间,再拼命干活。

每个并发标记周期,在初始标记STW的最后,G1会分配一个空的Next位图和一个指向分区顶部(Top)的NTAMS标记Previous位图记录的上次标记数据,上次的标记位置,即PTAMS,在PTAMS与分区底部(Bottom)的范围内,所有的存活对象都已被标记。那么,在PTAMS与Top之间的对象都将是隐式存活(Implicitly Live)对象。在并发标记阶段,Next位图吸收了Previous位图的标记数据,同时每个分区都会有新的对象分配,则Top与NTAMS(一开始和top重叠在一个位置)分离,前往更高的地址空间。在并发标记的一次标记中,并发标记线程将找出NTAMS与PTAMS之间的所有存活对象,将标记数据存储在Next位图中。同时,在NTAMS与Top之间的对象即成为已标记对象。如此不断地更新Next位图信息,并在清除阶段与Previous位图交换角色。

并发标记周期的流程

初始标记: Initial Mark

初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。

根分区扫描:Root Region Scanning

在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为在一次young GC中,这些存活的年轻代的对象会被复制到Survivor Region,因此需要扫描这些Survivor region来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年代的根的一部分。

在理解了这一点的基础上,那么对于阶段必须在下一次young GC启动前完成的要求,也就理解了。因为如果第二次的young GC启动了,那么这个过程中,survivor region就可能发生变化。这个时候执行root region phase就会产生错误的结果。


并发标记:Concurrent Marking


和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。

存活数据计算:Live Data Accounting

存活数据计算(Live Data Accounting)是标记操作的附加产物,只要一个对象被标记,同时会被计算字节数,并计入分区空间。只有NTAMS以下的对象会被标记和计算,在标记周期的最后,Next位图将被清空,等待下次标记周期。

重新标记:Remark

该阶段是一个STW的阶段。引入该阶段的目的,是为了能够达到结束标记的目标。要结束标记的过程,要满足三个条件:

  1. concurrent marking已经追踪了所有的存活对象;
  2. marking stack是空的;
  3. 所有的log都被处理了;

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

重新标记(Remark)是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间(STW的),去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。这个阶段也是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销

清除:Cleanup

紧挨着重新标记阶段的清除(Clean)阶段也是STW的。Previous/Next标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。清除阶段主要执行以下操作:

  1. RSet梳理,启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet梳理也有助于发现无用的引用。参数-XX:+PrintAdaptiveSizePolicy可以开启打印启发式算法决策细节;
  2. 整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合
  3. 识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期。

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

Evacuation

Evacuation阶段STW的,大概可以分成两个步骤:第一个步骤是从Region中选出若干个Region进行回收,这些被选中的Region称为Collect Set(简称CSet);而第二个步骤则是把这些Region中存活的对象复制到空闲的Region中去,同时把这些已经被回收的Region放到空闲Region列表中。
这两个步骤又可以被分解成三个任务:

  1. 根据RS的日志更新RS:只有在处理完了RS的日志之后,RS才能够保证是准确的,完整的,这也是Evacuation是STW的重要原因;
  2. 扫描RS和其余的根来确定存活对象:该阶段实际上最主要依赖于RS;
  3. 拷贝存活对象:该阶段只要从2中确定的根触发,沿着引用链一直追溯下去,将存活对象复制到新的region就可以。这个过程中,可能有一部分的年轻代对象会被提升到老年代;

Evacuation的时机

Evacuation的触发时机在不同的模式下会有一些不同。在不同的模式下都相同的是,只要堆的使用率达到了某个阈值,就必然会触发Evacuation。这是为了确保在Evacuation的时候有足够的空闲Region来容纳存活对象。

在young GC的情况下,G1会选择N个region作为CSet,该CSet首先需要满足软实时的要求,而一旦已经有N个region已经被分配了,那么就会执行一次Evacuation。

G1会尽可能的执行mixed GC。唯一的限制就是mix GC也需要满足软实时的要求。

G1触发Evacuation的原则大概是:

  1. 如果被分配的young region数量满足young GC的要求,那么就会触发young GC;
  2. 如果被分配的young region数量不满足young GC,就会进一步考察加上old region的数量,能否满足old GC的要求;

为了理解这一点,可以举例来说,假如回收一个old region的时间是回收一个young region的两倍,也就是young region花费时间T,old region花费2T,在满足软实时目标的情况下,GC只能回收8T的region,那么:

  1. 假如应用现在只分配k(k<8)块young region,没有分配任何old region。这个时候又分配了一个old region,那么这个时候会立刻触发一次mixed GC,此次GC会选择k块young region和一块old region;
  2. 因此,在这种假设下,只要有可以回收的old region的时候,总是会先回收old region;
  3. 在没有任何old region的情况下,才有可能触发young region。

当然,在一般情况下,这些假设是不成立的。读者可以思考一下,在young GC和mixed GC达到软实时的要求下,young region和old region之间回收的花销不同会导致young GC和mixed GC会在什么情况下触发。


年轻代收集/混合收集周期

年轻代收集和混合收集周期,是G1回收空间的主要活动。当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高收益的老年代分区,前文已述。但随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次年轻代收集(STW)中,G1将执行整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed Collection Cycle)

GC工作线程数:-XX:ParallelGCThreads

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

年轻代收集:Young Collection

每次收集过程中,既有并行执行的活动,也有串行执行的活动,但都可以是多线程的。在并行执行的任务中,如果某个任务过重,会导致其他线程在等待某项任务的处理,需要对这些地方进行优化。

并行活动:

  • 外部根分区扫描 Ext Root Scanning:此活动对堆外的根(JVM系统目录、VM数据结构、JNI线程句柄、硬件寄存器、全局变量、线程对栈根)进行扫描,发现那些没有加入到暂停收集集合CSet中的对象。如果系统目录(单根)拥有大量加载的类,最终可能其他并行活动结束后,该活动依然没有结束而带来的等待时间。

  • 更新已记忆集合 Update RS:并发优化线程会对脏卡片的分区进行扫描更新日志缓冲区来更新RSet,但只会处理全局缓冲列表。作为补充,所有被记录但是还没有被优化线程处理的剩余缓冲区,会在该阶段处理,变成已处理缓冲区(Processed Buffers)。为了限制花在更新RSet的时间,可以设置暂停占用百分比-XX:G1RSetUpdatingPauseTimePercent(默认10%,即-XX:MaxGCPauseMills/10)。值得注意的是,如果更新日志缓冲区更新的任务不降低,单纯地减少RSet的更新时间,会导致暂停中被处理的缓冲区减少,将日志缓冲区更新工作推到并发优化线程上,从而增加对Java应用线程资源的争夺。

  • RSet扫描 Scan RS:在收集当前CSet之前,考虑到分区外的引用,必须扫描CSet分区的RSet。如果RSet发生粗化,则会增加RSet的扫描时间。开启诊断模式-XX:UnlockDiagnosticVMOptions后,通过参数-XX:+G1SummarizeRSetStats可以确定并发优化线程是否能够及时处理更新日志缓冲区,并提供更多的信息,来帮助为RSet粗化总数提供窗口。参数-XX:G1SummarizeRSetStatsPeriod=n可设置RSet的统计周期,即经历多少此GC后进行一次统计

  • 代码根扫描 Code Root Scanning:对代码根集合进行扫描,扫描JVM编译后代码Native Method的引用信息(nmethod扫描),进行RSet扫描。事实上,只有CSet分区中的RSet有强代码根时,才会做nmethod扫描,查找对CSet的引用。

  • 转移和回收 Object Copy:通过选定的CSet以及CSet分区完整的引用集,将执行暂停时间的主要部分:CSet分区存活对象的转移、CSet分区空间的回收。通过工作窃取机制来负载均衡地选定复制对象的线程,并且复制和扫描对象被转移的存活对象将拷贝到每个GC线程分配缓冲区GCLAB。G1会通过计算,预测分区复制所花费的时间,从而调整年轻代的尺寸

  • 终止 Termination:完成上述任务后,如果任务队列已空,则工作线程会发起终止要求。如果还有其他线程继续工作,空闲的线程会通过工作窃取机制尝试帮助其他线程处理。而单独执行根分区扫描的线程,如果任务过重,最终会晚于终止。

  • GC外部的并行活动 GC Worker Other:该部分并非GC的活动,而是JVM的活动导致占用了GC暂停时间(例如JNI编译)。

串行活动:

  • 代码根更新 Code Root Fixup:根据转移对象更新代码根。

  • 代码根清理 Code Root Purge:清理代码根集合表。

  • 清除全局卡片标记 Clear CT:在任意收集周期会扫描CSet与RSet记录的PRT,扫描时会在全局卡片表中进行标记,防止重复扫描。在收集周期的最后将会清除全局卡片表中的已扫描标志。

  • 选择下次收集集合 Choose CSet:该部分主要用于并发标记周期后的年轻代收集、以及混合收集中,在这些收集过程中,由于有老年代候选分区的加入,往往需要对下次收集的范围做出界定;但单纯的年轻代收集中,所有收集的分区都会被收集,不存在选择。

  • 引用处理 Ref Proc:主要针对软引用、弱引用、虚引用、final引用、JNI引用。当Ref Proc占用时间过多时,可选择使用参数-XX:ParallelRefProcEnabled激活多线程引用处理。G1希望应用能小心使用软引用,因为软引用会一直占据内存空间直到空间耗尽时被Full GC回收掉;即使未发生Full GC,软引用对内存的占用,也会导致GC次数的增加。

  • 引用排队 Ref Enq:此项活动可能会导致RSet的更新,此时会通过记录日志,将关联的卡片标记为脏卡片。

  • 卡片重新脏化 Redirty Cards:重新脏化卡片。

  • 回收空闲巨型分区 Humongous Reclaim:G1做了一个优化:通过查看所有根对象以及年轻代分区的RSet,如果确定RSet中巨型对象没有任何引用,则说明G1发现了一个不可达的巨型对象,该对象分区会被回收。

  • 释放分区 Free CSet:回收CSet分区的所有空间,并加入到空闲分区中。

  • 其他活动 Other:GC中可能还会经历其他耗时很小的活动,如修复JNI句柄等。

并发标记周期后的年轻代收集

并发标记周期后的年轻代收集: Young Collection Following Concurrent Marking Cycle

当G1发起并发标记周期之后,并不会马上开始混合收集。G1会先等待下一次年轻代收集,然后在该收集阶段中,确定下次混合收集的CSet(Choose CSet)。

混合收集周期:Mixed Collection Cycle

单次的混合收集与年轻代收集并无二致。根据暂停目标,老年代的分区可能不能一次暂停收集中被处理完,G1会发起连续多次的混合收集,称为混合收集周期(Mixed Collection Cycle)。G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。

转移失败的担保机制 Full GC

转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。

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

  • 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
  • 从老年代分区转移存活对象时,无法找到可用的空闲分区
  • 分配巨型对象时在老年代无法找到足够的连续分区

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

总结

G1是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的GC暂停目标,就能得到不错的性能;同时,我们也看到G1对内存空间的浪费较高,但通过**首先收集尽可能多的垃圾(Garbage First)**的设计原则,可以及时发现过期对象,从而让内存占用处于合理的水平。


参考:www.jianshu.com/p/aef0f4765…