G1 GC概念简介
背景知识
G1使用了全新的分区算法,其特点如下所示:
- 并行性:G1在回收期间,可以有多个GC线程同时工作,可以有效利用多核的计算能力
- 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
- 分代GC:G1依然是一个分代收集器,但是和之前的各类回收器不同,它同时兼顾了年轻代和老年代。对比其他回收器,它们或者工作在年轻代,或者工作在老年代。
- 空间整理:G1在回收过程中,会进行适当的对象移动,不像CMS那样只是简单地标记清理对象。在若干次GC后,CMS必须进行一次碎片整理。而G1不同,它每次回收都会有效地复制对象,减少空间碎片,进而提升内部循环速度。
- 可预见性:由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
随着G1 GC的出现,GC从传统的连续堆内存布局逐渐走向了不连续内存块布局,这是通过引入Region概念实现的,也就是说,由一堆不连续的Region组成了堆内存。其实也不能说是不连续的,只是它从传统的物理连续逐渐改变为逻辑上的连续,这是通过Region的动态分配方式实现的,可以把一个Region分配给Eden、Surviⅳvor、老年代、大对象区间、空闲区间等区间的任意一个,而不是固定它的作用,因为越是固定,越是呆板。
G1的区间设计灵感
在G1中,堆被平均分成若干个大小相等的区域(Region)。每个Region都有个关联的Remembered Set(简称RS),RS的数据结构是Hash表,里面的数据是Card Table(堆中每512byte映射在card table 1byte)。简单地说,RS里面存在的是Region中存活对象的指针。当Region中数据发生变化时,首先反映到Card Table中的一个或多个Card上,RS通过扫描内部的Card Table得知Region中内存使用情况和存活对象。在使用Region过程中,如果Region 被填满了,分配内存的线程会重新选择一个新的Region,空闲Region被组织到一个基于链表的数据结构(LinkedList里面,这样可以快速找到新的Region。
G1 GC分代管理
年轻代
除非我们显示地通过命令行方式声明了年轻代的初始化值和最大值的大小,否则,一般来说,初始化值默认是整个Java堆大小的5%(通过选项-XX:G1NewSizePercent设置),最大值默认是整个Java堆大小的60%(通过选项-XX:G1MaxNewSizePercent设置)。
回收集合及其重要性
任何一次垃圾回收都会释放CSet里面的所有区间。一个CSet由一系列的等待回收的区间所组成。在一次垃圾回收过程中,这些回收候选区间的存活对象会被整体评估,并且在回收结束后这些区间会被加入到空闲区间队列(LinkedList队列)。在一次年轻代回收过程中,CSet只会包含年轻代区间,而在一个混合回收过程中,CSet会在年轻代区间基础上再包含一些老年代区间,这就是新增的混合回收概念,不再对年轻代和老年代完全切分。
G1 GC提供了两个选项用于帮助选择进入CSet的候选老年代区间:
- -XX:G1MixedGCLiveThresholdPercent:JDK8u45默认值为一个G1 GC区间的85%。这个值是一个存活对象的阈值,并且起到了从混合回收的CSet里排除一些老年代区间的作用,即可以理解为G1 GC限制CSet仅包含低于这个阈值(默认85%)的老年代区间,这样可以减少垃圾回收过程中拷贝对象所消耗的时间。
- -XX:G1OldCSetRegionThresholdPercent:JDK8u45默认值为整个Java堆区的10%。这个值设置了可以被用于一次混合回收暂停所回收的最大老年代区间数量。这个阈值取决于JVM进程所能使用的Java堆的空闲空间。
RSet及其重要性
一个RSet是一个数据结构,这个数据结构帮助维护和跟踪在它们单元内部的对象引用信息,在G1 GC里,这个单元就是区间(Region),也就是说,G1 GC里每一个RSet对应的是一个区间内部的对象引用情况。有了RSet,就不需要扫描整个堆内存了,当G1 GC执行STW独占回收(年轻代、混合代回收)时,只需要扫描每一个区间内部的RSet就可以了。因为所有RSet都保存在CSet里面,即Region-RSet-CSet这样的概念,所以一旦区间内部的存活对象被移除,RSet里面保存的引用信息也会立即被更新。这样我们就能够理解RSet就是一张虚拟的对象引用表了,每个区间内部都有这么一张表存在,帮助对区间内部的对象存活情况、基本信息做有序高效的管理。`
G1 GC的年轻代回收或者混合回收阶段,由于年轻代被尽可能地设计为最大量的回收,这样的设计方式减少了对于RSet的依赖,即减弱了对于年轻代里面存储的跟踪引用信息的依赖程度,进而减弱了多余RSet的消耗。G1 GC只在以下两个场景依赖RSet。
- 老年代到年轻代的引用:G1 GC维护了从老年代区间到年轻代区间的指针,这个指针保存在年轻代的RSet里面。
- 老年代到老年代的引用:G1 GC维护了从老年代区间到老年代区间的指针,这个指针保存在老年代的RSet里面。
每一个区间只会有一个RSet由于对于对象的引用是基于Java应用程序的需求的,所以有可能会出现RSet内部的“热点”,即一个区间出现很多次的引用更新,都出现在同一个位置的情况。
对于一个访问很频繁的区间来说,这样的方式会影响RSet的扫描时间。
注意,区间(Region)并不是最小单元,每个区间会被进一步划分为若干个块(Chunks)。在G1 GC区间里,最小的单元是一个512个字节的堆内存块(Card)。G1 GC为每个区间设置了一个全局内存块表来帮助维护所有的堆内存块,如下图所示:
当一个指针引用到了RSet里面的一个区间时,包含该指针的堆内存块就会在PRT里面被标记。如果需要快速地扫描一张数据表,最好的方式是建立索引,一个粗粒度的PRT就是基于哈希表建立的。对于一个细粒度的PRT来说,哈希表内部的每一个入口对应一个区间,而区间内部的内存块索引也是存储在位图里面的。当细粒度PRT的最大值被突破的时候,我们就会开始采用粗粒度方式处理PRT。
在垃圾回收过程中,当扫描RSet并且内存块确实存在于PRT里时,G1 GC会在全局堆内存块数据表里标记对应的入口,这种做法避免了重新扫描这个内存块。G1 GC会在回收循环阶段默认清除内存堆表,在GC线程的并行工作(主要包括根外部扫描、更新和扫描RSet、对象拷贝、终止协议等)完成之后紧跟着的就是清除堆内存表标记(Clear CT)阶段。Update RS和Scan RS对应的是RSet的更新和扫描动作。
RSet的作用是很明显的,但是在使用过程中我们也遇到了写保护和并行更新线程的维护成本。
OpenJDK HotSpot的并行老年代和CMS GC都在执行JVM的一个对象引用写操作时使用了写保护机制,如代码object field = some_other_object。还记得我们对于每个区间是采用针对最小单元堆内存块进行管理的吗?这个写保护机制也会通过更新一个类似于堆内存块表的数据结构来跟踪跨年代引用。堆内存表在最小垃圾回收时会被扫描。写保护算法基于Urs Holzle的快速写保护算法,这个算法减少了编译代码时的外部指令消耗。
当跨越区间的更新发生的时候,G1 GC会将这些对应的堆内存块放入一个缓存,我们可以称这个缓存为“更新日志缓存”,写入该缓存的方式和写入队列的方式一样。G1 GC会使用一个专门的线程组去维持RSet信息,它们的职责是扫描“更新日志缓存”,然后更新RSet。JDK8u45采用选项-XX:G1ConcRefinementThreads设置这个线程组的数量,如果你没有设置,那么默认采用-XX:ParallelGCThreads选项。
一旦“更新日志缓存”达到了最大可用,它会被放入全局化的满载队列并启用一个新的缓存块。一旦更新线程在全局满载队列里面发现了入口,它们就开始并行处理整个满载缓存队列。
G1 GC针对并行更新线程采用的是分层方法,为了保证更新速度会加入更多的线程,如果实在跟不上速度,Java应用程序线程也会加入战斗,但尽量不要出现这样的情况,这种情况是发生了线程窃取,会造成应用程序花费了本可以用于自身程序算法运行的能力。
这页有点讲晕了。。。
并行标记循环
并行标记循环的过程是初始标记阶段→根区间扫描阶段→并行标记阶段→重标记阶段→清除阶段,其中一部分是可以与应用程序并行执行的,一部分是独占式的。
1.初始标记阶段
这个阶段是独占式的,它会停止所有的Java线程,然后开始标记根节点可及的所有对象。这个阶段可以和年轻代回收同时执行,这样的设计方式主要是为了加快独占阶段的执行速度。
在这个阶段,每一个区间的NATMS值会被设置在区间的顶部。
2.根区间扫描阶段
设置了每个区间的TAMS值之后,Java应用程序线程重新开始执行,根区间扫描阶段也会和Java应用程序线程并行执行。基于标记算法原理,在年轻代回收的初始标记阶段拷贝到幸存者区间的对象需要被扫描并被当作标记根元素,相应地,G1 GC因此开始扫描幸存者区间。任何从幸存者区间过来的引用都会被标记,基于这个原理,幸存者区间也被称为根区间。
根区间扫描阶段必须在下一个垃圾回收暂停之前完成,这是因为所有从幸存者区间来的引用需要在整个堆区间扫描之前完成标记工作。
3.并行标记阶段
首先可以明确的是,并行标记阶段是一个并行的且多线程的阶段,可以通过选项-XX:ConcGCThreads来设置并行线程的数量。默认情况下,G1 GC设置并行标记阶段线程数量为选项-XX:ParallelGCThreads(并行GC线程)的1/4。并行标记线程一次只扫描一个区间,扫描完毕后会通过标记位方式标记该区间已经扫描完毕为了满足SATB并行标记算法的要求,G1 GC采用一个写前barrier执行相应的动作。
4.重标记阶段
重标记阶段是整个标记阶段的最后一环。这个阶段是一个独占式阶段,在整个独占式过程中,G1 GC完全处理了遗留的SATB日志缓存、更新。这个阶段主要的目标是统计存活对象的数量,同时也对引用对象进行处理。
G1 GC采用多线程方式加快并行处理日志缓存文件,这样可以节省下来很多时间,通过选项-XX:ParallelGCThreads可以设置GC数量。
注意,如果你的应用程序使用了大量的引用对象,例如弱引用、软引用、虚引用、强引用,那么这个重标记阶段的耗时会有所增加。
5.清除阶段
前面各个阶段在做的主要事情就是为了标记对象,那么为什么需要针对每一个区间进行标记呢?这是因为如果我们知道了每个区间的存活对象数量,如果这个区间没有一个存活对象,那么就可以很快地清除RSet,并且立即放入空闲区间队列,而不是将这个区间放入排队序列,等待一个混合垃圾回收暂停阶段的回收。RSet也可以被用来帮助检测过期引用,例如,如果标记阶段发现所有在特定堆块上的对象都已经死亡,那么RSet可以快速清除这块堆块。
一句话总结,清除阶段会识别并清理完全空闲的区域。它是并发的清理,不会引起停顿。
评估失败和完全回收
如果在年轻代区间或者老年代区间执行拷贝存活对象操作的时候,找不到一个空闲的区间,那么这个时候就可以在GC日志里看到诸如“to-space exhausted”这样的错误日志打印。
发生这个错误的同时,G1 GC会尝试去扩展可用的Java堆内存大小。如果扩展失败,G1 GC会触发它的失败保护机制并且启动单线程的完全回收动作。
在这个完全回收阶段,单线程会针对整个堆内存里的所有区间进行标记、清除、压缩等动作。在完成回收后,堆内存就完全由存活对象填充,并且所有的年龄代对应的区间都已经完成了压缩任务。
也正是因为这个完全回收是单线程执行的,所以当堆内存很大时势必耗时很长,所以需要谨慎使用,最好不要让它经常发生,以避免不必要的长时间的应用程序暂停。
G1 GC使用场景
如果应用程序具有如下的一个或多个特征,那么将垃圾收集器从CMS或ParallelOldGC切换到G1将会大大提升性能:
- Full GC次数太频繁或者消耗时间太长
- 对象分配的频率或代数提升(promotion)显著变化。
- 受够了太长的垃圾回收或内存整理时间(超过0.5~1s)
注意,如果正在使用CMS或ParallelOldGC,而应用程序的垃圾收集停顿时间并不长,那么继续使用现在的垃圾收集器是个好主意。
G1 GC性能优化方案
G1的年轻代回收
External Root Regions
外部根区间扫描指的是从根部开始扫描通过JNI中本地的类中调用Malloc函数分配出的内存。这个步骤是并行任务的第一个任务。这个阶段堆外(off-heap)根节点被开始扫描,这些扫描范围包括JVM系统字典、VM数据结构、JNI线程句柄、硬件注册器、全局变量,以及线程栈根部等,这个过程主要是为了找到并行暂停阶段是否存在指向当前收集集合(CSet)的指针。
这里还有一个情况需要引起大家的重视,就是查看工作线程是否在处理一个单一的根节点时耗时过长,导致感觉类似挂起的现象。这个现象可以通过查看工作线程对应的“termination”日志看出来。如果存在这个现象,你需要去查看是否存在比较大的系统字典(JVM System Dictionary),如果这个系统字典被当成了一个单一根节点进行处理,那么当存在大量的加载类时就会出现较长时间的耗时。
Rememebered Sets and Processed Buffers
Rset帮助维护和跟踪指向G1区间的引用,而这些区间本身拥有这些RSet。还记得我们在第4章介绍过的并行Refinement线程吗?这些线程的任务是扫描更新日志缓存,并且更新区间的RSet。为了更加有效地支援这些Refinement线程的工作,在并行回收阶段,所有未被处理的缓存(已经有日志写在里面了)都会被工作线程拿来处理,这些缓存也被称为日志里面的处理缓存。
为了限制花费在更新RSet上的时间,G1通过选项-XX:MaxGCPauseMills设置了目标暂停时间,采用相对于整个停顿目标时间百分比的方式,限制了更新RSet花费的总时长,让评估暂停阶段把最大量的时候花费在拷贝存活对象上。这个目标时间默认为整个停顿时间的10%,例如整个停顿时间是10s,那么花费在更新RSet上的时间最大为ls。G1 GC的设计目标是让更多的停顿时间花费在拷贝存活对象上面,因此暂停时间的10%被用于更新RSet也是比较合理的,百分比大了,花在干具体业务(各阶段拷贝存活对象)上的时间也就少了。
如果你发现这个值不太准确或者不符合你的实际需求,这里可以通过更新选项-XX:G1RSetUpdatingPauseTimePercent来改变这个更新RSet的目标时间值。切记,如果你改变了花费在更新RSet上的时间,那你必须有把握工作线程可以在回收暂停阶段完成它们的工作,如果不能,那这部分工作会被放到并行Refinement线程里面去执行,这会导致并行工作量增加、并行回收次数增多。最坏的情况是如果并行Refinement线程也不能完成任务,那么Java应用程序就会被暂停,原本负责执行Java应用程序的资源就会直接接手任务,这个画面“太美”不敢看!大家要尽量避免这种情况发生。
注意,-XX:G1ConcRefinementThreads选项的值默认和-XX:ParallelGCThreads的值一样,这意味着对于-XX:ParallelGCThreads选项的修改会同样改变-XX:G1ConcRefinementThreads选项的值。
在当前CSet里面回收之前,CSet内部的每个区间的Rset都需要被扫描,主要目的是找到CSet区间内部的引用关系。一个有较多存活对象的区间容易导致Rset的粒度变细,即每个区间对应的表格会从粗粒度变为细粒度,也可以理解为里面对象增多后扫描一个Rset需要更长的扫描时间,这样你就会看到更多的时间被花费在了扫描RSet上面。也可以理解为扫描时间取决于RSet数据结构的粗细粒度。
Summarizing Remembered Sets
XX:+G1SummarizeRSetStats选项用于统计RSet的密度数量(细粒度或者粗粒度),这个密度帮助决定是否并行Refinement线程有能力去应对更新缓存的工作,并且收集更多关于Nmethods的信息。这个选项每隔n次GC暂停收集一次RSet的统计信息,这个n次由选项-XX:G1SummarizeRSetStatsPeriod=n决定,也是需要通过选项进行设置的。
注意,-XX:+G1SummarizeRSetStats选项是一个诊断选项,因此必须启用-XX:+UnlockDiagnosticVMOptions选项才可以启用-XX:+G1SummarizeRSetStats选项。
PDF书籍下载地址:
https://github.com/jiankunking/books-recommendation/tree/master/Java
- 本文链接: https://jiankunking.com/java-jvm-gc-g1-notes.html
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-ND 许可协议。转载请注明出处!
欢迎关注我的其它发布渠道