【硬核JVM调参】真实案例带你真正学会控制JVM

110 阅读7分钟

故障背景

案例来自于推送网关的一次线上故障。日志显示【内存使用率过高-超过95%】并实例自动重启,查看日志和apollo配置,确认应用资源限流已经触发,正常情况下的数据推送量不可能会出现内存被打满的情况,必定出现了短时间大流量的数据推送。查询得知,短时间内数据推送量达到100W,远超应用预设的最大推送量。

推送网关架构

图片

系统现状

当前应用是一个推送网关,通过消费MQ,执行责任链将数据推送。

图片

为了避免推送效率受mq消费线程数(mq默认20)的限制或者采用线程池但不便设置具体线程池大小(每个消息体的大小差异性极大),推送时采用apache的HttpAsyncClient(内部基于reactor模型)来执行网络请求,callback回调的方式来获取推送结果。 原限流方案是,对每个业务类型代码设置为50条message,超过50会被拒,返回给MQ稍后消费状态,间隙性去MQ重试消费被拒的消息。存在的问题:随着接入业务增多,而 所需最大资源 = 业务类型种数 * 限流数,实例有限的情况下,限流效果逐步减弱。

故障分析

模拟场景,将数据往MQ投递,观察JVM资源情况,分析原因。

当前应用资源配置情况

实例数量:1
服务实例配置:1核2g
jvm默认参数:-Xmx1g -Xms1g -Xmn512m -XX:SurvivorRatio=10 -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSMaxAbortablePrecleanTime=5000 -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExplicitGCInvokesConcurrent -XX:ParallelGCThreads=2 -Xloggc:/opt/zcy/modules/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/zcy/modules/java.hpro

先查看服务无流量基本处于闲置状态时一个情况,通过arthas dashboard查看jvm使用情况图片

可以看到各线程对cpu的使用率极低,基本处于闲置状态。

接着看下半部分Memory框中的信息,主要的数据项含义如下:

  • heap(堆大小)

  • par_eden_space(伊甸区大小)

  • par_survivor_space(S区大小)

  • cms_old_gen(老年代大小)

  • gc.parnew.count(young gc总次数)

  • gc.parnew.time(young gc总耗时)

  • gc.concurrentmarksweep.count(full gc总次数)

  • gc.concurrentmarksweep.time(full gc总耗时)

图片5S后图片

结合5s前的数据,我们主要关注以下指标:

  • 线程情况:线程cpu使用率并没有明显变化
  • heap(堆大小):堆使用大小增加3M

  • par_eden_space(伊甸区大小):年轻代中的伊甸区只增加3M,按照伊甸区426M的大小,大约需要710秒(约12分钟)才会触发一次young gc

  • par_survivor_space(S区大小):无变化

  • cms_old_gen(老年代大小):无变化

  • gc.parnew.count(young gc总次数):无变化

  • gc.parnew.time(young gc总耗时):无变化

  • gc.concurrentmarksweep.count(full gc总次数):无变化

  • gc.concurrentmarksweep.time(full gc总耗时):无变化

接下来模拟积压数据进行推送时的场景,数据提前通过自动化脚本投递到MQ当中。

积压5000条数据

使用arthas命令 dashboard -i 1000 , 按照5s的间隔输出,观察老年代:

图片

5s后:

图片

对比两次数据,关键信息如下:

  • 5s之内(gc.parnew.count)发生了510-503 = 7次young gc
  • 老年代(cms_old_gen)由233m增长至265m,增长了32m左右,按照老年代512m的大小,大约80s就会发生一次full gc

积压1.5W条数据

图片

5S后

图片

对比两次数据,得知以下关键信息:

  • 线程情况:cms垃圾回收线程cpu占用率高达45%+

  • full gc次数:5s内发生了3次full gc

  • full gc总耗时:总耗时由 14742 增加至 15131

5s之内发生了3次full gc,平均每次full gc耗时(15131-14742)/ 3 = 129 ms,虽然出现了full gc频繁的情况,还不至于造成服务不可用。

积压2W条数据

图片

5S后

图片

对比两次数据发现:

  • 线程情况:cms垃圾回收线程cpu占用率48%+
  • 老年代:已使用511m(总大小512m)
  • full gc次数:5s内发生了3次full gc

5s内发生了3次full gc,老年代始终处于已使用511m(总大小512m)的情况,每次的full gc平均耗时 (81942-79740)/3=734 ms,相当于5s内有2.2s的时间都在full gc

此时查看日志发现数据推送时发生大量的socket连接超时

图片

再查看下当时的gc日志

图片

查看gc日志发现:两次full gc之间只差了1.4s左右,从524287k回收至524275k,只回收了12k的内存空间,却花费了0.7s左右时间,系统有一半的时间都在进行full gc,造成系统不可用。

原因分析

通过上面测试可以发现系统存在的问题主要是由于下游消费速率(网络请求)跟不上上游的投递速率(mq消费),导致内存逐渐被打满,系统频繁full gc造成服务不可用,直至产生OOM程序崩溃。

优化方案

在该场景中系统的主要瓶颈在于jvm内存大小上面,当前系统频繁full gc,无法及时释放内存导致内存溢出,致使整个实例挂掉。所以达到我们的目的,需要做到两点:

  • 调整JVM参数,让内存回收更合理(缓解,但不治标)

  • 扩大内存,满足数据洪峰的最大值(治标,但浪费资源)

  • 基于jvm堆内存使用率来对上游进行限流控制(可彻底解决问题,将流量压力抛给上游MQ)。

jvm参数优化

  • 原来年轻代大小设为1024M并不适用于这种数据推送场景。通过观察发现老年代常驻对象150M左右,考虑浮动垃圾等,老年代分配512M大小,所以年轻代调整为1.5g大小:-Xmn1536M
  • 修改年轻代中的伊甸区和s区的比例由10:1:1修改为8:1:1,避免young gc后存货对象过多直接进入老年代: -XX:SurvivorRatio=8
  • 元空间大小分配256m。-XX:MaxMetaspaceSize=256M -XX:MetaspaceSize=256M
  • 线程栈最大1m:-Xss1M
  • 针对年轻代使用ParNew垃圾收集器:-XX:+UseParNewGC

增加资源限流

当前推送流程是基于责任链的模式来进行设计的,所以只需在链中再增加一个限流器即可。

  • 限流器中可设定一个jvm堆内存的使用率,当超过这个阈值后对当前的消费线程进行阻塞,直到使用率低于阈值后再进行放行
  • 当触发限流阻塞消费者线程时,为应对特殊情况,可设置一个最大阻塞时间,超过该时间后放行该消费者线程

其中阈值的配置范围给出一个参考公式:
最大阈值百分比 = (年轻代可使用最大值 + 老年代可使用最大值 * -XX:CMSInitiatingOccupancyFraction参数值)/  堆可使用最大值
最小阈值百分比= 年轻代可使用最大值 / 堆可使用最大值

代入相关参数,即
最大阈值百分比 = (1381 + 512*0.8 )/  1894 = 94%
最小阈值百分比 = 1381 / 1894 = 72%

参考代码:

方案验证

资源配置

  • 实例配置:1核4g
  • 实例数:2

数据量:5w条积压数据

资源限流前

图片

现象:由于内存被打满,jvm频繁进行full gc,导致cpu被打满,数据推送线程获取的cpu时间片减少,引起网络io下降,大量请求返回socket连接超时。

资源限流后

在对jvm参数进行优化以及添加资源限流器后,再次模拟数据积压情况观察效果。

推送完成后:

图片

结果比对

推送总耗时full gc次数full gc总耗时单次full gc平均耗时
优化前约35分钟312309232991ms
优化后约18分钟10445387436ms

对比优化前后,使用新的限流方案后的效果显著的,性能提升近了一倍。并模拟导致线上故障的情况,通过脚本将100W数据量推送MQ后,服务正常运行。

感谢你读到这里,不如关注一下?👇

image.png