深入理解Java虚拟机(三)--G1垃圾回收器

G1 GC,全称Garbage-First Garbage Collector,从官网的描述中说明G1是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现应用高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求(可预测停顿),停顿预测模型的意思是能够支持指定在一个时间长度为M毫秒的时间片段内消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。并且部分回收过程是和应用线程并发执行,采用复制和标记整理算法来减少内存碎片。

G1回收期与传统的收集器不同的是,传统的回收器要么是整个新生代(Minor GC),要么就是整个老年代(Major GC)要么是整个Java堆(Full GC)。而G1跳出这个逻辑,可以面向堆内存中任何部分来组成回收集(Collection Set,CSet)进行回收,衡量标准不再是它属于哪一个分代,而是内存哪块区域存放的垃圾最多,回收收益最大,这就是G1收集器的Mixed GC模式。虽然G1仍然保留新生代老年代的概念,但是新生代和老年代不再是固定不变的区域,它们都是一系列不用连续的动态集合。

1.核心概念

(1)分区

传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代(JDK 8去除了永久代,引入了元空间Metaspace),这种划分的特点是各代的存储地址是连续的。

图1

而G1将整个堆分为相同大小的分区,每个分区都可能是Eden区、Survivor区或老年代,同类型的Region可以不连续。

图2

还有一些分区标明了H,表示巨型对象(humongous object,H-obj),它们代表大小大于等于region一半的对象(后面会继续说明H-obj的特点)。

(2)CardTable

Card Table是划分堆的数据结构,将整个堆内存划分成一个个大小为512字节的Card,并维护一个Card Table来存储每张Card的标识位。Card Table是单字节数组,每一个元素可以用来标示所在老年代Card中对象是否持有新生代Card中对象的引用,有持有则称该老年代Card是Dirty Card。Card Table不是G1独有的结构。

图3

Card Table目的是回收新生代时减少老年代的全堆空间扫描,从而高效GC。在回收新生代的过程中,老年代对象可能引用新生代的对象,所以在GC Roots Tracing过程中就需要全堆扫描存活对象,这就导致回收新生代代价太高。Card Table的引入可以解决这个问题,因为只需要在Card Table中寻找Dirty Card,并加入GC Roots中,Dirty Card扫描后便会将其标识位清空(普通Card)。

每次引用发生变化时都需要更新Card Table,一般采用post-write barrier(引用发生变化后的处理逻辑)和处理线程来更新维护Card Table。

(3)RSet

RSet全称Remembered Set,这是一种抽象概念,用来辅助GC过程,在分代式GC时用于记录从非收集部分(Old Generation)指向收集部分(Young Generation、Old Generation)的指针集合的抽象数据结构。RSet在Card Table的基础上实现的,每个Region都有一份,这个RSet记录的是xx Region的xx Card指向RSet所在Region,这是一种"points-into"(谁引用了我的对象)的结构,而Card Table是"points-out"(我引用了谁的对象)的结构。因此,RSet是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是别的Region的Card Table Index。

举例来说,如果Region2的RSet里有一项的Key是Region1,Value里有Index为1234的Card,意思是Region1的一个Card里有引用指向Region2。所以对Region2来说,该RSet记录的是“points-into”的关系,而Card Table仍然记录了"points-out"的关系。

图4

作用

在做YGC的时候,除了Young Generation GC Root Tracing外,只需要选定Young Generation Region的RSet作为根集,这些RSet记录了Old->Young的跨代引用,避免了扫描整个Old Generation Region。 而MixedGC的时候,Old Generation Region中记录了Old->Old的RSet,Young->Old的引用由扫描全部Young Generation Region得到,这样也不用扫描全部Old Generation Region。至于为什么RSet只记录了Old->Old/Young引用是因为Young  Generation的引用变化非常频繁,GC时不如直接扫描Young  Generation Region带来的收益高(G1的YGC和MixedGC过程都会全盘扫描Young Generation Region)。

每次引用发生变化都需要更新RSet,“引用发生变化”在程序中体现就是在给引用类型的字段赋值,如果每次给引用类型的字段赋值都要更新RSet,这带来的额外开销实在太大,G1中采用post-write barrier和ConcurrentG1RefineThread实现了RSet的更新。

(4)三色标记法

基本算法

三色标记法是一些垃圾收集器的标记阶段使用的算法,在GC Roots遍历过程中,标记出哪些对象是存活的,哪些是垃圾(可回收)。

该算法将对象的状态分成三种颜色:

白色:尚未访问过。

黑色:该对象已访问过,而且该对象所引用的其他对象也全部访问过了。

灰色:该对象已访问过,但是该对象所引用的其他对象尚未全部访问完。

假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

初始时,所有对象都在【白色集合】中。

将GC Roots直接引用到的对象放入【灰色集合】中。

从灰色集合中获取对象:将本对象引用到的其他对象全部挪到【灰色集合】中;将本对象放入【黑色集合】里面。

重复步骤3,直至【灰色集合】为空时结束。

结束后,仍在【白色集合】的对象即为GC Roots不可达,可以进行回收。

图5

(5)Snapshot-At-The-Beginning(SATB)

在并发收集周期的第一个阶段(初始标记)是STW的,会给所有的分区做个快照,后面的扫描都是按照这个快照进行;在并发标记周期的第二个阶段,并发标记,这是收集线程和应用线程同时进行的,这时候应用线程就可能修改了某些引用的值,导致上面那个快照不是完整的,因此G1就想了个办法,我把在这个期间对对象引用的修改都记录动作都记录下来(有点像mysql的操作日志)。

G1的SATB的设计使得重新标记(remark)阶段只需要扫描改动的记录,而CMS在remark阶段需要重新扫描所有的线程栈和年轻代作为GC roots。

2.回收主要过程

G1主要包含的四种操作

(1)Young GC:Eden区域满了后触发,并行收集整个新生代,且完全STW。

(2)并发标记周期:它的第一个阶段初始化标记和YGC一起发生,这个阶段的目的就是找到回收价值最大的Region集合(垃圾很多,存活对象很少),为接下来的Mixed GC服务。

(3)Mixed GC:回收所有年轻代的Region和部分老年代的Region,Mixed GC可能连续发生多次。

(4)Full GC:非常慢,会STW且回收所有类型的Region。

2.1 Young GC

Young GC是STW的,在分配一般对象(非巨型对象)时,当所有Eden Region使用达到最大阈值并且无法申请足够内存时,会触发一次Young GC。每次Young GC会回收所有Eden以及一个Survivor区,并且将存活对象复制到Old区(涉及到晋升到老年代规则)以及另一个的Survivor区。

Young GC一般过程:

扫描GC Roots根对象。

处理Dirty Card,更新RSet。ConcurrentG1RefineThread会对Dirty Card的分区进行扫描更新日志缓冲区来更新RSet,但只会处理全局缓冲列表。作为补充,所有被记录但是还没有被ConcurrentG1RefineThread处理的剩余缓冲区,会在该阶段处理,变成已处理缓冲区(Processed Buffers)。

扫描RSet,扫描RSet中所有Old区对扫描到的Young区引用,避免了Old区全扫描。

拷贝扫描出的存活的对象到Survivor/Old区,将待回收的Region全部加入CSet进行回收。

Global Concurrent Marking

Global Concurrent Marking是为Mixed GC提供标记服务的(一般会将Global Concurrent Marking包含在Mixed GC过程中,这里单独拆开描述),用来统计出收集收益最高的若干老年代Region。主要使用Region的bitmap结构和TAMS指针来支持整个标记过程,下节开始会按照Global Concurrent Marking的四个步骤对两个bitmap和两个TAMS指针进行详细说明。

四个步骤:

初始标记(Initial Marking,STW)

并发标记(Concurrent Marking)

最终标记(Remark,STW)

清理阶段(Cleanup)

bitmap结构:prevBitmap和nextBitmap。preBitmap记录是上一轮Concurrent Marking后的老年代Region对象的标记状态,因为上一轮已经完成,所以可以直接使用prevBitmap;nextBitmap是当前这一轮Concurrent Marking的结果,尚未完成,不能使用。当前这一轮Concurrent Marking结束后,prevBitmap和nextBitmap互换。

TAMS指针:prevTAMS和nextTAMS。prevTAMS记录的上一轮Concurrent Marking后的老年代Region对象的top指针(指向已使用部分最大地址)位置;nextTAMS记录的是当前这一轮Concurrent Marking的top指针。当前这一轮Concurrent Marking结束后,prevTAMS和nextTAMS互换。

初始标记(Initial Marking, STW)

这阶段仅仅只是标记GC Roots能直接关联到的对象,直接关联到的对象就是下图Region内存已使用的部分中(botoom和top指针之间);并修改nextTAMS的指针指向为top和创建nextBitmap,就绪后让下一阶段应用线程并发运行时,能在可用的Region中创建新对象。这是STW的过程,共用了Young GC的暂停,这是因为他们可以复用GC Roots Scan操作,所以可以说Global Concurrent Marking是伴随Young GC而发生的。

图6

并发标记(Concurrent Marking)

在并发标记阶段,下图GC线程工作在prevTAMS和nextTAMS 之间,对堆里的对象进行GC Roots可达性分析,边标记边更新nextBitmap,黑色对应的是存活对象,白色对应的垃圾对象。应用线程是工作在bottom和top之间的,和GC线程工作区间不重叠的地方是新对象的创建(创建新对象分配在nextTAMS和top指针之间),而重叠的地方可能会造成对象误标,所以还要同时处理pre-write barrier记录的在并发时有引用变动的对象,以上来保证SATB。

图7

最终标记(Remark, STW)

这一阶段对应用线程做另一个短暂的暂停,用于处理并发标记阶段结束后仍遗留下来的最后那少量的pre-write barrier引用变动记录,这是因为并发标记阶段处理pre-write barrier引用变动的时候应用线程还可能持续在产生新的引用变动,所以必须STW一会来处理剩余的最后一小部分记录。

图8

清理阶段(Cleanup)

这阶段包括了清理和回收。这阶段的清理是将nextBitmap和prevBitmap合并,并统计该Region有多少对象存活,如果该Region没有对象存活,那么就会在该阶段的回收部分进行整体回收,并加入到可分配Region列表中。最后将prevBitmap和nextBitmap互换,prevTAMS和nextTAMS互换。

图9

以上便是G1收集器进行YGC的过程。

2.2 Mixed GC特点:

(1)Mixed GC是完全STW的,它根据用户设置的停顿时间目标,可以选择回收所有年轻代,以及部分老年代Region集合(CSet)

(2)在一次完整的全局并发标记周期后,如果满足触发Mixed GC的条件,那么就会执行Mixed GC,并且Mixed GC可能会执行多次。当整个堆的使用率超过指定百分比时,GC会启动新一轮的并发标记周期。

何时发生Mixed GC?被几个参数控制:

(1)G1HeapWastePercent:在global concurrent marking结束之后,我们可以知道老年代中有多少空间要被回收,如果老年代中可回收的内存/老年代总内存到此参数(默认5%),才会发生Mixed GC;否则发生YGC。

(2)有多少分区会被认定为垃圾分区。-XX:G1MixedGCLiveThresholdPercent=n表示如果一个分区存活对象比例超过n,就不会被选为垃圾分区。值越大越容易被当做垃圾分区。通过它可以控制每次混合收集的分区个数

(3)G1在一个并发周期中,最多经历几次Mixed GC,这个可以通过-XX:G1MixedGCCountTarget=n设置,默认是8,如果减小这个值,可以增加每次混合收集收集的分区数,但是可能会导致停顿时间过长;

(4)最大停顿时间MaxGCPauseMillis,默认是200ms,如果实际运行时间比这个值小,G1就能收集更多的分区

在选定CSet后,G1在执行Evacuation阶段时,其实就跟YGC的算法类似,采用并行复制算法把CSet里每个Region中的存活对象拷贝到新的Region里,然后回收掉这些Region,整个过程完全STW。这个过程相当于进行压缩,因此G1出现碎片化的频率比CMS小得多

Evacuation拷贝&清理

Evacuation,就是将存活对象拷贝到1个或多个Region中,并清理RSet中的Region;YGC和Mixed GC中都包含这个过程。

STW阶段,Evacuation过程有两种模式:既可能由YGC来完成,只回收所有的年轻代(GC日志样例:[GC pause (young)])。也可能是Mixed GC来完成的,即回收所有的年轻代外加根据global concurrent marking统计得出收集收益高的部分老年代Region(GC日志样例:[GC Pause (mixed)]):

也就是说,并发标记周期之后不一定发生Mixed GC,需要看是否满足Mixed GC的条件。

2.3 Full GC

在G1的正常工作流程中没有Full GC的概念,老年代的收集全靠Mixed GC来完成。

(1)但是,毕竟Mixed GC有搞不定的时候,如果Mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会切换到G1之外的Serial Old GC来收集整个堆,进入这种状态的G1就跟-XX:+UseSerialGC的Full GC一样(背后的核心代码是两者共用的),在发生Full GC时是单线程运行的,因此应该尽量避免发生Full GC。

(2)这就是为什么不建议G1模式下参数-XX:MaxGCPauseMillis=200 的值设置太小,如果设置太小,可能导致每次Mixed GC只能回收很小一部分Region,最终可能无法跟上程序分配内存的速度,从而触发Full GC。

(3)顺带一提,G1模式下的System.gc()默认也是Full GC,。只有加上参数 -XX:+ExplicitGCInvokesConcurrent 时G1才会用自身的并发GC来执行System.gc();

几种导致Full GC的情况:

(1)concurrent mode failure

说明:G1并发标记期间,如果在标记结束前,老年代被填满,G1 会放弃标记。

解决:增加堆大小;调整并发周期,例如增加并发标记的线程数,让并发标记尽快结束;更早进行并发周期,默认是整堆45%占用就开始

(2)晋升失败

说明:混合收集过程中,伴随着年轻代垃圾收集,进行清理老年代空间,如果这个时候清理的速度小于消耗的速度,导致老年代不够用,那么会发生晋升失败。

(3)疏散失败--to space overflow

说明:YGC的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象

(4)大对象分配失败,没有连续可用的region存放巨型对象(G1会挑选出1组物理连续的可用 Region, 相加后只要能够确保总大小可以存放这个巨型对象,就会分配给这个巨型对象。反之如果没有能够找到符合条件的连续可用Region ,会执行1次Full GC压缩堆)

(5)元空间 = MaxMetaspaceSize

3.巨型对象的管理

巨型对象:一个对象的大小超过region大小的一半。

特点:

巨型对象直接分配到了老年代,防止了反复拷贝移动。

在JDK8u40之前,巨型对象的回收只能在并发收集周期的clean up或FULL GC过程中被回收,在JDK8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在YGC中被回收

巨型对象存放在连续可用的region,并且独占region

如果发现有很多由于巨型对象分配引起的连续并发周期,并且堆已经碎片化了(明明空间够,却触发full GC),可以考虑使用-XX:G1HeapRegionSize命令增大分区大小,将巨型对象变为普通对象,从而减少巨型对象分配对GC的影响。

最后:打一个小广告,后续的文章会在微信公众号“程序员之家QAQ”推送,欢迎大家搜索关注~~

你可能感兴趣的:(深入理解Java虚拟机(三)--G1垃圾回收器)