G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
G1垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器,在 JDK9 中更被指定为官方GC收集器
一、G1垃圾收集器的开发背景:
1.1 CMS 垃圾收集器的缺陷:
JVM 团队设计出 G1 收集器的目的就是取代 CMS 收集器,因为 CMS 收集器在很多场景下存在诸多问题,缺陷暴露无遗,具体如下:
(1)CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4
(2)CMS收集器无法处理浮动垃圾,由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自然会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,称为“浮动垃圾”,CMS 无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。
(3)由于垃圾收集阶段会产生“浮动垃圾”,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以参数 -XX:CMSInitiatingOccupancyFraction 设置的过高将会很容易导致 “Concurrent Mode Failure” 失败,性能反而降低。
(4)CMS是基于“标记-清除”算法实现的收集器,会产生大量不连续的内存碎片。当老年代空间碎片太多时,如果无法找到一块足够大的连续内存存放对象时,将不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程。
1.2 G1 垃圾收集器的特点:
G1(Garbage First)收集器是 JDK7 提供的一个新收集器,在 JDK9 中更被指定为官方GC收集器,与CMS收集器相比,最突出的改进是:
基于 “标记-整理” 算法,收集后不会产生内存碎片。
可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
二、G1的内存模型
G1是一个分代的,增量的,并行与并发的标记-复制垃圾回收器。它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。G1回收器和CMS比起来,有以下不同:
G1垃圾回收器是compacting的,因此其回收得到的空间是连续的。这避免了CMS回收器因为不连续空间所造成的问题。如需要更大的堆空间,更多的floating garbage。连续空间意味着G1垃圾回收器可以不必采用空闲链表的内存分配方式,而可以直接采用bump-the-pointer的方式;
G1回收器的内存与CMS回收器要求的内存模型有极大的不同。G1将内存划分一个个固定大小的region,每个region可以是年轻代、老年代的一个。内存的回收是以region作为基本单位的;
G1还有一个及其重要的特性:软实时(soft real-time)。所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。
2.1 相关参数
-XX:+UseG1GC:使用G1垃圾回收算法
-XX:G1HeapRegionSize=size:G1堆区域的划分大小
-XX:MaxGCPauseMillis=time:默认的最大暂停时间
例子:
-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200
-XX:+UseG1GC为开启G1垃圾收集器,
-Xmx32g 设计堆内存的最大内存为32G,
-XX:MaxGCPauseMillis=200设置GC的最大暂停时间为200ms。
如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。
其次,G1将新生代,老年代的物理空间划分取消了。
这样我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。
2.2 分区Region
G1将Java堆划分为多个大小相等的独立区域(Region),JVM目标是不超过2048个Region(JVM源码里TARGET_REGION_NUMBER 定义),实际可以超过该值,但是不推荐。
一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数“-XX:G1HeapRegionSize”手动指定Region大小,但是推荐默认的计算方式。
G1虽然抛弃了新生代和老年代作为整块内存空间的方式,但还是保留了年轻代和老年代的概念,只是新生代和老年代的内存空间不再是物理隔阂了,它们都是(可以不连续)Region的集合。
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
3. G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous区中,而且一个大对象如果太大,可能会横跨多个Region来存放。
Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
2.3 本地分配缓冲 Local allocation buffer (Lab)
值得注意的是,由于分区的思想,每个线程均可以"认领"某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区(Lab)。
其中,应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。
2.4 (RSet) 记忆集合 Remember Set
在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,在每个Region(分区)记录了一个记忆集合(RSet),内部类似一个反向指针,记录引用分区内对象的卡片索引。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。
事实上,并非所有的引用都需要记录在RSet中,如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象,当然不用落入RSet中;同时,G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区(an RSet’s owning region)。
2.4.1 Per Region Table(PRT)
RSet在内部使用Per Region Table(PRT)记录分区的引用情况。由于RSet的记录要占用分区的空间,如果一个分区非常"受欢迎",那么RSet占用的空间会上升,从而降低分区的可用空间。G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:
稀少:直接记录引用对象的卡片索引
细粒度:记录引用对象的分区索引
粗粒度:只记录引用情况,每个分区对应一个比特位
由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。
2.5 Card Table
如果一个线程修改了Region内部的引用,就必须要去通知RSet,更改其中的记录。需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,因此 G1 回收器引入了 Card Table 解决这个问题。
一个 Card Table 将一个 Region 在逻辑上划分为若干个固定大小(介于128到512字节之间)的连续区域,每个区域称之为卡片 Card,因此 Card 是堆内存中的最小可用粒度,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过卡片 Card 来查找(见RSet),每次对内存的回收,也都是对指定分区的卡片进行处理。每个 Card 都用一个 Byte 来记录是否修改过,Card Table 就是这些 Byte 的集合,是一个字节数组,由 Card 的数组下标来标识每个分区的空间地址。默认情况下,每个 Card 都未被引用,当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外 RSet 也将这个数组下标记录下来。
一个Region可能有多个线程在并发修改,因此也可能会并发修改 RSet。为避免冲突,G1垃圾回收器进一步把 RSet 划分成了多个哈希表,每个线程都在各自的哈希表里修改。最终,从逻辑上来说,RSet 就是这些哈希表的集合。哈希表是实现 RSet 的一种常见方式,它的好处就是能够去除重复,这意味着,RS的大小将和修改的指针数量相当,而在不去重的情况下,RSet的数量和写操作的数量相当。
图中的RSet并不是一个和Card Table独立的,不同的数据结构,而是指RSet是一个概念模型。实际上,Card Table是RSet的一种实现方式。
2.6 RSet的维护
由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。在G1中,RSet的维护主要来源两个方面:写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)
2.6.1 写栅栏
我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。
写前栅栏 Pre-Write Barrrier
即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。
写后栅栏 Post-Write Barrrier
当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。
2.6.2 (SATB) 起始快照算法 Snapshot at the beginning
Taiichi Tuasa贡献的增量式完全并发标记算法起始快照算法(SATB),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。
SATB会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。
2.6.3 并发优化线程 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应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。
2.7 (CSet) 收集集合 Collect Set
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。
候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。
由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
2.7.1 年轻代收集集合 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),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。
2.7.2 混合收集集合 CSet of Mixed Collection
年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。
为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。
三、G1垃圾收集器收集过程
3.1 Marking bitmaps和TAMS
Marking bitmap是一种数据结构,其中的每一个bit代表的是一个可用于分配给对象的起始地址。举例来说:
其中addrN代表的是一个对象的起始地址。绿色的块代表的是在该起始地址处的对象是存活对象,而其余白色的块则代表了垃圾对象。
G1使用了两个bitmap,一个叫做previous bitmap,另外一个叫做next bitmap。
previous bitmap记录的是上一次的标记阶段完成之后的构造的bitmap;
next bitmap则是当前正在标记阶段正在构造的bitmap。
在当前标记阶段结束之后,当前标记的next bitmap就变成了下一次标记阶段的previous bitmap。
TAMS(top at mark start)变量,是一对用于区分在标记阶段新分配对象的变量,分别被称为previous TAMS和next TAMS。在previous TAMS和next TAMS之间的对象则是本次标记阶段时候新分配的对象。如图:
白色region代表的是空闲空间,绿色region代表是存活对象,橙色region代表的在此次标记阶段新分配的对象。注意的是,在橙色区域的对象,并不能确保它们都事实上是存活的。
3.2 垃圾收集器的算法
fully-young generational mode:有时候也会被称为young GC,该模式只会回收young region,算法是通过调整young region的数量来达到软实时目标的;
partially-young mode:也被称为Mixed GC,该阶段会回收young region和old region,算法通过调整old region的数量来达到软实时目标;
有趣的地方是不论处在何种模式之下,yong region都在被回收的范围内。而old region只能期望于Mixed GC。但是,如同在CMS垃圾回收器中遇到的困境一样,Mixed GC可能来不及回收old region。也就说,在需要分配老年代的对象的时候,并没有足够的空间。这个时候就只能触发一次full GC。
算法会自动在young GC和mixed GC之间切换,并且定期触发Marking cycle phase。HotSpot的G1实现允许指定一个参数InitiatingHeapOccupancyPercent,在达到该参数的情况下,就会执行marking cycle phase。
算法并不使用在对象头增加字段来标记该对象,而是采用bitmap的方式来记录一个对象被标记的情况。这种记录方法的好处就是在使用这些标记信息的时候,仅仅需要扫描bitmap而已。G1统计一个region的存活的对象,就是依赖于bitmap的标记。
3.3 并发标记周期 Concurrent Marking Cycle
并发标记周期是G1中非常重要的阶段,这个阶段将会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别所有(可能)存活对象,并计算每个分区的活跃度,从而确定GC效率等级。
当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,便会触发并发标记周期。
整个并发标记周期为:
初始标记(Initial Mark):G1收集器扫描所有的根。该过程是和young GC的暂停过程一起的;
根分区扫描(Root Region Scanning):扫描Survivor Regions中指向老年代的被initial mark phase标记的引用及引用的对象,这一个过程是并发进行的。但是该过程要在下一个young GC开始之前结束;
并发标记(Concurrent Marking):并发标记阶段,标记整个堆的存活对象。该过程可以被young GC所打断。并发阶段产生的新的引用(或者引用的更新)会被SATB的write barrier记录下来;
重新标记(Remark):
也叫final marking phase。该阶段只需要扫描SATB(Snapshot At The Beginning)的buffer,处理在并发阶段产生的新的存活对象的引用。作为对比,CMS的remark需要扫描整个mod union table的标记为dirty的entry以及全部根;
清除(Cleanup):
清理阶段。该阶段会计算每一个region里面存活的对象,并把完全没有存活对象的Region直接放到空闲列表中。在该阶段还会重置Remember Set。该阶段在计算Region中存活对象的时候,是STW(Stop-the-world)的,而在重置Remember Set的时候,却是可以并行的;
其中,初始标记(随年轻代收集一起活动)、重新标记、清除是STW的,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集。
3.3.1 初始标记 Initial Mark
初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
3.3.2 根分区扫描 Root Region Scanning
在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
该过程主要是扫描Survivor region中指向老年代的,在initial mark标记的引用及其引用的对象。这是一个很奇怪的步骤,因为在前面不论是Parallel Collector还是CMS,都没有这么一个步骤。
要理解这一点,要注意的是,算法的两种模式,不论是young GC还是mixed GC,都需要回收young region。因为实际上RSet是不记录从young region出发的指针,例如,这部分指针包括young region - young region,也包括young-region - old region指针。那么就可能出现一种情况,一个老年代的存活对象,只被年轻代的对象引用。在一次young GC中,这些存活的年轻代的对象会被复制到Survivor Region,因此需要扫描这些Survivor region来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年代的根的一部分。
在理解了这一点的基础上,那么对于阶段必须在下一次young GC启动前完成的要求,也就理解了。因为如果第二次的young GC启动了,那么这个过程中,survivor region就可能发生变化。这个时候执行root region phase就会产生错误的结果。
3.3.3 并发标记 Concurrent Marking
和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。
可以说,在并发标记阶段,因为应用还在运行,所以可能会有引用变更,包括现有引用指向别的对象,或者删除了一个引用,或者创建了一个新的对象等。G1采用的是使用SATB的并发标记算法。
在G1中,该算法的关键在于,如果在并发标记的时候,出现了引用修改(不包含新分配内存给对象),那么写屏障会把这些引用的原始值捕获下来,记录在log buffer中。而后再处理。后续的所有的标记,都是从原来的值出发,而不是从新的值出发的。
SATB是一个逻辑上存在概念,在实际中并没有任何真的实际的数据结构与之对应。叫这个名字,是因为,一旦进入了concurrent marking阶段,那么该在该阶段的运行过程中,即便应用修改了引用,但是因为SATB的写屏障记录下来了原始的值,在遍历整个堆查找存活对象的时候,使用的依然是原来的值。这就是在逻辑上保持了一个snapshot at the beginning of concurrent marking phase。
在处理新创建的对象,G1采用了不同的方式。G1用了两个TAMS变量了判断新创建的对象。一个叫做previous TAMS,一个叫做next TAMS。位于两者之间的对象就是新分配的对象。
并发标记阶段,bitmap和TAMS的作用如图:
该图的详细解释如下:
A是第一次marking cycle的initial marking阶段。next bitmap尚未标记任何存活对象,而此时的previous TAMS被初始化为region内存地址起始值,next TAMS被初始化为top。top实际上就是一个region未分配区域和已分配区域的分界点;
B是经过concurrent marking阶段之后,进入了remark阶段。此时存活对象的扫描已经完成了,因此next bitmap构造好了,刚好代表的是当下状态中region中的内存使用情况。注意的是,此时top已经不再与next TAMS重合了,top和next TAMS之间的就是在前面标记阶段之时,新分配的对象;
C代表的是clean up阶段。C和B比起来,next bitmap变成了previous bitmap,而在bitmap中标记为垃圾(也就是白色区域的)的对应的region的区域也被染成了浅灰色。这并不是指垃圾对象已经被清扫了,仅仅是标记出来了。同时next TAMS和previous TAMS也交换了角色;
D代表的是下一个marking cycle的initial marking阶段,该阶段和A类似,next TAMS重新被初始化为top的值;
EF就是BC的重复;
3.3.4 重新标记 Remark
重新标记(Remark)是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间(STW),去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。这个阶段也是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
3.3.5 清除 Clean up
紧挨着重新标记阶段的清除(Clean)阶段也是STW的。Previous/Next标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。清除阶段主要执行以下操作:
RSet梳理,启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。参数-XX:+PrintAdaptiveSizePolicy可以开启打印启发式算法决策细节;
整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;
识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期。
3.3 G1 Young GC
当Eden区已满,JVM分配对象到Eden区失败时,便会触发一次STW式的年轻代收集young GC,将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到年老代空间。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
young GC 还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升时的去向。young GC 首先将晋升对象尺寸总和、年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量 -XX:TargetSurvivorRatio(默认50%)、最大任期阈值 -XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。
这时,我们需要考虑一个问题,如果仅仅 GC 新生代对象,我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是就需要使用到我们上文介绍到的 RSet 了。RSet 中记录了其他 region 对当前 region 的引用,因此,在进行Young GC 时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
3.3.1、young GC的详细回收过程
(1)第一阶段,根扫描:
根是指static变量指向的对象、正在执行的方法调用链上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。
(2)第二阶段,更新RSet:
处理 dirty card 队列中的 card,更新 RSet,此阶段完成后,RSet 可以准确的反映老年代对所在的region 分区中对象的引用
(3)第三阶段:处理RSet:
识别被老年代对象指向的 Eden 中的对象,这些被指向的Eden中的对象被认为是存活的对象
(4)第四阶段:对象拷贝:
将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到年老代空间。
(5)第五阶段:处理引用:
处理软引用、弱引用、虚引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的、没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
3.4 G1 Mixed GC
老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
3.7 Full GC
停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
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使用
4.1 G1收集器参数设置
-XX:+UseG1GC:使用G1收集器
-XX:ParallelGCThreads:指定GC工作的线程数量
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)
-XX:G1MaxNewSizePercent:新生代内存最大空间
-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
-XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
-XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
4.2 G1垃圾收集器优化建议
假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。
那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。
或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。
所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc.
4.3 G1什么场景适合使用
50%以上的堆被存活对象占用
对象分配和晋升的速度变化非常大
垃圾回收时间特别长,超过1秒
8GB以上的堆内存(建议值)
停顿时间是500ms以内
4.4 每秒几十万并发的系统如何优化JVM(题外话)
Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。
五、总结
G1是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的GC暂停目标,就能得到不错的性能;同时,我们也看到G1对内存空间的浪费较高,但通过首先收集尽可能多的垃圾(Garbage First)的设计原则,可以及时发现过期对象,从而让内存占用处于合理的水平。
参考资料:
[1]详解 JVM Garbage First(G1) 垃圾收集器_coderlius的博客-CSDN博客
[2]G1垃圾回收器详解 - 简书 (jianshu.com)
[3]关于G1 GC底层原理的深度研究 - 知乎 (zhihu.com)