JIT优化技术

我们知道,想要把高级语言转变成计算机认识的机器语言有两种方式,分别是编译和解释,虽然Java转成机器语言的过程中有一个步骤是要编译成字节码,但是,这里的字节码并不能在机器上直接执行。

所以,JVM中内置了解释器(interpreter),在运行时对字节码进行解释翻译成机器码,然后再执行。

解释器的执行方式是一边翻译,一边执行,因此执行效率很低。为了解决这样的低效问题,HotSpot引入了JIT技术(Just-In-Time)。

有了JIT技术之后,JVM还是通过解释器进行解释执行。但是,当JVM发现某个方法或代码块运行时执行的特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

asdwdafa

扩展知识

HotSpot虚拟机中内置了两个JIT编译器:Client ComplierServer Complier,分别用在客户端和服务端,目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。

当 JVM 执行代码时,它并不立即开始编译代码。首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译这段代码并执行代码来说,要快很多。第二个原因是最优化,当 JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化。

在机器上,执行java -version命令就可以看到自己安装的JDK中JIT是哪种模式:

上图是我的机器上安装的jdk1.8,可以看到,他是Server Compile,但是,需要说明的是,无论是Client Complier还是Server Complier,解释器与编译器的搭配使用方式都是混合模式,即上图中的mixed mode。

热点检测

上面我们说过,要想触发JIT,首先需要识别出热点代码。目前主要的热点代码识别方式是热点探测(Hot Spot Detection),有以下两种:

  1. 基于采样的方式探测(Sample Based Hot Spot Detection) :
    周期性检测各个线程的栈顶,发现某个方法经常出现在栈顶,就认为是热点方法。好处就是简单,
    缺点就是无法精确确认一个方法的热度。容易受线程阻塞或别的原因干扰热点探测。

  2. 基于计数器的热点探测(Counter Based Hot Spot Detection)。
    采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,某个方法超过阀值就认为是热点方法,触发JIT编译。

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。

  • 方法计数器。顾名思义,就是记录一个方法被调用次数的计数器。

  • 回边计数器。是记录方法中的for或者while的运行次数的计数器。

编译优化

前面提到过,JIT除了具有缓存的功能外,还会对代码做各种优化。说到这里,不得不佩服HotSpot的开发者,他们在JIT中对于代码优化真的算是面面俱到了。

主要的优化有:

逃逸分析、 锁消除、 锁膨胀、 方法内联、 空值检查消除、 类型检测消除、 公共子表达式消除

逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

例如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb; // 动态作用域发生改变
}

public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString(); // 返回string StringBuffer动态作用域结束
}

第一段代码中的StringBuffer就逃逸了,而第二段代码中的StringBuffer就没有逃逸。

使用逃逸分析,编译器可以对代码做如下优化:

  1. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

  2. 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析,

1
2
3
4
-XX:+DoEscapeAnalysis: 表示开启逃逸分析

-XX:-DoEscapeAnalysis : 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,
如需关闭,需要指定-XX:-DoEscapeAnalysis

同步省略

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。

如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这个取消同步的过程就叫同步省略,也叫锁消除

如以下代码:

1
2
3
4
5
6
private void process(int i, int j) {
Object lock = new Object();
synchronized (lock){
System.out.println("panther");
}
}

代码中对lock这个对象进行加锁,但是lock对象的生命周期只在process()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。优化成:

1
2
3
4
private void process(int i, int j) {
Object lock = new Object();
System.out.println("panther");
}

所以,在使用synchronized的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。

标量替换&栈上分配

JIT优化可能带来的问题

大家理解了JIT编译的原理之后,其实可以知道,JIT优化是在运行期进行的,并且也不是Java进程刚一启动就能优化的,是需要先执行一段时间的,因为他需要先知道哪些是热点代码。

所以,在JIT优化开始之前,我们的所有请求,都是要经过解释执行的,这个过程就会相对慢一些。

而且,如果你们的应用的请求量比较大的的话,这种问题就会更加明显,在应用启动过程中,会有大量的请求过来,这就会导致解释器持续的在努力工作。

一旦解释器对CPU资源占用比较大的话,就会间接的导致CPU、LOAD等飙高,导致应用的性能进一步下降。这也是为什么很多应用在发布过程中,会出现刚刚重启好的应用会发生大量的超时问题了。

而随着请求的不断增多,JIT优化就会被触发,这就是使得后续的热点请求的执行可能就不需要在通过解释执行了,直接运行JIT优化后缓存的机器码就行了。

如何解决

主要有两种思路:

  1. 提升JIT优化的效率

  2. 降低瞬时请求量

很多人都听说过缓存预热,其实思想是类似的。

就是说在应用刚刚启动的时候,通过调节负载均衡,不要很快的把大流量分发给他,而是先分给他一小部分流量,通过这部分流量来触发JIT优化,等优化好了之后,再把流量调大。