ZGC 都出来了,你还不懂 G1?
概念
G1(Garbage-First Collector)是一种垃圾回收算法,最早在JDK 6 Update 14中作为实验性功能加入,并在JDK 7 Update 4正式JDK,之后在JDK 9 中成为默认垃圾回收算法,在JDK 10中优化了Full GC性能。
G1是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。它是专门针对以下应用场景设计的:
像CMS收集器一样,能与应用程序线程并发执行
整理空闲空间更快
需要GC停顿时间更好预测
不希望牺牲大量的吞吐性能
不需要更大的Java Heap
G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色:
G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片
G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间
内存结构
G1同之前的垃圾收集器一样实现了逻辑上的分代模型,不同的是G1在物理上是不分代的。G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。如下图所示:
G1把堆内存分为大小相等的内存分段,默认情况下会把内存分为2048个内存分段,可以用-XX:G1HeapRegionSize调整内存分段的个数。比如32G堆内存,2048个内存分段每段的大小为16M。这相当于把内存化整为零。内存分段是物理概念,代表实际的物理内存空间。
每个内存分段都可以被标记为Eden区,Survivor区,Old区,或者Humongous区。这样属于不同代,不同区的内存分段就可以不必是连续内存空间了。
在每个分区内部又被分成了若干个大小为512 Byte 卡片(Card),标识堆内存最小可用粒度。所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见 RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。
另外与之前垃圾回收器不同的是增加了一个Humongous区,表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象。H-obj有如下几个特征:
H-obj直接分配到了old gen,防止了反复拷贝移动
H-obj在全局并发标记清理阶段和Full GC阶段回收不再存活的对象
在分配H-obj之前先检查是否超过Java堆占用率阈值, 如果超过的话就启动并发标记,为的是提早回收从而防止 Evacuation Failures 和 Full GC
此类对象直接被分配到老年代中的“巨型区域”,这些巨型区域是一个连续的区域集。StartsHumongous 标记该连续集的开始,ContinuesHumongous 标记它的延续。由于每个 StartsHumongous 和 ContinuesHumongous 区域集只包含一个巨型对象,所以没有使用巨型对象的终点与上个区域的终点之间的空间(即巨型对象所跨的空间)。如果对象只是略大于Region大小的倍数,则此类未使用的空间可能会导致堆碎片化。
为了减少连续H-objs分配对GC的影响,需要把大对象变为普通的对象,建议增大Region size。这样一来,之前的巨型对象就不再是巨型对象了,而是采用常规的分配路径。一个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。
RSet
原理
在串行和并行收集器中,GC 通过整堆扫描,来确定对象是否处于可达路径中。然而 G1 为了避免 STW 式的整堆扫描,在每个Region记录了一个RSet(Remembered Set)),内部类似一个反向指针,记录引用分区内对象的Card索引。当要回收该分区时,通过扫描分区的 RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。我们知道每个Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是 xx Region的 xx Card。
引用
通常的,有两种记录引用关系的方式,PointOut和PointIn。如果obj1.field1=obj2,如果是PointOut方式,则在obj1所在region的RSet记录obj2的位置;如果是PointIn方式,则在obj2所在region记录obj1的位置。G1采用的是PointIn方式。
G1中一共有五种分区间的引用关系:
分区内引用
新生代分区Y1引用新生代分区Y2
新生代分区Y1引用老年代分区O1
老年代分区O1引用新生代分区Y1
老年代分区O1引用老年代分区O2
YGC时,GC Root主要是两类:栈空间和老年代分区到新生代分区的引用关系。
Mixed GC时,由于仅回收部分老年代分区,老年代分区之间的引用关系也将被使用。
因为年轻代回收是针对全部年轻代的对象的,反正所有年轻代内部的对象引用关系都会被扫描,所以RS不需要保存来自年轻代内部的引用。对于属于老年代分段的RSet来说,也只会保存来自老年代的引用,这是因为老年代的回收之前会先进行年轻代的回收,年轻代回收后Eden区变空了,G1会在老年代回收过程中扫描Survivor区到老年代的引用。因此,我们仅需要记录两种引用关系:老年代分区引用新生代分区,老年代分区之间的引用。
Per Region Table
RSet 在内部使用 Per Region Table(PRT)记录分区的引用情况。由于 RSet 的记录要占用分区的空间,如果一个分区非常"受欢迎",那么 RSet 占用的空间会上升,从而降低分区的可用空间。G1 应对这个问题采用了改变 RSet 的密度的方式,在 PRT 中将会以三种模式记录引用:
稀少:直接记录引用对象的卡片索引
细粒度:记录引用对象的分区索引
粗粒度:只记录引用情况,每个分区对应一个比特位
由上可知,粗粒度的 PRT 只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。
更新维护
为了维护这些RSet,如果每次给引用类型的字段赋值都要更新RSet,这带来的额外开销实在太大,G1中采用写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)实现了RSet的更新。
在执行引用赋值语句前后JVM会添加pre-write barrier和post-write barrier,post-write barrier主要做了以下事情:
找到该字段所在的位置(Card),并设置为Dirty_Card(包含对象引用信息)
维护一个全局共享的Dirty Card Queue,把该Card插入队列
赋值动作到此结束,接下来的RSet更新操作交由多个ConcurrentG1RefineThread并发完成,每当全局队列集合超过一定阈值后,ConcurrentG1RefineThread会取出若干个队列,遍历每个队列中记录的Card,并进行处理,大概实现逻辑如下:
根据Card的地址,计算出Card所在的Region
如果Region不存在或者Region是Young区或者该Region在回收集合中,则不进行处理
最终使用闭合函数处理该Card中的对象,通过add_reference方法添加到RSet中
Refinement threads线程数量可以通过-XX:G1ConcRefinementThreads(默认等于 -XX:ParellelGCThreads)参数设置。如果记录增长很快或者来不及处理,那么通过阈值 -X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone,G1 会用分层的方式调度,使更多的线程处理全局队列。如果并发优化线程也不能跟上缓冲区数量,则 Mutator 线程(Java 应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。
CSet
CSet(Collection Set)代表每次 GC 暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中来实现压缩,从而减少堆内存碎片。在混合垃圾回收期间会通过并发标记阶段,在老年代候选回收分区中筛选出回收收益最高的分区添加到 CSet 中。
候选老年代分区的 CSet 准入条件,可以通过活跃度阈值 -XX:G1MixedGCLiveThresholdPercent(默认65%)进行设置,从而拦截那些回收开销巨大的对象;每次混合垃圾回收包含候选老年代分区,可根据 CSet 对堆的总大小占比 -XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。
STAB
全称是Snapshot-At-The-Beginning,由字面理解,是GC开始时活着的对象的一个快照。它是通过Root Tracing得到的,作用是维持并发GC的正确性。 那么它是怎么维持并发GC的正确性的呢?根据三色标记算法,我们知道对象存在三种状态:
白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。
灰:对象被标记了,但是它的field还没有被标记或标记完。
黑:对象被标记了,且它的所有field也被标记完了。
由于并发阶段的存在,Mutator和Garbage Collector线程同时对对象进行修改,就会出现白对象漏标的情况,这种情况发生的前提是:
Mutator赋予一个黑对象该白对象的引用。
Mutator删除了所有从灰对象到该白对象的直接或者间接引用。
对于第一个条件,在并发标记阶段,如果该白对象是new出来的,并没有被灰对象持有,那么它会不会被漏标呢?Region中有两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。在TAMS以上的对象是新分配的,这是一种隐式的标记。对于在GC时已经存在的白对象,如果它是活着的,它必然会被另一个对象引用,即条件二中的灰对象。如果灰对象到白对象的直接引用或者间接引用被替换了或者删除了,白对象就会被漏标,从而导致被回收掉,这是非常严重的错误。SATB关注的是第二个条件的打破,即引用关系的删除。SATB利用pre write barrier将所有即将被删除的引用关系的旧引用记录下来,在重新标记(Remark)阶段以这些旧引用为根STW的重新扫描一遍RSet即可避免漏标问题。
PLAB
由于堆内存是应用程序共享的,应用程序的多个线程在分配内存的时候需要加锁以进行同步。为了避免加锁,提高性能每一个应用程序的线程会被分配一个TLAB。TLAB中的内存来自于G1年轻代中的内存分段。当对象不是Humongous对象,TLAB也能装的下的时候,对象会被优先分配于创建此对象的线程的TLAB中。这样分配会很快,因为TLAB隶属于线程,所以不需要加锁。
与TLAB类似,G1会在年轻代回收过程中把Eden区中的对象复制(“提升”)到Survivor区中,Survivor区中的对象复制到Old区中。G1的回收过程是多线程执行的,为了避免多个线程往同一个内存分段进行复制,那么复制的过程也需要加锁。为了避免加锁,G1的每个线程都关联了一个PLAB,这样就不需要进行加锁了。
预测机制
Pause Prediction Model 即停顿预测模型。G1 GC是一个响应时间优先的GC算法,它与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis指定一个G1收集过程目标停顿时间,默认值200ms,不过它不是硬性条件,只是期望值。那么G1怎么满足用户的期望呢?G1不是实时收集器,它很有可能达到设定的暂停时间目标,但并非绝对确定。G1根据先前收集的数据,估算在用户指定的目标时间内可以收集多少个区域。因此,收集器具有收集区域成本的合理准确的模型,并且收集器使用此模型来确定要收集哪些和多少个区域,同时保持在暂停时间目标之内。
GC过程
Young GC
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当所有的Eden区都满了,G1会启动一次年轻代垃圾回收过程。年轻代只会回收Eden区和Survivor区。首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
扫描根:根引用连同RSet记录的外部引用作为扫描存活对象的入口。
更新RSet:处理Dirty Card Queue中的Card,更新RSet。此阶段完成后,RS可以准确的反映老年代对所在的内存分段中对象的引用。
处理RSet:识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
复制对象:对象树被遍历,Eden区Region中存活的对象会被复制到Survivor区中空的Region,Survivor区Region中存活的对象如果年龄未达阈值(G1默认是15),年龄会加1,达到阀值会被会被复制到Old区中空的Region。
清除内存:原有的年轻代分区将被整体回收掉后放入空闲列表中,等待下次被使用。
Mixed GC
当整个堆内存(包括老年代和新生代)被占满一定大小的时候(默认是45%,可以通过-XX:InitiatingHeapOccupancyPercent进行设置),Mixed GC(混合回收)就会被启动。具体检测堆内存使用情况的时机是年轻代回收之后或者Houmongous对象分配之后。
Mixed GC主要可以分为两个阶段
全局并发标记(global concurrent marking)
包含以下几个阶段:
初始标记(initial mark,STW):在此阶段对GC Root对象进行标记,初始标记阶段共用了Young GC的暂停,这是因为他们可以复用Root Scan操作。
根分区扫描(Root Region Scanning):在初始标记暂停结束后,年轻代收集也完成的对象复制到 Survivor 的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到 Survivor 分区的对象,都需要被扫描并标记成根。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次 GC 会产生新的存活对象集合。
并发标记(Concurrent Marking):在整个堆中查找根可达(存活的)对象,收集各个Region的存活对象信息,过程中还会扫描上文中提到的SATB write barrier所记录下的引用。
重新标记(Remark,STW):标记那些在并发标记阶段发生变化的对象,将被回收。
清理垃圾(Cleanup,部分STW):在这个最后阶段,G1 GC 执行统计和 RSet 净化的 STW 操作。在统计期间,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合。识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次GC周期。
拷贝存活对象(Evacuation)
将Region里的活对象拷贝到空Region里去(并行拷贝),然后回收原本的Region的空间。
为了满足停顿预测模型即暂停时间,G1 可能不能一口气将所有的Region都收集掉,因此 G1 可能会产生连续多次的混合收集与应用线程交替执行,每次 STW 的混合收集与年轻代收集过程相类似。由于老年代中的内存分段默认分8次(可以通过-XX:G1MixedGCCountTarget设置)回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。G1 GC 回收了足够的旧区域后(经过多次混合垃圾回收),G1 将恢复执行年轻代垃圾回收,直到下一个标记周期完成。
Full GC
转移失败(Evacuation Failure)是指当 G1 无法在堆空间中申请新的分区时,G1 便会触发担保机制,执行一次 STW 式的、单线程的 Full GC。Full GC 会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数 -XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。
1. 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
2. 从老年代分区转移存活对象时,无法找到可用的空闲分区
3. 分配巨型对象时在老年代无法找到足够的连续分区
G1 的Full GC算法就是单线程执行的 Serial Old GC,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免Full GC。
G1调优参数
G1调优建议
微调 G1 GC 时,请记住以下建议:
年轻代大小:避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。
暂停时间目标:每当对垃圾回收进行评估或调优时,都会涉及到延迟与吞吐量的权衡。G1 GC 是增量垃圾回收器,暂停统一,同时应用程序线程的开销也更多。G1 GC 的吞吐量目标是 90% 的应用程序时间和 10%的垃圾回收时间。如果将其与 Java HotSpot VM 的吞吐量回收器相比较,目标则是 99% 的应用程序时间和 1% 的垃圾回收时间。因此,当您评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示您愿意承受更多的垃圾回收开销,而这会直接影响到吞吐量。当您评估 G1 GC 的延迟时,请设置所需的(软)实时目标,G1 GC 会尽量满足。副作用是,吞吐量可能会受到影响。
掌握混合垃圾回收:当您调优混合垃圾回收时,请尝试以下选项
-XX:InitiatingHeapOccupancyPercent:堆内存比例,达到后触发 Mixed GC
-XX:G1MixedGCLiveThresholdPercent 和 -XX:G1HeapWastePercent:Region的存活比例和允许堆的浪费比例
-XX:G1MixedGCCountTarget 和 -XX:G1OldCSetRegionThresholdPercent:执行混合垃圾回收的目标次数和设置混合垃圾回收期间要回收的最大旧区域数
G1的推荐用例
G1的首要重点是为运行需要大堆且GC延迟有限的应用程序的用户提供解决方案。这意味着堆大小约为6GB或更大,并且稳定且可预测的暂停时间低于0.5秒。
如果当前具有CMS或ParallelOld垃圾收集器的应用程序具有以下一个或多个特征,则将其切换到G1很有用。
超过50%的Java堆被实时数据占用。
分代中的对象分配率或提升率差异很大
不必要的长时间垃圾收集或压缩暂停(长于0.5到1秒)
参考
http://www.linkedkeeper.com/1511.html
https://www.jianshu.com/p/870abddaba41
https://blog.csdn.net/a860mhz/category_9293742.html
https://tech.meituan.com/2016/09/23/g1.html
https://www.cnblogs.com/yufengzhang/p/10571081.html
https://www.oracle.com/technetwork/cn/articles/java/g1gc-1984535-zhs.html
https://www.cnblogs.com/yufengzhang/p/10571081.html
版权声明: 本文为 InfoQ 作者【岁月安然】的原创文章。
原文链接:【http://xie.infoq.cn/article/c4330f868a28c32549f0c0b7b】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论