码农戏码

JIT优化之道

碎碎念

《JIT优化之道》是去年在公司的一次分享,对于公司组织分享我是赞同又不赞同,怎么讲呢?

技术分享当然是好的,这是一个双赢,分享者教学相长,而收听者也能更快的了解进步。

但以前在原先的公司也做过些类事情,但没有想象的好,大家对分享主题的探索也只限于在分享时间段内,过后很少有人,几乎没人去做进一步的探索。填鸭式的学习效果甚微。后来只涉及一些项目中使用到的知识点,让项目中人去发现项目中的一些亮点,盲区

聪明人从旁人的错误中吸取教训,愚笨人则从自身的错误中吸取教训,有多少聪明人呢?
不经历风雨又怎么见彩虹?

JIT主要关注三个点

  1. JIT是什么
  2. JIT的原理
  3. JIT的意义

JIT是什么

JIT是just in time,即时编译器;使用该技术,能够加速java程序的执行速度

image

编译器

image

Java编译器总的来说分为

  1. 前端编译器
  2. JIT(just in time compiler)编译器
  3. AOT(Ahead Of Time Compiler)编译器

前端编译器: 将Java文件编译为class文件的编译器,目前主要有以下两个,
Sun提供的Javac 和Eclipse JDT中的增量式编译器(ECJ)

JIT编译器: 虚拟机后端运行期编译器,把字节码转换为机器码的过程。
HotSpot Vm中提供的C1, C2编译器

AOT编译器:直接把Java文件转换为本地机器码的过程

解释器与编译器

image

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。

在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率

分层编译的策略TieredCompilation

为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启动分层编译的策略。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,包括:

  1. 第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
  2. 第1层,也称为C1编译,将字节码编译成本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
  3. 第2层,也称为C2编译,也是将字节码编译为本地代码,但是会启动一下编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,Client Compiler和Server Compiler 将会同时工作,许多代码都可能会多次编译,C1获取更高的编译速度,用C2获取更好的编译质量,在解释时候的时候也无须再承担收集性能监控信息的任务。

1
2
3
4
Oracle JDK从JDK 6u25以后的版本支持了多层编译(-XX:+TieredCompilation)
可以用jinfo -flag或-XX:+PrintFlagsFinal来确认是否打开

JDK8是默认打开的

image

图表描绘了纯解析、客户端、服务器端和混合编译的性能区别。X轴代码执行时间,Y轴代表性能

和单纯的代码解析相比,使用客户端编译器可以提高大约5-10倍的执行性能,实际上提高了应用的性能。当然,收益的变化还是依赖于编译器性能如何,哪些优化生效了或者被实现了,还有就是对于目标执行平台来说应用程序设计的有多好。后者是Java开发人员从来不需要担心的。

和客户端编译器相比,服务器端编译器通常能够提升可度量的30%-50%的代码效率。在大部分情况下,这性能的提高将平衡掉多余的资源开销。

分层编译结合了两种编译器的优点。客户端编译产生了快速的启动时间和及时的优化,服务器端编译在执行周期的后期,可以提供更多的高级优化

JIT开关

image

3种执行方式,分别是解释执行、混合模式和编译执行,默认情况下处于混合模式中

1
通过-Xint  -Xcomp改变执行方式

通过代码也可以

1
2
3
java.lang.Compiler.disable();

java.lang.Compiler.enable();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Pi {
public static double calcPi() {
double re = 0;
for (int i = 1; i < 100000; i++) {
re += ((i & 1) == 0 ? -1 : 1) * 1.0 / (2 * i - 1);
}
return re * 4;
}

public static void main(String[] args) {
long b = System.currentTimeMillis();
for (int i = 0; i < 100000; i++)
calcPi();
long e = System.currentTimeMillis();
System.err.println("spend:" + (e - b) + "ms");
}
}
mixed:spend:418ms
int:spend:2547ms
comp:spend:416ms

jstat -compiler 显示VM实时编译的数量等信息。
显示列名
具体描述
Compiled
编译任务执行数量
Failed
编译任务执行失败数量
Invalid
编译任务执行失效数量
Time
编译任务消耗时间
FailedType
最后一个编译失败任务的类型
FailedMethod
最后一个编译失败任务所在的类及方法

1
2
3
jstat -compiler 55417
Compiled Failed Invalid Time FailedType FailedMethod
6296 6 0 50.37 1 org/eclipse/jdt/internal/core/CompilationUnitStructureRequestor createTypeInfo

JIT原理

image

寻找热点代码

在运行过程中,会被即时编译器编译的“热点代码”有两类,即:

  1. 被多次调用的方法
  2. 被多次执行的循环体(OSR On StackReplacement)

判断是否是热点代码的行为成为热点探测:hot spotdetection,主要的热点探测方式主要有两种:

  1. 基于采样的热点探测,JVM会周期性检查各个线程的栈顶,如果某个方法经常出现在栈顶,那就认定为热点方法。简单高效,精度不够。

  2. 基于计数器的热点探测,统计方法执行次数。(HOTSPOT使用这种方式)

计数器

HOTSPOT有两个计数器:

1、方法调用计数器

2、回边计数器

方法调用计数器

image

1
2
3
4
5
方法调用计数器
client默认1500次,
server默认10000次,

-XX:CompileThreshold

方法调用计数器并不是统计方法调用绝对次数,而是一个相对执行频率,
超过一定时间,如果方法调用次数不足以让它提交给编译器,则计数器就会被减少一半,这种现象称为热度衰减(Counter Decay),
进行热度衰减的动作是在垃圾回收时顺便进行的,而这段时间就被称为半衰周期(Counter Half Life Time)

1
2
可用-XX:-UseCounterDecay来关闭热度衰减,
用-XX:CounterHalfLifeTime来设置半衰时间。

要不要关闭这个衰减?

HotSpot VM的触发JIT的计数器的半衰(counter decaying)是一种很好的机制,保证只有真正热的代码才会被编译,而那种偶尔才被调用一次的方法则不会因为运行时间长而积累起虚假的热度。

不建议关闭这个选项,除非它在具体案例中造成了问题。

Counter decaying是伴随GC运行而执行的。过多的手动调用System.gc()倒是有可能会干扰了这个衰减,导致方法达不到触发JIT编译的热度。

回边计数器

image

回边计数器阈值计算公式

(1)Client模式下
方法调用计数器阈值(CompileThreshold)* OSR比率(OnStackReplacePercentage)/100.
其中OnStackReplacePercentage默认值为933,如果都取默认值,那Client模式虚拟机回边数的阈值为13995。

(2)Server模式下
方法调用计数器阈值(CompileThreshold)*(OSR比率(OnStackReplacePercentage)减去解释器监控比率(InterpreterProfilePercentage)的差值)/100。

其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,
如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700

1
2
-XX:BackEdgeThreshold
-XX:OnStackReplacePercentage

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数

学习JIT意义

大方法 与 小方法?

java中一般建议一个方法不要写的过长,不方便维护和阅读是其中的一个原因,但是其真正性能的原因大家知道吗?

我们知道,JVM一开始是以解释方式执行字节码的。当这段代码被执行的次数足够多以后,它会被动态优化并编译成机器码执行,执行速度会大大加快,这就是所谓的JIT编译。
hotsopt源码中有一句

1
if (DontCompileHugeMethods && m->code_size() > HugeMethodLimit) return false;

当DontCompileHugeMethods=true且代码长度大于HugeMethodLimit时,方法不会被编译

DontCompileHugeMethods与HugeMethodLimit的值在globals.hpp中定义:

1
2
3
4
product(bool, DontCompileHugeMethods, true,
"don't compile methods > HugeMethodLimit")
develop(intx, HugeMethodLimit, 8000,
"don't compile methods larger than this if +DontCompileHugeMethods")

上面两个参数说明了Hotspot对字节码超过8000字节的大方法有JIT编译限制,这就是大方法不会被JIT编译的原因。由于使用的是product mode的JRE,

我们只能尝试关闭DontCompileHugeMethods,即增加VM参数”-XX:-DontCompileHugeMethods”来强迫JVM编译大方法。

但是不建议这么做,因为一旦CodeCache满了,HotSpot会停止所有后续的编译任务,虽然已编译的代码不受影响,但是后面的所有方法都会强制停留在纯解释模式。

查看jit工作的参数

1
2
3
4
5
6
7
8
9
10
-XX:-CITime 打印发费在JIT编译上的时间

$ java -server -XX:+CITime Benchmark
[...]
Accumulated compiler times (for compiled methods only)
------------------------------------------------
Total compilation time : 0.178 s
Standard compilation : 0.129 s, Average : 0.004
On stack replacement : 0.049 s, Average : 0.024
[...]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-XX:+PrintCompilation 我们可以简单的输出一些关于从字节码转化成本地代码的编译过程

$ java -server -XX:+PrintCompilation Benchmark
1 java.lang.String::hashCode (64 bytes)
2 java.lang.AbstractStringBuilder::stringSizeOfInt (21 bytes)
3 java.lang.Integer::getChars (131 bytes)
4 java.lang.Object::<init> (1 bytes)
--- n java.lang.System::arraycopy (static)
5 java.util.HashMap::indexFor (6 bytes)
6 java.lang.Math::min (11 bytes)
7 java.lang.String::getChars (66 bytes)
8 java.lang.AbstractStringBuilder::append (60 bytes)
9 java.lang.String::<init> (72 bytes)
10 java.util.Arrays::copyOfRange (63 bytes)
11 java.lang.StringBuilder::append (8 bytes)
12 java.lang.AbstractStringBuilder::<init> (12 bytes)
13 java.lang.StringBuilder::toString (17 bytes)
14 java.lang.StringBuilder::<init> (18 bytes)
15 java.lang.StringBuilder::append (8 bytes)
[...]
29 java.util.regex.Matcher::reset (83 bytes)

每当一个方法被编译,就输出一行-XX:+PrintCompilation。每行都包含顺序号(唯一的编译任务ID)和已编译方法的名称和大小。

因此,顺序号1,代表编译String类中的hashCode方法到原生代码的信息。根据方法的类型和编译任务打印额外的信息。例如,本地的包装方法前方会有”n”参数,像上面的System::arraycopy一样。注意这样的方法不会包含顺序号和方法占用的大小,因为它不需要编译为本地代码。

同样可以看到被重复编译的方法,例如StringBuilder::append顺序号为11和15。输出在顺序号29时停止 ,这表明在这个Java应用运行时总共需要编译29个方法。

朱兴生 wechat
欢迎大家关注:码农戏码微信公众号