高并发场景下 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 配置参数如下:
单纯从参数上分析,存在以下问题:
未显示指定收集器
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
这样服务在启动和发布的过程中,元数据区域达到 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 可以获取该服务线上实例的情况。
可以看出 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 倍
2.ParNew +CMS,Young 区扩大 1 倍,
去除-XX:+CMSScavengeBeforeRemark(使用【-XX:CMSScavengeBeforeRemark】参数可以做到在重新标记前先执行一次新生代 GC)。
因为老年代和年轻代之间的对象存在跨代引用,因此老年代进行 GC Roots 追踪时,同样也会扫描年轻代,而如果能够在重新标记前先执行一次新生代 GC,那么就可以少扫描一些对象,重新标记阶段的性能也能因此提升。)
3.ParNew +CMS,Young 区扩大 0.5 倍
4.ParNew +CMS,Young 区不变
下面,我们需要在压测环境,对不同负载下 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,即目标方案
对照组 1 xx.xxx.15.215
采用原始方案
对照组 2 xx.xxx.40.87
采用方案 4,即候选目标方案
灰度 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%)。
最终目标方案的配置为:
如上配置,灰度 xx.xxx.60.6 一台机器;
从再次优化的结果上看,CMS Foreground GC 引起的毛刺基本消失,符合预期。
因此,视频服务最终目标方案的配置为;
五、结果验收
灰度持续 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
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/9c5bd9c062b585b5d8a96f049】。文章转载请联系作者。
评论