以这幅图为例,region1和region2都引用了region3中的对象,那么region3的RSet中有两个key,分别是region1的起始地址和region2的起始地址。在扫描region3的RSet时,发现key为0x6a的region是一个old区region。如果这时第3,5card对应的对象没有被标记为可达,那么这里就会根据RSet再次标记。同样的,key为0x9b对应的region是一个young区域的region,那么0,2号card的对象则不会被标记。
事实上,并非所有的引用都需要记录在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中将会以三种模式记录引用:
收集集合(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的分区,则通过计算得到的GC效率进行安排。
Young GC流程
在了解了region的内部结构之后,我们再来看一下G1的young gc的具体流程。
stop the world,整个young gc的流程都是在stw里进行的,这也是为什么young gc能回收全部eden区域的原因。控制young gc开销的办法只有减少young region的个数,也就是减少年轻代内存的大小,还有就是并发,多个线程同时进行gc,尽量减少stw时间。
Snapshot At The Beginning,G1在分配对象时,会在region中有2个top-at-mark-start(TAMS)指针,分别表示prevTAMS和nextTAMS。对应着卡表上即指向表示卡表范围的的两个编号,GC是分配在nextTAMS位置以上的对象都视为活着的,这是一种隐式的标记(这涉及到G1 MixedGC垃圾回收阶段的细节,很复杂,接下来会详细讨论)。这种解决漏标的方式是有缺陷的,它会造成真正应该被回收的白对象躲过这次GC生存到下一次GC,这就是float garbage(浮动垃圾)。因为SATB的做法精度比较低,所以造成float garbage的情况也会比较多。
因为这一步是和mutator(用户线程)并发运行的,所以从根节点扫描的时候其实是扫描的一个快照snapshot,快照位置就是prevTAMS到nextTAMS(注意快照位置是不变的,但是prevTAMS到nextTAMS之间的对象在扫描过程中会改变)。
当region中分配新对象时,新对象都会分配在nextTAMS之后,这导致top指向的位置也往后移动,nextTAMS和top之间选哪个都是被认为隐式存活。
还有这期间也有可能应该被扫描的位置prevTAMS和nextTAMS之间的位置引用发生了变化,比如白色对象被黑色对象持有了,这就是三色标记算法的缺陷,需要更改白色对象的状态。这里会将引用被更改的对象放入satb_mark_queue。satb_mark_queue是一个队列,里面记录所有被改变引用关系的白色对象。这里指的satb_mark_queue指的全局的queue。除了全局的queue,每个线程也有自己的satb mark queue,全局的queue的引用是由所有其他线程的satb mark queue合并得来的,线程的satb mark queu满了会被转移到全局satb mark queue。且并发标记阶段会定期检查全局satb mark queue的容量,超过某个容量就concurrent marker线程就会将全局satb mark que和线程satb mark que的对象都取出来全部标记上,当然也会将这些对象的子field全部压栈(marking stack)等待接下来被标记到,这个处理类似于全局dirty card quene。这里注意。
随着并发标记结束nextBitMap里也标记了哪些对象是可以回收的,但注意,不一定每个线程里satb mark queue都被转移到了全局的satb mark queue,因为合并这个过程也是并发的。所以需要下一步
最终标记(remark):
标记那些并发标记阶段发生变化的对象,就是将线程satb mark queue中引用发生更改的对象找出来,放入satb mark queue。这个阶段为了保证标记正确必须STW。
初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
根分区扫描 Root Region Scanning
在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
并发标记 Concurrent Marking
和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XXarallelGCThreads/4)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。
存活数据计算 Live Data Accounting
存活数据计算(Live Data Accounting)是标记操作的附加产物,只要一个对象被标记,同时会被计算字节数,并计入分区空间。只有NTAMS以下的对象会被标记和计算,在标记周期的最后,Next位图将被清空,等待下次标记周期。
重新标记 Remark