jvm

Java基础: GC(三) G1垃圾收集器优化

Posted by ZhouJ000 on August 5, 2019

Java基础: GC(一) 介绍
Java基础: GC(二) G1垃圾收集器
Java基础: GC(三) G1垃圾收集器优化
Java基础: GC(四) SA调试工具

Java性能优化03-JVM调优

G1优化

g1-step

年轻代GC各阶段

一个G1年轻代收集同时具有串行和并行阶段。在给定的stop-the-world暂停时,只有某些任务完成后,其他认为才可以进行,从这个角度说这个暂停就是串行的。并行阶段会使用多个GC工作线程,这些线程有自己的工作队列,当工作队列中的任务完成后,它们会从其他线程的工作队列中”偷取”一些任务来做

年轻代收集的串行阶段可以是多线程的,使用-XX:ParallelGCThreads选项的值来确定垃圾收集器工作线程的数量

所有并行活动开始

GC Worker Start和GC Worker End为每个并行阶段打上开始和结束时间戳的标签。GC Worker Start的Min时间戳为第一个工作线程开始的时间节点,同样GC Worker End的Max时间戳为最后的工作线程结束它所有任务的时间节点。在gc日志中还包括以毫秒为单位的Avg值和Diff值,其中Diff值为离0有多远(0是理想值),Max、Min、Avg值是否存在一些重大偏差,表明着工作线程无法在同一时间开始或结束它们的并行任务,可能是某些队列处理存在着问题,需要在并行阶段进一步观察并行工作的开展,并做进一步分析

外部根分区

外部根分区扫描(Ext Root Scanning)是第一个并行任务。在此阶段对外部的(堆外的)根进行扫描,比如JVM的系统目录、VM数据结构、JNI线程句柄、硬件寄存器、全局变量和线程堆栈根,通过扫描来发现有没有进入到当前暂停收集集合(CSet)的点

Diff值的差异反映出组成并行阶段所有定时活动的情况。一个高的差异值通常意味着某个特定活动涉及到的并行线程的工作是不均衡的。这也是我们分析的入手点,同时更深入的分析将帮助我们找到潜在的原因,这有可能会要求重构Java应用

我们会主要寻找Diff>0的,以及Max、Min、Avg值的主要差异。此外需要注意工作线程在处理单独的根时能否跟上进度

已记忆集合和已处理缓冲区

G1 GC使用已记忆集合(RSet)来帮助维护和跟踪那些”拥有”RSet的G1 GC分区的对内引用。并发优化线程负责对有脏卡片的分区进行扫描更新日志缓冲区和更新RSet。作为并发优化线程所开展工作的补充,所有被优化线程记录但是还没被处理的剩余缓冲区,都会在收集暂停的并行阶段被工作线程所处理。这些缓冲区也就是在日志片段中所说的已处理缓冲区(Processed Buffers)。为了限制花在更新RSet上的时间,G1设置了一个目标时间作为暂停时间目标的百分比(-XX:MaxGCPauseMillis),这个目标缺省为暂停时间目标的10%,任何转移暂停将花费大多数时间来复制存活对象,因此将暂停时间目标的10%花费在更新RSet上被认为是一个合理的时间数量。如果在查看日志后认为花费暂停时间目标的10%在更新RSet上不可取,可以使用-XX:G1RSetUpdatingPauseTimePercent来更改百分比为期望值。不过要记住,如果更新日志缓冲区的数目不改变,在收集暂停期间减少RSet的更新时间会导致在这个暂停中被处理的缓冲区减少,这将会把日志缓冲区更新工作推到并发优化线程上,于是导致增加并发工作以及与Java应用线程共享资源。而且最糟糕的情况下,如果并发优化线程不能跟上日志缓冲区的更新速度,Java应用线程必须介入并协助处理,应当避免这个场景

-XX:G1ConcRefinementThreads默认情况下与-XX:ParallelGCThreads相同的值,意味着它的任意变化同样会改变-XX:G1ConcRefinementThreads的值

在收集当前CSet中的分区之前,考虑到CSet分区的对内应用,因此必须扫描CSet中分区的RSet,一个分区里的普通对象或一个普通分区本身会导致它的RSet粗化,即从稀少PRT到细粒度PRT甚至粗粒度位图,从而扫描这样的RSet将需要更多的时间。另一个和RSet相关的并行任务是代码根扫描,在此期间,会对代码根集合进行扫描以查找当前CSet的对内引用

在HotSpot早期版本中,整个代码缓存被视为一个单独的根,并由一个独立的工作线程处理。一个庞大而装满或几乎装满的代码缓存将导致工作线程延误并增加总的暂停时间。随着代码根扫描作为一个单独的并行活动被引入,扫描nmethod的工作被减少到仅仅针对来自编译后代码的引用做RSet扫描。因此对于CSet里的某个分区,只有在这个分区里的RSet有强代码根时,才会做相关的nmethod扫描

art nmethod描述了Java方法的动态编译代码。不要将nmethod和原生方法相混淆,它指的是一个JNI方法。除了生成的代码,nmethod还包括其他的补充信息,比如常量池

为了减少nmethod扫描时间,只有在CSet里的分区的RSet会被扫描,相关引用是被编译器所引入的,而不是那些被Java应用线程所引入的”平常的”引用

选项-XX:+G1SummarizeRSetStats=n可以用来为RSet粗化(细粒度PRT或粗粒度位图)总数提供一个窗口,来帮助确定并发优化线程是否能够处理更新缓冲区以及是否能在nmethod上收集到更多的信息,此选项经历每n个GC暂停就总结一次RSet统计数据

注意:-XX:+G1SummarizeRSetStats是一个诊断选项,因此必须添加-XX:UnlockDiagnosticVMOptions到命令行来激活

对潜在提升性能的4个领域(RSet粗化、更新RSet、扫描RSet、扫描nmethod引用的RSet)的可视化,将有效地帮助你理解你的应用程序

转移和回收

既然G1知道当前收集暂停的CSet,以及CSet里一套完整的引用集,它可以开始执行暂停的最昂贵部分:CSet分区存活对象的转移以及最新释放空间的回收。理论上来说,对象拷贝的时间是暂停时间的最主要的组成部分。需要被转移的存活对象将拷贝到目标分区中的GC分配缓冲区(GCLABs),工作线程开始竞争来安装一个转发指针到最近分配的旧对象镜像拷贝。在工作窃取的帮助下,一个获胜的线程将负责复制和扫描对象,工作窃取还提供了工作线程之间的负载均衡

G1垃圾收集器使用拷贝时间作为加权平均数来预测花费在拷贝一个单独分区的时间,如果预测逻辑未能跟上所需的暂停时间目标,用户可以调整年轻代的尺寸大小

终止

在完成刚才的扫描任务后,如果工作线程的工作队列都已清空,则工作线程会要求停止。一个线程要求终止会检查其他线程的工作队列并尝试工作窃取。如果没有工作可以做了,则线程就终止。终止(Termination)也标记了每个工作线程在此终止协议所花费的时间。一个卷入单独根扫描的GC工作线程会来不及完成队列中的所有任务,并最终因此晚于终止

如果某个(或全部)工作线程在什么地方被纠缠住,就会体现在一个很长的终止时间上,同时可能会提示一个工作窃取或负载均衡的问题

GC外部的并行活动

终止标志着转移/收集暂停期间的工作线程并行活动的结束。在日志片段的下一行,即标记为GC Worker Other,它是花费在并行阶段的时间,但不是在任何到目前为止所扫描的”通常的”并行活动。虽然归于”GC时间(GC Work Total)”,但它可以很容易地被发生在GC之外的某些事所占用,这种占用正好发生在GC暂停的并行阶段。在这段时间里GC线程都会被停掉。由于某些不合适的编译选项,结果就因为JVM活动导致编译工作发生了增长,我们就会发现GC Worker Other的时间变得很高,如果在此观察较长时间,可以发现此类非GC活动

所有串行活动启动

并行阶段完成后,串行阶段就开始了,即那些标记有Code Root、Code Root Purge和Clear CT的行。在这些时间里,主GC线程根据转移对象的新位置更新代码根,同时清理代码根集合表。Clear CT阶段(在并行工作线程的帮助下并行执行)在扫描RSet时将清除卡片表标志,一旦一张卡片表被扫描,G1 GC会在全局卡片表中标记一个相对应的记录以免重复扫描。在暂停的Clear CT阶段,这个标志被清除

主GC线程是虚拟机线程,它在一个安全点(safepoint)执行GC虚拟机操作

其他串行活动

这一系列阶段的最后部分就是标记Other的部分,Other的几个重要组成部分包括:为收集选择CSet、引用处理队列和排队、卡片重新脏化、回收空闲巨型分区、在收集完成后释放CSet。会通过PrintGCDetails输出这些组成部分

对于一个年轻代收集,所有年轻代分区都会被收集,因此这里就不存在选择,因为所有年轻代分区都会自动成为年轻代CSet的一部分。选择发生在混合收集暂停过程中,并成为如何调优混合集合的一个重要因素

引用处理以及排队针对的是软引用、虚引用、final引用和JNI引用

引用排队的行为可能需要更新RSet,因此这个更新就需要记录日志并且与它们相关联的卡片需要被标记为脏的,花费在重新脏化卡片的时间被显示为Redirty time

巨型对象回收(Humongous Reclaim)是在JDK 8u40中新加入的,如果通过查看所有根集或者年轻代分区的引用并且确认对RSet中的巨型对象没有任何引用,那么就会发现这一个巨型对象是不可达的,该对象会在转移暂停过程中被回收

Other的剩余时间都花在修复JNI句柄以及类似的工作上,Other里这种共有时间会非常小,同时任何独享时间都应该有一个合理的解释。举例来说,如果每一次CSet暂停都非常大,那么会看到更多的时间花费在Free CSet上。类似的,Ref Proc和Ref Enq可能显示了更多的时间,这将取决于有多少引用被使用在了你的应用程序中,类似的原因也适用于Humongous Reclaim时间,这取决于你有多少短命的巨型对象

调优

年轻代调优

G1 GC具有较大的调优潜力,对于G1 GC的启发式算法可以使用相应的初始值和缺省值,同时为了能够优化G1 GC,我们需要理解这些默认值以及它们对启发式算法产生的效果

比如-XX:MaxGCPauseMillis(暂停时间目标,默认为200ms),-XX:G1NewSizePercent(年轻代初始大小,显示为堆总尺寸的百分比,默认为5%),-XX:G1MaxNewSizePercent(年轻代增长上限,显示为堆总尺寸的百分比,默认为60%),这些选项有助于基于初始值和上限值增大或缩小年轻代的大小,调整目标暂停时间以及之前拷贝时间的加权平均值。如果对工作负荷有良好的了解,同时可以预见到避免自适应调整尺寸带来的收益(例如预估的时间与实际时间有较大差异),可以调整这些默认值。而副作用就是因为更多的可预见的限制,你必须放弃自适应调整尺寸,而且新的限制仅适用于一个你正在调优的应用,它并不会延续到其他具有类似暂停时间需求的其他或不同应用程序,因为大多数程序根据其分配比率、晋升比率、稳定或短暂存活的数据集、对象大小以及寿命程度,而表现出不同的行为

并发标记阶段调优

对G1而言,调优选项-XX:InitiatingHeapOccupancyPercent=n(n默认为Java堆大小总数的45%,同时考虑到老年代占用的情况,包括老年代分区和巨型分区)有助于决定何时启动并发标记周期

并发标记周期开始于一个初始标记暂停,它和一个年轻代收集暂停同时发生(又叫做”捆绑”)。这个暂停意味着收集周期的开始,后面跟着会开始其他并行或并发任务,比如根分区扫描、并发标记、活跃度统计、最终标记及清理

如果应用程序的存活对象图非常大,那么并发标记任务可能需要花费很长的时间,甚至被年轻代收集暂停所打断。并发标记周期必须要在一个混合收集暂停启动前完成,并且紧跟着一个年轻代收集来计算下一个暂停触发混合收集的阈值

一个初始标记暂停(被捆绑在一个年轻代收集上),而在并发阶段正在进行的时候,可能有不止一个年轻代收集。而最终标记(也称为重新标记)完成标记,并且会有一个小的清理暂停来协助清理活动。在清理暂停后会有一个年轻代转移暂停,它将帮助准备混合收集周期。在年轻代收集暂停之后会使混合收集转移暂停,它们将从目标CSet分区中成功收集所有垃圾

如果某个并发标记任务过重导致整个周期需要很长时间才能完成,那么混合暂停收集将会被延迟,这可能最终导致转移失败。转移失败将在GC日志中显示为一个to-space exhausted的消息,并且归结于失败的总时间将会显示在该暂停的Other部分(Evacuation Failure)。当在日志中看到这样的信息可以尝试一下措施避免问题发生:
1、必须将标识阈值设置为适合应用程序静态暂停存活数据需要的值,如果设置的标记阈值过高,将面临转移失败的风险;如果标记阈值过低,可能会过早地引发并发周期,并很大可能 在混合收集期间回收不到空间。我们宁愿在标记周期的早期犯错误,因为转移失败的后果远大于在标记周期运行过于频繁
2、如果认为标记阈值是正确的,但是并发周期仍然花费太长时间,混合收集回收分区时有以”losing the race”告终,并触发转移失败,你可以尝试增加并发线程总数-XX:ConcGCThreads其默认为-XX:ParallelGCThreads的四分之一,可以直接增加并发线程数,或者增加并行GC线程数,这样可以有效提高并发线程数(需要注意的是,增加并发线程数将会占用Java应用程序的处理时间,因为并发GC线程和应用线程在同时工作)

混合垃圾收集阶段调优

之前优化了年轻代收集和并发标记周期,现在可以关注于混合收集周期执行的老年代收集。混合收集CSet由所有年轻分区加上一小部分从老年代中选出的分区所组成,优化混合收集可以被分为多个手段:改变混合收集CSet中老年代分区的数目,增加足够多连续的混合收集去分摊由其中任何一个收集所有被选中的老年代分区所花费的时间成本

选项-XX:PrintAdaptiveSizePolicy会存储启发式算法决策的细节,会告诉我们转移暂停类型、CSet选择、添加年轻代分区和老年代分区到CSet中这些活动的预测时间

可回收百分比阈值-XX:G1HeapWastePercent是在应用程序中可以容忍的垃圾总量的尺寸,它表现为占应用程序总体Java堆空间的百分比,默认为5%。如果混合收集呈现指数上涨,提升这个阈值会有所帮助,但是这将导致更多碎片化的和被占用的分区,这意味着老一代将会保留更多存活数据(包括短暂存活),这也必须根据调整的标记阈值来做相应计算

在混合收集周期中,每个混合收集暂停的CSet中包含了老年代分区数量的最小阈值,它通过-XX:G1MixedGCCountTarget来设定,默认为8,其每次混合收集暂停的最小老年代CSet尺寸=混合收集周期确认的候选老年代分区总数/G1MixedGCCountTarget,这个公式确定每次混合收集中每个CSet的老年代分区的最小数目,进而推动会收集到的所有候选老年代分区的连续混合收集。在一个已完成的并发标记周期之后执行的连续的混合收集集合构成一个混合收集周期。正如有一个老年代分区最小阈值被添加到CSet中,同样也有一个最大阈值-XX:G1OldCSetRegionThresholdPercent指定被添加到CSet的老年代分区的最大阈值,默认为Java堆总大小的10%。当我们知道如何在每次混合收集的每个CSet中指定最小和最大分区数量,就可以通过阈值来满足暂停时间目标,并同时维护老年代中所需要的短期存活数据的数量

选项-XX:G1MixedGCLiveThresholdPercent默认位85%,它是一个CSet中所能容纳一个分区存活数据的最大百分比,每个分区的存活数据百分比是在并发标记阶段中计算的,我们认为一个未被包含进CSet候选分区的老年代分区的转移是昂贵的,也就是说它的存活数据百分比是在活跃度阈值之上的,这个选项直接控制每个分区的碎片化程度,所以要谨慎

避免转移失败

在并发标记调优中讲到了转移失败,还有一些重要的调优参数:
1、堆尺寸大小,确保Java堆里可以容纳所有的静态短暂存活数据以及短生命周期和中等生命周期的应用程序数据,为了GC能有效工作,除了容纳存活数据,需要保留一部分额外的Java堆空间作为余量,可用余量越多,就越可能提高吞吐量,降低延迟
2、避免过渡调整JVM命令行选项,让默认值为你工作。通过调整初始与最大堆设置获取一个基线和一个期望的暂停时间目标
3、如果应用中有长生命的巨型对象,确保标记阈值设置的足够低以容纳它们,可以使用-XX:G1HeapRegionSize的值来确保大于或等于50%分区大小的对象被视为巨型对象
4、有时候转移失败是由于survivor分区没有足够的空间容纳新晋升的对象,当观察到这种情况时,通过增加-XX:G1ReservePercent保留比例为保留空间设置一个失效上限,以应对任何晋升模式下的异常情况,其默认值是Java堆总数的10%,同时G1限制其最大占用Java堆的50%

引用处理

垃圾收集处理Java引用对象(虚引用、软引用、弱引用)的方式和其他Java对象不同,和非引用对象相比,引用对象需要做更多的工作去收集

-XX:+PringGCDetails会将花在引用队列摆对的时间和花在处理它们的时间分别记录进年轻代收集和混合收集日志的Other部分,使用-XX:+PrintReferenceGC可以在每次收集中为每个引用对象类型记录详细细节,识别出收集器在处理哪个引用对象类型上花费更多时间。在Ref Proc时间超过GC暂停时间总数的10%的时候,就需要优化垃圾收集器的引用处理。对于G1重新标记活动,一般是处理引用占据的时间很多,因为老年代收集周期中发现的大部分引用对象,就是在并发周期的重新标记阶段被处理的

使用-XX:+ParallelRefProcEnabled激活多线程方式的引用处理,因为HotSpot默认为单线程的引用处理,这个默认值为了减少内存占用并让CPU周期对其他应用程序可用,启用此选项将在引用处理期间消耗更多CPU,但是完成时间将降低

如果处理时间依旧大于年轻代或混合收集暂停时间的10%,那就需要打印日志确定是哪个引用对象类型或引用对象类型集合花费了长时间处理,并根据此信息来重构应用程序,以减少对识别出来的引用对象类型的使用,或减少该引用对象类型的回收时间,以减少对识别出来的引用对象类型的使用或回收时间

引用对象类型需要注意并小心使用的是软引用,如果输出大量软引用正在被处理,可能会看到频繁的老年代收集周期,它由并发周期和一个混合收集的队列组成。如果看到此现象,并且GC事件频繁,或堆占用情况始终保持在最大堆尺寸附近,使用命令-XX:SoftRefLRUPolicyMSPerMb做更激进的调优来回收软引用,其默认为1000,单位毫秒,意味着如果最后一次大于1000毫秒的访问超过时间乘以Java堆中可用空间,那么软引用将被清除并可以被回收。这个值使者较低将影响触发软引用更激进的清除和回收,从而导致GC活动之后更低的堆占用比例,也就是更少的存活数据。相反将减少软引用激进的回收和清除,导致更多的存活数据和更高的堆占用比例。优化该选项主要理由是减少堆中存活数据的数量来减少老年代收集活动的频率,在应用中也不建议使用软引用手段来实现内存敏感对象的存储,因为这样会增加存活数据数量,导致GC额外的开销

扩展:
垃圾收集器GC中parallel scavenge收集器为什么不能CMS配合使用?
可能是最全面的G1学习笔记
JVM调优03-所有垃圾收集器总结