高并发场景下的JVM调优实践< titlesplit >一、背景2021年2月,我们收到反馈,视频APP某核心界面在高峰期响应缓慢,影响用户体验。通过监控发现界面响应慢主要是P99耗时高导致的。怀疑和这个服务的GC有关。该服务一个典型实例的GC性能如下:可以看出,在观察期内:YoungGC平均每10分钟GC次数为66次,峰值为470次;平均每10分钟FullGC次数为0.25次,峰值为5次;可以看出FullGC非常频繁,YoungGC在特定时间段也很频繁,优化空间很大。由于GC暂停的优化是降低接口P99延迟的有效手段,因此决定对核心服务进行JVM调优。2.优化目标接口P99延迟,降低30%。减少YoungGC和FullGC的次数,停顿时间,单次停顿时间。由于GC的行为是和并发相关的,比如并发比较高的时候,不管怎么调,YoungGC总是会很频繁,总会有不该提升的对象被提升触发FullGC,所以根据负载设定优化目标:目标一:高负载(单机1000QPS以上)YoungGC次数减少20%-30%,YoungGC累计消耗时间不恶化;FullGC次数减少50%以上,单次和累计FullGC耗时减少50%以上,服务发布不触发FullGC。目标2:针对中等负载(单机500-600次)减少20%-30%的YoungGC次数,减少20%的累计YoungGC花费时间;每天FullGC次数不超过4次,服务发布不会触发FullGC。目标三:针对低负载(单机200QPS以下)减少20%-30%的YoungGC次数,减少20%累计YoungGC花费时间;每天FullGC次数不超过1次,服务发布不会触发FullGC。3、当前问题当前服务的JVM配置参数如下:-Xms4096M-Xmx4096M-Xmn1024M-XX:PermSize=512M-XX:MaxPermSize=512M单纯从参数分析,存在以下问题:指定的不显示收集器JDK8默认收集器为ParallelGC,即Young区使用ParallelScavenge,老年代使用ParallelOld进行收集。这种配置的特点是吞吐量优先,一般适用于后台任务服务器。比如批量订单处理、科学计算等对吞吐量敏感,对时延不敏感的场景。当前的服务是视频和用户交互的入口,对延迟非常敏感。因此,不适合使用默认的收集器ParralelGC,应该选择一个更合适的收集器。Young区比例不合理目前的服务主要是提供API。这类服务的特点是常驻对象较少,大多数对象的生命周期都比较短。经过一两次YoungGC,它们就会消亡。查看当前JVM配置:整个堆4G,Young区一共1G,默认-XX:SurvivorRatio=8,即有效大小为0.9G,老区常驻对象大小代约为400M。这意味着当服务负载高、请求并发量大时,Young区的Eden+S0区会很快被填满,YoungGC会更频繁。另外,会造成本应被YoungGC回收的对象过早提升,增加FullGC的频率,增加单次回收的面积。Old区由于使用ParralellOld,无法与用户线程并发执行,导致使用寿命长。时间冻结,可用性下降,P99响应时间上升。-XX:MetaspaceSize和-XX:MaxMetaspaceSizePerm区域在jdk1.8中已经过时,被Meta区域取代,所以-XX:PermSize=512M-XX:MaxPermSize=512M配置将被忽略,实际控制Meta区域的参数GC是-XX:MetaspaceSize:Metaspace初始大小,64位机默认21M左右-XX:MaxMetaspaceSize:Metaspace最大值,64位机默认18446744073709551615Byte,可以理解为无上限-XX:MaxMetaspaceExpansion:增加触发metaspaceGC的最大阈值Requirement-XX:MinMetaspaceExpansion:增加触发metaspaceGC的最小阈值,默认为340784Byte。在启动和发布这样一个服务的过程中,当元数据区达到21M时,会触发一次FullGC(MetadataGCThreshold),然后随着元空间数据区的扩大,会混合几次FullGC(MetadataGCThreshold),会降低服务发布的稳定性和效率。另外,如果服务大量使用动态类生成技术,也会因为这种机制产生不必要的FullGC(MetadataGCThreshold)。4.优化方案/验证方案目前配置的明显缺点上面已经分析过了。下面的优化方案主要是有针对性的解决这些问题,再根据效果决定是否继续进一步优化。目前主流/优秀的收集器包括:ParrallelScavenge+ParrallelOld:吞吐量优先,适用于后台任务类服务;ParNew+CMS:经典的低暂停收集器,被大多数商业和延迟敏感的服务使用;G1:JDK9默认收集器,当堆内存比较大(6G-8G以上)时,表现出比较高的吞吐量和较短的停顿时间;ZGC:JDK11引入的低延迟垃圾收集器,目前处于实验阶段;结合当前服务的实际情况(堆大小、可维护性),我们选择ParNew+CMS方案更为合适。参数选择原则如下:1)必须指定Metaarea的大小,MetaspaceSize和MaxMetaspaceSize的大小设置要一致。具体大小要结合在线实例的情况,可以通过jstat-gc获取服务的在线实例情况。#jstat-gc31247S0CS1CS0US1UECEUOCOUMCMUCCSCCCSUYGCYGCTFGCFGCTGCT37888.037888.00.032438.5972800.0403063.53145728.02700882.3167320.0152285.018856.016442.415189597.2096570.447667.655可以看出MU在150M左右,因此-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M比较合理。2)Youngarea越大越好。当heapsize一定时,Young区越大,YoungGC的频率越小,但Old区会变小。如果太小,一些对象稍有提升就会触发FullGC。如果Young区太小,YoungGC会比较频繁,所以Old区会比较大,单次FullGC的停顿会比较大。因此需要根据业务情况,在几种场景下比较Young区的大小,最终得出最合适的配置。基于以上原理,下面是四种参数组合:1.ParNew+CMS,Youngareadoubled-Xms4096M-Xmx4096M-Xmn2048M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC-XX:+CMSScavengeBeforeRemark2.ParNew+CMS,Young区翻倍,去掉-XX:+CMSScavengeBeforeRemark(使用[-XX:CMSScavengeBeforeRemark]参数在remarking前进行新生代GC)。因为老年代和年轻代之间的对象存在跨代引用,老年代进行GCRootstracking时,年轻代也会被扫描,如果新生代GC可以在remarking之前进行,那么less扫描就可以了done对于某些对象,relabeling阶段的性能也可以提高。)-Xms4096M-Xmx4096M-Xmn2048M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC3.ParNew+CMS,Young区域扩大0.5倍-Xms4096M-Xmx4096M-Xmn1536Metace-XX256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC-XX:+CMSScavengeBeforeRemark4.ParNew+CMS,Young区不变-Xms4096M-Xmx4096M-Xmn1024M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=2XX:+UseParNewGC-XX:+UseConcMarkSweepGC-XX:+CMSScavengeBeforeRemark接下来我们需要在压测环境下对比分析验证四种方案在不同负载下的实际表现。4.1压测环境验证/分析高负载场景下的GC性能(1100QPS)可以看出,在高负载场景下,ParNew+CMS这四个指标的性能要比ParrallelScavenge+ParrallelOld好很多。其中:方案4(Young区扩大0.5倍)表现最好,接口P95、P99延迟较当前方案降低50%,FullGC累计耗时减少88%,YoungGC次数减少了23%,YoungGC的累计耗时减少了4%,扩大Young区域后,虽然次数减少了,但是Young区域扩大了,单次YoungGC的耗时也会大概率增加,符合预期。使Young面积翻倍的两种方案,即方案2和方案3,具有相似的性能。与现有方案相比,接口P95和P99的延迟降低了40%,FullGC累计时间减少了81%,YoungGC次数减少了43%。累计GC耗时减少了17%,比起0.5倍的Young区扩大略差。整体表现不错。两种方案合并,不再区分。新方案中,Young地区的不变方案表现最差,被淘汰。所以在中等负载场景下,我们只需要比较方案2和方案4。从中等负载场景下(600QPS)的GC性能可以看出,在中等负载场景下,ParNew+两种的性能CMS(Scheme2和Scheme4)也比ParrallelScavenge+ParrallelOld好很多。Young区翻倍的方案表现最好,接口P95和P99延迟较当前方案降低32%,FullGC累计时间减少93%,YoungGC次数减少42次%,YoungGC累计时间减少44%;Young扩大0.5倍面积的方案略逊一筹。总体而言,两种方案的性能非常相似。原则上两种方案都可以接受,但是将Young区域扩大0.5倍的方案在业务高峰期表现更好。为了保证高峰期服务的稳定性和性能,目前更倾向于选择ParNew+CMS,计划将Young区域扩大0.5倍。4.2灰度方案/分析为了保证覆盖业务高峰期,在周五、周六、周日的两个机房随机抽取一个在线实例。在线实例指标达到预期后,进行全量升级。目标组xx.xxx.60.6采用方案2,即目标方案-Xms4096M-Xmx4096M-Xmn1536M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC-XX:+CMSScavengeBeforeRemark控制group1xx.xxx.15.215采用原方案-Xms4096M-Xmx4096M-Xmn1024M-XX:PermSize=512M-XX:MaxPermSize=512Mcontrolgroup2xx.xxx.40.87采用方案4,即候选目标方案-Xms4096M-xmx4096M-Xmn2048M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC-XX:+CMSScavengeBeforeRemark灰度3台机器。我们先来分析一下YoungGC的相关指标:YoungGC次数YoungGC累计耗时YoungGC单次耗时可以看出,与原方案相比,目标方案的YGC次数减少了50%,累计耗时为减少了47%。在流量增加的同时,服务停顿的频率大大降低,代价是单次YoungGC的耗时增加3ms,收益非常高。对比plan2,即2Gplan在Young区域的整体性能略逊于targetplan,然后分析FullGC指标。老年代内存增长FullGC次数FullGC累积/单次耗时与原方案相比,使用目标方案时,老年代的增长速度要慢很多,FullGC发生次数从155次基本减少观察期间减少到27次,减少了82%,平均停顿时间从399ms减少到60ms,减少了85%,毛刺非常少。控制方案2,即YoungDistrict2G方案整体性能较目标方案差。至此,可以看出目标解在各个维度上都远超原解,基本达到了调参的目的。但是,细心的同学会发现,与原方案相比,“FullGC”(其实是CMSBackgroundGC)的耗时在目标方案中更加稳定,但是经过几次“FullGC”后会出现耗时毛刺",表示此时用户请求会暂停2-3s。能否进一步优化,给用户更极致的体验?4.3重新优化这里首先要分析一下这个现象背后的逻辑。对于CMS收集器,使用的收集算法是Mark-Sweep-[Compact]。CMS收集器的类型GC:CMS背景GC是最常见的CMS类型。它是周期性的。JVM的驻留线程定时扫描老年代的使用率。当使用率超过阈值时,触发。这就是Mark-Sweep方法。由于没有Compact等耗时操作,而且可以和用户进程并行,所以CMS的暂停会比较低。GC日志中出现GC(CMSInitialMark)字样,表示发生了CMSBackgroundGC。因为BackgroundGC采用Mark-Sweep,所以在老年代会造成内存碎片,这也是CMS最大的弱点。CMSForegroundGC是CMS收集器中真正的FullGC。它使用SerialOld或ParralelOld进行收集,出现频率较低。经常出现时,会造成较大的停顿。触发CMSForegroundGC的场景有很多,场景如下:System.gc();jmap-histo:livepid;元数据区空间不足;提升失败,GC日志中的标志为ParNew(提升失败);concurrentmodefailed,GC日志中的标志是并发模式失败。不难推断,目标解决方案中的故障是由提升失败或并发模式失败引起的。由于线上没有开启gc日志,所以无所谓,因为这两种场景的根本原因是一样的,就是后面几次CMSBackgroudGCOldage内存碎片导致的。我们只需要尽量减少老年代分片触发的提升失败和并发模式失败。CMSBackgroundGC由JVM常驻线程定时扫描,扫描老年代的使用率。当使用率超过阈值时,阈值由-XX:CMSInitiatingOccupancyFraction这两个参数控制;-XX:+UseCMSInitiatingOccupancyOnly。如果不设置,第一次默认92%,后续会根据历史情况进行预测,动态调整。如果我们固定阈值,将阈值设置为一个相对合理的值,既不会让GC过于频繁,又可以降低提升失败或并发模式失败的概率,可以大大缓解毛刺出现的频率。目标解决方案的堆分布如下:Young区、1.5GOld区、2.5GOld区的常驻对象约400M。根据经验数据,75%和80%是一个折衷,所以我们选择-XX:CMSInitiatingOccupancyFraction=75-XX:+UseCMSInitiatingOccupancyOnly灰度观察(我们也对80%的场景做了对照实验,75%优于80%)。最终目标解决方案的配置为:-Xms4096M-Xmx4096M-Xmn1536M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC-XX:+CMSScavengeBeforeRemark-XX:CMSInitiatingOccupancyFraction=75-UseCMSInitiatingOccupancy如上配置,一台机器灰度为xx.xxx.60.6;从重新优化的结果来看,CMSForegroundGC导致的故障基本消失,符合预期。因此,视频服务的最终目标解决方案的配置是;-XX:+UseCMSInitiatingOccupancyOnly5、结果验收灰度持续7天左右,涵盖工作日和周末。结果符合预期,符合全量上线的条件。以下是全卷后的成绩评价。YoungGC次数YoungGC累计耗时单次YoungGC耗时从YoungGC指标来看,调整后YoungGC次数平均减少30%,YoungGC累计耗时平均减少17%,单次YoungGC平均耗时增加了7ms左右,YoungGC表现符合预期。除了技术手段,我们在业务上也做了一些优化。调优前实例的YoungGC会有明显的不规则毛刺(定时任务可能没有分配到当前实例)。下面是业务中的一个定时任务。将加载大量数据。在调优的过程中,任务会被分片分布到多个实例中,从而让YoungGC更加顺畅。FullGC单次/累计耗时从“FullGC”指标来看,“FullGC”的频率和停顿都大大减少。可以说基本没有真正的FullGC。Coreinterface-A(更多下游依赖)P99响应时间,减少19%(从3457ms到2817ms);核心接口-B(下游依赖适中)P99响应时间,减少41%(从1647ms到973ms);核心接口-C(最小下游依赖)P99响应时间,减少80%(从628ms到127ms);总的来说,总体结果超出预期。YoungGC的表现与既定目标非常吻合。基本上,没有真正的FullGC。P99接口的优化效果取决于下游依赖的数量。依赖越少,效果越明显。6.写在最后由于GC算法复杂,影响GC性能的参数很多,具体参数的设置取决于服务的特性。这些因素大大增加了JVM调优的难度。本文基于视频业务的优化经验,着重介绍了优化思路和实现过程,总结了一些通用的优化流程,希望能为大家提供一些参考。作者:李冠云,JessicaChen,vivo互联网技术团队
