1.G1的垃圾回收过程概述
1.1 主要环节
G1 GC的垃圾回收过程主要包括如下三个环节:
年轻代GC (Young GC)
老年代并发标记过程(Concurrent Marking)
混合回收(Mixed GC)
(如果需要,单线程、独占式、高强度的Full Gc还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)
按照 young gc -> young gc + concurrent mark-> Mixed GC顺序,进行垃圾回收。
1.2 Young GC
应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到survivor区间或者老年区间,也有可能是两个区间都会涉及。
1.3 老年代并发标记
当堆内存使用达到-XX:InitiatingHeapOccupancyPercent(默认45%)时,开始老年代并发标记过程。
1.4 混合回收
标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了(在规定时间内挑选价值高的回收)。同时,这个老年代Region是和年轻代一起被回收的。
举个例子:一个web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
1.5 Remember Set
为什么要有Remember Set
主要是要解决一个对象被不同区域引用的问题:一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?在其他的分代收集器,也存在这样的问题(而G1更突出):回收新生代也不得不同时扫描老年代,这样明显会降低Minor GC的效率;
使用Remember Set 解决问题
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描。每个Region都有一个对应的Remembered Set;每次Reference类型数据写操作时,都会产生一个写屏障(Write Barrier)暂时中断操作,然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象)。如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set,就可以保证不进行全局扫描,也不会有遗漏。
上页提到的Remebered Set就是上述Reset,上页提到的Reference类型就是引用类型,其中Reset的作用是记录当前Region中哪些对象被外部引用指向,比如Old区中的对象会指向Eden区的对象,然后当我们要回收某个Region的时候,直接遍历当前Region中的所有对象就可以了,然后针对性的去找到那些指向当前对象的其他对象,最终确定当前对象是否是根可达的。如果不是,那就应该被删除,其实之前的垃圾回收器都涉及到这个问题,当进行Minor GC的时候,通过GC Roots查找的时候还需要遍历OId区的对象,毕竟Old区对象也可能会指向Eden区对象。但是G1通过Rset避免了全堆的扫描,当引用类型数据写操作时,先暂时中断,然后判断当前引用类型数据是否被其他对象所指向,如果不被指向,那就直接放在Region中就可以了;如果被其他对象指向,那么还要判断这个对象是在当前要插入的Region中,还是在其他Region中。如果在其他Region中,那就需要使用CardTable把当前引用类型数据的指向信息放在Rset中,也就是形成上面的虚线连线;如果在当前Region中,那就不需要指向了,毕竟到时候我们会进行遍历查找根可达对象,那肯定会找到的,所以这种情况也是直接放在Region中就可以了。
2 年轻代GC
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。年轻代垃圾回收只会回收Eden区和Survivor区。
首先G1停止应用程序的执行(Stop-The-world) ,创建回收集(collection set,是指需要被回收的内存分段的集合),年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
然后开始如下回收过程:
1、第一阶段,扫描根。可以体现Rset作用:避免全堆扫描。根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
2、第二阶段,更新RSet。作用:保证Rset中的数据准确性。处理dirty card queue(见3.8.1)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
3、第三阶段,处理RSet。作用:根可达性遍历的一部分。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
4、第四阶段,复制对象。说明:新生代使用复制算法。此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阈值会被会被复制到old区中空的内存分段。如果survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
5、第五阶段,处理引用。处理Soft,weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空(见备注),GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
备注: 空Eden: Eden变成空的,那它就变成了无主Region,因此会被记录到空链表中,等待下一次被分配。
3 dirty card queue(脏卡表队列)
什么是脏卡表队列
对于应用程序的引用赋值语句obiect.field=object(其中object.field=object中的第一个object代表老年代中的对象,而第二个object代表Eden区中的对象),JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet, 保证RSet实时准确的反映引用关系。
脏卡表队列的意义
那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
脏卡表队列的作用
Reset更新需要线程同步,所以开销会很大,因此不能实时更新,因此我们需要把引用对象被其他对象引用的关系放在一个脏卡表队列中,当年轻代回收的时候会进行STW,所以我们也正好把脏卡表队列中的值更新到Rset中,这样不仅没有涉及到开销问题,还可以保证Rset中的数据是准确的。
4 并发标记
1.初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
2.根区域扫描(Root Region Scanning) : G1 GC扫描survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成。主要扫描哪些老年代对象是可达:毕竟我们进行young GC的时候会移动Survivor区,移动之后就找不到哪些老年代对象是可达的了。
- 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
4.再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。原因:并发标记不准确
5.独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。其实是一个统计计算过程,不会涉及垃圾清理。
- 并发清理阶段:识别并清理完全空闲的区域。
5 混合回收
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC, 该算法并不是一个Old
GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。
并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。
混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收:-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间,则该region不会被回收。
混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收的内存却很少。
6 可选的垃圾回收过程:Full GC
G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-world),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决。
导致G1 Full GC的原因可能有:
Evacuation的时候没有足够的to-space来存放晋升的对象。解决:加大堆空间
并发处理过程完成之前空间耗尽。解决:调小触发并发GC周期的Java堆占用阈值(默认是45%)
最大GC停顿时间太短,导致在规定的时间间隔内无法完成垃圾回收,也会导致Full GC。解决︰加大最大GC停顿时间