写点什么

高并发场景下 JVM 调优实践之路

发布于: 刚刚
高并发场景下JVM调优实践之路

一、背景


2021 年 2 月,收到反馈,视频 APP 某核心接口高峰期响应慢,影响用户体验。


通过监控发现,接口响应慢主要是 P99 耗时高引起的,怀疑与该服务的 GC 有关,该服务典型的一个实例 GC 表现如下图:




可以看出,在观察周期里:


  • 平均每 10 分钟 Young GC 次数 66 次,峰值为 470 次;

  • 平均每 10 分钟 Full GC 次数 0.25 次,峰值 5 次;


可见 Full GC 非常频繁,Young GC 在特定的时段也比较频繁,存在较大的优化空间。由于对 GC 停顿的优化是降低接口的 P99 时延一个有效的手段,所以决定对该核心服务进行 JVM 调优。


二、优化目标


  • 接口 P99 时延降低 30%

  • 减少 Young GC 和 Full GC 次数、停顿时长、单次停顿时长


由于 GC 的行为与并发有关,例如当并发比较高时,不管如何调优,Young GC 总会很频繁,总会有不该晋升的对象晋升触发 Full GC,因此优化的目标根据负载分别制定:


目标 1:高负载(单机 1000 QPS 以上)


  • Young GC 次数减少 20%-30% ,Young GC 累积耗时不恶化;

  • Full GC 次数减少 50%以上,单次、累积 Full GC 耗时减少 50%以上,服务发布不触发 Full GC。


目标 2:中负载(单机 500-600)


  • Young GC 次数减少 20%-30% ,Young GC 累积耗时减少 20%;

  • Full GC 次数不高于 4 次/天,服务发布不触发 Full GC。


目标 3:低负载(单机 200 QPS 以下)


  • Young GC 次数减少 20%-30% ,Young GC 累积耗时减少 20%;

  • Full GC 次数不高于 1 次/天,服务发布不触发 Full GC。


三、当前存在的问题


当前服务的 JVM 配置参数如下:

-Xms4096M -Xmx4096M -Xmn1024M-XX:PermSize=512M-XX:MaxPermSize=512M
复制代码


单纯从参数上分析,存在以下问题:


未显示指定收集器 


JDK 8 默认搜集器为 ParrallelGC,即 Young 区采用 Parallel Scavenge,老年代采用 Parallel Old 进行收集,这套配置的特点是吞吐量优先,一般适用于后台任务型服务器。


比如批量订单处理、科学计算等对吞吐量敏感,对时延不敏感的场景,当前服务是视频与用户交互的门户,对时延非常敏感,因此不适合使用默认收集器 ParrallelGC,应选择更合适的收集器。



Young 区配比不合理


当前服务主要提供 API,这类服务的特点是常驻对象会比较少,绝大多数对象的生命周期都比较短,经过一次或两次 Young GC 就会消亡。


再看下当前 JVM 配置


整个堆为 4G,Young 区总共 1G,默认-XX:SurvivorRatio=8,即有效大小为 0.9G,老年代常驻对象大小约 400M。


这就意味着,当服务负载较高,请求并发较大时,Young 区中 Eden + S0 区域会迅速填满,进而 Young GC 会比较频繁。


另外会引起本应被 Young GC 回收的对象过早晋升,增加 Full GC 的频率,同时单次收集的区域也会增大,由于 Old 区使用的是 ParralellOld,无法与用户线程并发执行,导致服务长时间停顿,可用性下降, P99 响应时间上升。


未设置

-XX:MetaspaceSize 和-XX:MaxMetaspaceSize

Perm区在jdk 1.8已经过时,被Meta区取代,因此-XX:PermSize=512M -XX:MaxPermSize=512M配置会被忽略,真正控制Meta区GC的参数为-XX:MetaspaceSize:Metaspace初始大小,64位机器默认为21M左右 -XX:MaxMetaspaceSize:Metaspace的最大值,64位机器默认为18446744073709551615Byte,可以理解为无上限 -XX:MaxMetaspaceExpansion:增大触发metaspace GC阈值的最大要求 -XX:MinMetaspaceExpansion:增大触发metaspace GC阈值的最小要求,默认为340784Byte
复制代码


这样服务在启动和发布的过程中,元数据区域达到 21M 时会触发一次 Full GC (Metadata GC Threshold),随后随着元数据区域的扩张,会夹杂若干次 Full GC (Metadata GC Threshold),使服务发布稳定性和效率下降。


此外如果服务使用了大量动态类生成技术的话,也会因为这个机制产生不必要的 Full GC (Metadata GC Threshold)。




四、优化方案/验证方案


上面已分析出当前配置存在的较为明显的不足,下面优化方案主要先针对性解决这些问题,之后再结合效果决定是否继续深入优化。


当前主流/优秀的搜集器包含:


  • Parrallel Scavenge + Parrallel Old:吞吐量优先,后台任务型服务适合;

  • ParNew + CMS:经典的低停顿搜集器,绝大多数商用、延时敏感的服务在使用;

  • G1:JDK 9 默认搜集器,堆内存比较大(6G-8G 以上)的时候表现出比较高吞吐量和短暂的停顿时间;

  • ZGC:JDK 11 中推出的一款低延迟垃圾回收器,目前处在实验阶段;



结合当前服务的实际情况(堆大小,可维护性),我们选择 ParNew + CMS 方案是比较合适的。


参数选择的原则如下:


1)Meta 区域的大小一定要指定,且 MetaspaceSize 和 MaxMetaspaceSize 大小应设置一致,具体多大要结合线上实例的情况,通过 jstat -gc 可以获取该服务线上实例的情况。

# jstat -gc 31247S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT37888.0 37888.0 0.0 32438.5 972800.0 403063.5 3145728.0 2700882.3 167320.0 152285.0 18856.0 16442.4 15189 597.209 65 70.447 667.655
复制代码


可以看出 MU 在 150M 左右,因此-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M 是比较合理的。


2)Young 区也不是越大越好


当堆大小一定时,Young 区越大,Young GC 的频率一定越小,但 Old 区域就会变小,如果太小,稍微晋升一些对象就会触发 Full GC 得不偿失。


如果 Young 区过小,Young GC 就会比较频繁,这样 Old 区就会比较大,单次 Full GC 的停顿就会比较大。因此 Young 区的大小需要结合服务情况,分几种场景进行比较,最终获得最合适的配置。


基于以上原则,以下为 4 种参数组合:


1.ParNew +CMS,Young 区扩大 1 倍

-Xms4096M -Xmx4096M -Xmn2048M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC-XX:+CMSScavengeBeforeRemark
复制代码


2.ParNew +CMS,Young 区扩大 1 倍,

去除-XX:+CMSScavengeBeforeRemark(使用【-XX:CMSScavengeBeforeRemark】参数可以做到在重新标记前先执行一次新生代 GC)。


因为老年代和年轻代之间的对象存在跨代引用,因此老年代进行 GC Roots 追踪时,同样也会扫描年轻代,而如果能够在重新标记前先执行一次新生代 GC,那么就可以少扫描一些对象,重新标记阶段的性能也能因此提升。)

-Xms4096M -Xmx4096M -Xmn2048M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC
复制代码


3.ParNew +CMS,Young 区扩大 0.5 倍

-Xms4096M -Xmx4096M -Xmn1536M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC -XX:+CMSScavengeBeforeRemark
复制代码


4.ParNew +CMS,Young 区不变

-Xms4096M -Xmx4096M -Xmn1024M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC -XX:+CMSScavengeBeforeRemark
复制代码


下面,我们需要在压测环境,对不同负载下 4 种方案的实际表现进行比较,分析,验证。


4.1 压测环境验证/分析


高负载场景(1100 QPS)GC 表现



可以看出,在高负载场景,4 种 ParNew + CMS 的各项指标表现均远好于 Parrallel Scavenge + Parrallel Old。其中:


  • 方案 4(Young 区扩大 0.5 倍)表现最佳,接口 P95,P99 延时相对当前方案降低 50%,Full GC 累积耗时减少 88%, Young GC 次数减少 23%,Young GC 累积耗时减少 4%,Young 区调大后,虽然次数减少了,但 Young 区大了,单次 Young GC 的耗时也大概率会上升,这是符合预期的。


  • Young 区扩大 1 倍的两种方案,即方案 2 和方案 3,表现接近,接口 P95,P99 延时相对当前方案降低 40%,Full GC 累积耗时减少 81%, Young GC 次数减少 43%,Young GC 累积耗时减少 17%,略逊于 Young 区扩大 0.5 倍,总体表现不错,这两个方案进行合并,不再区分。


Young 区不变的方案在新方案里,表现最差,淘汰。所以在中负载场景,我们只需要对比方案 2 和方案 4。


中负载场景(600 QPS)GC 表现



可以看出,在中负载场景,2 种 ParNew + CMS(方案 2 和方案 4)的各项指标表现也均远好于 Parrallel Scavenge + Parrallel Old。


  • Young 区扩大 1 倍的方案表现最佳,接口 P95,P99 延时相对当前方案降低 32%,Full GC 累积耗时减少 93%, Young GC 次数减少 42%,Young GC 累积耗时减少 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
复制代码


对照组 1  xx.xxx.15.215


采用原始方案

-Xms4096M -Xmx4096M -Xmn1024M-XX:PermSize=512M-XX:MaxPermSize=512M
复制代码


对照组 2  xx.xxx.40.87


采用方案 4,即候选目标方案

-Xms4096M -Xmx4096M -Xmn2048M-XX:MetaspaceSize=256M-XX:MaxMetaspaceSize=256M-XX:+UseParNewGC-XX:+UseConcMarkSweepGC -XX:+CMSScavengeBeforeRemark
复制代码


灰度 3 台机器。


我们先分析下 Young GC 相关指标:


Young GC 次数



Young GC 累计耗时



Young GC 单次耗时



可以看出,与原始方案相比,目标方案的 YGC 次数减少 50%,累积耗时减少 47%,吞吐量提升的同时,服务停顿的频率大大降低,而代价是单次 Young GC 的耗时增长 3ms,收益是非常高的。


对照方案 2 即 Young 区 2G 的方案整体表现稍逊与目标方案,再分析 Full GC 指标。


老年代内存增长情况



Full GC 次数



Full GC 累计/单次耗时



与原始方案相比,使用目标方案时,老年代增长的速度要缓慢很多,基本在观测周期内 Full GC 发生的次数从 155 次减少至 27 次,减少 82%,停顿时间均值从 399ms 减少至 60ms,减少 85%,毛刺也非常少。


对照方案 2 即 Young 区 2G 的方案整体表现逊于目标方案。到这里,可以看出,目标方案从各个维度均远优于原始方案,调优目标也基本达成。


但细心的同学会发现,目标方案相对原始方案,"Full GC"(实际上是 CMS Background GC)耗时更加平稳,但每个若干次"Full GC"后会有一个耗时很高的毛刺出现,这意味这个用户请求在这个时刻会停顿 2-3s,能否进一步优化,给用户一个更加极致的体验呢?



4.3 再次优化


这里首先要分析这现象背后的逻辑。



对于 CMS 搜集器,采用的搜集算法为 Mark-Sweep-[Compact]。


CMS 搜集器 GC 的种类:


CMS Background GC


这种 GC 是 CMS 最常见的一类,是周期性的,由 JVM 的常驻线程定时扫描老年代的使用率,当使用率超过阈值时触发,采用的是 Mark-Sweep 方式,由于没有 Compact 这种耗时操作,且可以与用户进程并行,所以 CMS 的停顿会比较低,GC 日志中出现 GC (CMS Initial Mark)字样就代表发生了一次 CMS Background GC。


Background GC 由于采用的是 Mark-Sweep,会导致老年代内存碎片,这也是 CMS 最大的弱点。


CMS Foreground GC


这种 GC 是 CMS 搜集器里真正意义上的 Full GC,采用 Serial Old 或 Parralel Old 进行收集,出现的频率就较低,当往往出现后就会造成较大的停顿。


触发 CMS Foreground GC 的场景有很多,场景的如下:


  • System.gc();

  • jmap -histo:live pid;

  • 元数据区域空间不足;

  • 晋升失败,GC 日志中的标志为 ParNew(promotion failed);

  • 并发模式失败,GC 日志中的标志为 councurrent mode failure 字样。


不难推断,目标方案中的毛刺是晋升失败或并发模式失败造成的,由于线上没有开启打印 gc 日志,但也无妨,因为这两种场景的根因是一致的,就是若干次 CMS Backgroud GC 后造成的老年代内存碎片。


我们只需要尽可能减少由于老年代碎片触发晋升失败、并发模式失败即可。


CMS Background GC 由 JVM 的常驻线程定时扫描老年代的使用率,当使用率超过阈值时触发,该阈值由-XX:CMSInitiatingOccupancyFraction; -XX:+UseCMSInitiatingOccupancyOnly 两个参数控制,不设置,默认首次为 92%,后续会根据历史情况进行预测,动态调整。


如果我们固定阈值的大小,将该阈值设置为一个相对合理的值,既不使 GC 过于频繁,又可以降低晋升失败或并发模式失败的概率,就可以大大缓解毛刺产生的频率。


目标方案的堆分布如下:

  • Young 区 1.5G

  • Old 区 2.5G

  • Old 区常驻对象 约 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 -XX:+UseCMSInitiatingOccupancyOnly
复制代码


如上配置,灰度 xx.xxx.60.6 一台机器;



从再次优化的结果上看,CMS Foreground GC 引起的毛刺基本消失,符合预期。


因此,视频服务最终目标方案的配置为;

-Xms4096M -Xmx4096M -Xmn1536M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSScavengeBeforeRemark -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly
复制代码


五、结果验收


灰度持续 7 天左右,覆盖工作日与周末,结果符合预期,因此符合在线上开启全量的条件,下面对全量后的结果进行评估。


Young GC 次数



Young GC 累计耗时



单次 Young GC 耗时



从 Young GC 指标上看,调整后 Young GC 次数平均减少 30%,Young GC 累积耗时平均减少 17%,Young GC 单次耗时平均增加约 7ms,Young GC 的表现符合预期。


除了技术手段,我们也在业务上做了一些优化,调优前实例的 Young GC 会出现明显的、不规律的(定时任务不一定分配到当前实例)毛刺,这里是业务上的一个定时任务,会加载大量数据,调优过程中将该任务进行分片,分摊到多个实例上,进而使 Young GC 更加平滑。


Full GC 单次/累积耗时




从"Full GC"的指标上看,"Full GC"的频率、停顿极大减少,可以说基本上没有真正意义上的 Full GC 了。


核心接口-A (下游依赖较多) P99 响应时间,减少 19%(从 3457 ms 下降至 2817 ms);



核心接口-B (下游依赖中等)  P99 响应时间,减少 41%(从 1647ms 下降至 973ms);



核心接口-C (下游依赖最少) P99 响应时间,减少 80%(从 628ms 下降至 127ms);



综合来看,整个结果是超出预期的。Young GC 表现与设定的目标非常吻合,基本上没有真正意义上的 Full GC,接口 P99 的优化效果取决于下游依赖的多少,依赖越少,效果越明显。


六、写在最后


由于 GC 算法复杂,影响 GC 性能的参数众多,并且具体参数的设置又取决于服务的特点,这些因素都很大程度增加了 JVM 调优的难度。


本文结合视频服务的调优经验,着重介绍调优的思路和落地过程,同时总结出一些通用的调优流程,希望能给大家提供一些参考。



作者:vivo 互联网技术团队 Li Guanyun、 Jessica Chen

发布于: 刚刚阅读数: 2
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
高并发场景下JVM调优实践之路