EZLippi-浮生志

JIT引起系统Load高分析

有个应用在启动的初期系统的load会飚的很高,导致监控系统报了告警,经过分析是JVM对热点代码做JIT即时编译导致CPU使用率很高,而此时Web容器收到了大量的请求但是系统处理请求较慢导致业务的线程池满出现异常,这篇文章主要分析load高的可能原因以及JIT编译的一些基础知识。

load高的可能原因

  1. 长时间执行了耗费cpu的操作(usr使用率较高),比如死循环执行某个计算操作
  2. 等待竞争资源(比如锁、IO)致使请求处理慢,执行时间变长(sys占比高)
  3. 运行线程数见多,频繁上下文切换导致单个任务处理时间变长(sys占比高)
  4. 有大流量进入导致load身高

如何排查CPU占用升高的原因

一般从以下三个方面考虑:

  1. 业务代码执行,比如业务代码写了不合理的循环
  2. 框架和中间件的初始化过程
  3. JVM或者web容器(Tomcat/Jetty/Jboss等)

我们问题的背景是在启动的初期load较高,一段时间之后Load又降低下来,而且在多个主机上都出现了;根据这些特点首先排查了业务是否在启动过程做了一些复杂的操作,排查结果是没有;然后查了下接口调用日志发现在那个时间点收到了很多调用请求,联想到JIT的编译过程:JVM在启动初期通过解释字节码进行执行,当方法执行次数达到指定阈值后,触发JIT即时编译,JIT把字节码编译成机器码后执行,执行效率提高。这个时间点load飚高原因就是收到大量调用请求导致很多方法变成热点代码,JIT会启动对热点代码的编译,这个时候系统Load会升高导致请求处理较慢,最后就导致了业务线程池满。

JIT分层编译相关概念

需要注意的是,jdk1.8默认开启了分层编译,在1.7版本你可以通过-XX:+TieredCompilation开启分层编译,关于分层编译网上介绍的文章不多,主要分为C1和C2编译器,C1又称为客户端编译,C2编译器称为服务端编译器,通过抓取jvm进程线程堆栈也可以发现C1和C2编译线程的足迹,整个JIT的编译级别有以下5种:

0:解释执行,这是最慢的一种方式
1:简单C1编译代码
2:受限的C1编译代码,不做性能分析,根据方法调用次数和方法内部循环次数来启动
3:完全C1编译代码,编译器收集分析信息之后做的编译
4:C2编译代码,编译最慢,编译后执行速度最快

编译级别的转换主要是根据方法调用计数器和回边计数器(方法内循环次数)以及C1和C2编译线程当前的负载情况来决定是否开启更高级别的编译,每个层次的阈值可以添加jvm启动参数-XX:+PrintFlagsFinal的输出来查看,比如我在jdk1.8.0_25时参数如下:

1
2
3
4
5
CompileThreshold = 10000                               
IncreaseFirstTierCompileThresholdAt = 50
Tier2CompileThreshold = 0
Tier3CompileThreshold = 2000
Tier4CompileThreshold = 15000

可以看到层次2的阈值为0,层次3为2000,层次4(开启C2编译)为15000,根据当前的代码执行情况下一步采取的编译措施可能是如下几种:

  1. 继续解释执行(可能编译线程负载很高)
  2. 解释器开始采样分析
  3. 根据分析数据用C1的层次3进行编译,后续可以继续优化
  4. 不分析直接用C1的层次2进行编译,后续再优化的可能性较低
  5. 不需要任何信息直接用C1的层次1进行编译

如果server编译器队列满了,就会从server队列中取出方法,以级别2进行编译,在这个级别上,C1编译器使用方法调用计数器和回边计数器(但不需要性能分析的反馈信息)。这使得方法编译得更快,而方法也将在C1编译器收集分析信息之后被编译为级别3,最终当server编译器队列不太忙的时候被编译为级别4。

可以给jvm添加启动参数-XX:+PrintCompilation来查看jit的编译信息,每次进行jit编译后会输出编译的方法名称到控制台,下面这个列表是一个样例的编译输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
编译时间戳	编译ID	方法属性	编译层次 方法签名(大小)
180 4 1 sun.instrument.TransformerManager::getSnapshotTransformerList (5 bytes)
180 5 3 java.lang.String::lastIndexOf (13 bytes)
181 6 3 java.lang.String::indexOf (7 bytes)
182 7 3 java.lang.System::getSecurityManager (4 bytes)
183 2 4 java.lang.String::hashCode (55 bytes)
183 1 4 java.lang.Object::<init> (1 bytes)
184 8 4 java.lang.String::length (6 bytes)
184 3 4 java.lang.String::charAt (29 bytes)
185 9 3 java.util.HashMap$Node::<init> (26 bytes)
185 10 4 java.lang.Math::max (11 bytes)
185 11 4 java.lang.String::indexOf (70 bytes)
186 12 4 java.lang.AbstractStringBuilder::append (29 bytes)
188 13 3 java.util.Arrays::copyOf (19 bytes)
189 14 3 java.lang.String::equals (81 bytes)
190 15 1 java.nio.Buffer::position (5 bytes)
190 16 n 0 java.lang.System::arraycopy (native) (static)
190 17 3 java.lang.String::startsWith (72 bytes)
190 23 4 java.lang.AbstractStringBuilder::ensureCapacityInternal (16 bytes)
191 19 3 sun.nio.cs.UTF_8$Encoder::encode (359 bytes)
192 30 3 java.io.WinNTFileSystem::normalize (231 bytes)
192 28 4 java.io.WinNTFileSystem::isSlash (18 bytes)
192 29 s 4 java.lang.StringBuffer::append (13 bytes)
194 24 4 java.lang.CharacterData::of (120 bytes)
194 25 4 java.lang.CharacterDataLatin1::getProperties (11 bytes)
196 27 3 java.lang.String::<init> (62 bytes)
196 26 3 java.lang.Character::toLowerCase (6 bytes)
196 20 3 java.lang.Math::min (11 bytes)
196 18 ! 3 sun.misc.URLClassPath$JarLoader::ensureOpen (32 bytes)
196 21 3 java.util.jar.JarFile::getJarEntry (9 bytes)
197 22 3 java.util.jar.JarFile::getEntry (22 bytes)
1195 31 3 java.lang.String::replace (127 bytes)
1196 34 3 java.lang.String::indexOf (166 bytes)
1196 32 3 java.lang.String::startsWith (7 bytes)
1196 35 s! 3 sun.misc.URLClassPath::getLoader (154 bytes)
1199 44 3 java.util.HashMap::hash (20 bytes)
1199 46 3 java.util.HashMap::putVal (300 bytes)
1200 45 3 java.util.HashMap::put (13 bytes)
1200 47 3 java.lang.String::<init> (10 bytes)

可以看到大部分方法的编译级别是C1的级别3,有些方法比如java.lang.String::indexOf先经过级别3编译然后用C2进行编译。

方法属性的解释如下:

  • 同步方法用s标识
  • 有异常处理的方法用!标识
  • 阻塞方法用b标识
  • native方法用n标识

关于分层编译的一些要点:

  • 最开始都是解释执行
  • 理想情况下应转成level3编译
  • 根据C1队列长度和C1编译线程数来调整编译的阈值
  • 根据C2队列长度可能转向C2编译
  • 根据C2队列长度、C2编译线程数调整level4编译阈值
  • 如果方法非常小,没什么可以优化的空间,直接转level1编译
  • 最常见的编译层次转换:0 -> 3 -> 4

最后来看一下编译层次转换图:

JIT相关JVM参数简介

选项 默认值 解释
CompileThreshold 1000 or 1500/10000 编译阈值,方法执行多少次后进行编译
PrintCompilation false jit编译时输出日志
InitialCodeCacheSize 160K (varies) 初始codecache大小
ReservedCodeCacheSize 32M/48M codecache最大值
ExitOnFullCodeCache false codecache满了退出jvm
UseCodeCacheFlushing false codecache满了时清空一半的codecache
PrintFlagsFinal false 打印所有的jvm选项
PrintCodeCache false jvm退出时打印codecache
PrintCodeCacheOnCompilation false 编译时打印codecache使用情况

如何解决Jit引起的load高

前面分析了这么多jit的相关知识,那针对这个场景怎么去解决前面出现的系统负载高的问题呢?根据JIT的特点,当方法执行次数到达某个阈值(server虚拟机默认是10000次)时会触发JIT编译,当应用的调用量很大时,大量的请求同时进入,多个方法同时触发JIT,会出现短期内load较高的情况,从而可能导致服务调用超时,因此问题出现具有随机性。我们决定采用提前触发编译的方法来解决服务发布后可能出现的load高的问题;具体做法就是在Web容器启动时循环调用热点方法,触发JIT编译,然后容器启动完成对外发布Rest服务。

这样做的缺点是侵入式,要增加代码,增加了启动时间,优点是实现成本较低,不影响业务。

具体实施方案

1)为了避免CodeCache满导致JIT停止编译或者CodeCacheFlushing的情况,先通过JMX获取到当前JIT的CodeCache大小(参考hellojava中的方法来获取),再通过jinfo -flag ReservedCodeCacheSize javaPid命令获取当前jvm的ReservedCodeCacheSize参数大小,根据实际情况调整ReservedCodeCacheSize的大小,最后调整之后我们在jvm启动脚本中加上了如下两个参数:

1
2
-XX:ReservedCodeCacheSize=512m
-XX:-UseCodeCacheFlushing(禁用CodeCacheFlushing机制)

2) 编写预热代码

  1. 编写WarmUpContextListener实现Spring的ApplicationContextAware接口,确保在Web容器启动完成前,调用需要预热的方法;
  2. WarmUpContextListener读取预先配置好的参数,包括要调用的目标方法、请求参数、执行次数和超时时间;
  3. 新建线程池执行目标方法,执行N次触发JIT编译;
  4. 执行完成,关闭预热线程池;
  5. Web容器启动完成,对外发布服务。

经过以上两个步骤之后,我们的系统就没出现过因jit导致的负载高的场景。

在写这篇博客的过程中参考了以下资料,在此表示感谢。

🐶 您的支持将鼓励我继续创作 🐶