JVM即时编译

在讨论java类编译加载执行过程时,我们提到了编译这个概念。一说到编译,我们一定会想到将.java文件编译成.class文件的过程,这个编译我们一般称为前端编译。Java的编译和运行过程非常复杂,除了前端编译外,还有运行时编译。

一、什么是即时编译

即时编译,也被称为运行时编译。由于操作系统无法直接运行java生成的字节码,因此,在运行时,JIT或解释器会将字节码转换成机器码,这个转换过程就叫运行时编译。

类文件在运行时会被进一步编译,它们可以变成高度优化的机器代码。由于C/C++编译器的所有优化都是在编译期间完成的,运行期间以性能监控为基础的优化措施则无法进行,例如,调用频率预测、分支频率预测、裁剪未被选择的分支等;而Java在运行时的再次编译,就可以进行基础的优化操作。

因此,JIT编译器可以说是JVM中运行时编译最重要的部分之一。

二、即时编译器的类型

在HotSpot虚拟机中,内置了两个JIT,分别为C1编译器和C2编译器,这两个编译器的编译过程是不一样的。

  • C1编译器,是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI应用对界面启动速度就有一定的要求。

  • C2编译器,是为长期运行的服务器应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。

根据各自的适配性,这两种即时编译分别被称为Client Compiler和Server Compiler。

2.1 、- Java7

在Java7之前,需要根据程序的特性选择对应的JIT,虚拟机默认采用解释器和其中一个编译器配合工作。

2.2、Java7

在Java7中,引入了分层编译,这种编译方式综合了C1的启动性能优势和C2的峰值性能优势,我们可以通过参数"-client"和"-server"强制指定虚拟机的即时编译模式。分层编译将JVM的执行状态分为了5个层次:

  • 第0层,程序解释执行,默认开启性能监控功能(Profiling)。如果不开启,可触发第二层编译;
  • 第1层,可称为C1编译,不开启Profiling,将字节码编译为本地代码,进行简单、可靠的优化;
  • 第2层,也称为C1编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数的C1编译;
  • 第3层,也称为C1编译,执行所有带Profiling的C1编译;
  • 第4层,可称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

2.3、Java8

在Java8中,默认开启分层编译,-client和-server的设置是无效的。如果只想开启C2,可以使用参数-XX:-TieredCompilation关闭分层编译;如果只想开启C1,可以在打开分层编译的同时,使用参数-XX:TieredStopAtLevel=1。

除了这种默认的混合编译模式外,我们还可以使用“-Xint”参数强制虚拟机运行于只有解析器的编译模式下,这时JIT完全不介入工作;此外,我们还可以使用参数“-Xcomp”强制虚拟机运行于只有JIT的编译模式下。

通过在命令行执行java -version命令,可以查看到当前系统使用的编译模式,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
kubxy@server:~$ java -version
java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)
kubxy@server:~$ java -Xint -version
java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, interpreted mode)
kubxy@server:~$ java -Xcomp -version
java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, compiled mode)

三、热点探测

在HotSpot虚拟机中,热点探测是JIT优化的前提。

热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器,来统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。

虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过了阈值,就会触发JIT编译。

  • 方法调用计数器,用于统计方法被调用的次数。方法调用计数器的默认阈值在C1模式下是1500次,在C2模式下是10000次,可通过-XX:CompileThreshold参数来设定;而在分层编译的情况下,-XX:CompileThreshold制定的阈值将失效,此时将会根据当前编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发JIT编译。
  • 回边计数器,用于统计一个方法中循环体代码执行的次数。在字节码中遇到控制流向后跳转的指令称为“回边”,该值用于计算是否触发C1编译的阈值,在不开启分层编译的情况下,C1默认为13995,C2默认为10700,可通过-XX:OnStackReplacePercentage=N来设置;而在分层编译的情况下,-XX:OnStackReplacePercentage指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。

建立回边计数器的主要目的是为了触发OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码中,当循环次数达到回边计数器阈值时,JVM会认为这段是热点代码,JIT编译器就会将这段代码编译成机器语言并缓存,在该循环时间内,会直接将执行代码替换,执行缓存的机器语言。

四、编译优化技术

JIT编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。下面我们主要讨论两种优化手段。

5.1、 方法内联

当我们调用一个方法时,通常要经历入栈和出栈。调用方法就是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置。

这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。因此,方法调用会产生一定的时间和空间方面的开销。

对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大。方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。例如以下方法:

1
2
3
4
5
6
7
private int add1(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}

private int add2(int x1, int x2) {
return x1 + x2;
}

最终会被优化为:

1
2
3
private int add1(int1 x1, int x2, int x3, int x4) {
return x1 + x2 + x3 + x4;
}

JVM会自动识别热点方法,并对它们使用方法内联进行优化。我们可以通过-XX:CompileThreshold来设置热点方法的阈值。但要强调一点,热点方法不一定会被JVM做内联优化,如果这个方法体太大了,JVM将不执行内联操作。而方法体的大小阈值,我们也可以通过参数来优化:

  • 经常执行的方法,默认情况下,方法大小小于325字节的都会进行内联,我们可以通过-XX:MaxFreqInlineSize=N来设置大小值;
  • 不是经常执行的方法,默认情况下,方法大小小于35字节才会进行内联,我们也可以通过-XX:MaxInlinxSize=N来重置大小值。

之后我们就可以通过配置JVM参数来查看到方法被内联的情况:

1
2
3
-XX:+PrintCompilation  //在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
-XX:+PrintInlining //将内联方法打印出来

当我们设置VM参数:-XX:PrintCompilation -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining之后,运行以下代码:

1
2
3
4
5
public static void main(String[] args) {
for(int i=0; i<1000000; i++) { //方法调用计数器的默认阈值在C1模式下是1500次,在C2模式下是10000次,我们循环遍历的次数需要超过阈值
add1(1,2,3,4)
}
}

我们可以看到运行结果中,显示了方法内联的日志:

热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:

  • 通过设置JVM参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
  • 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
  • 尽量使用final、private、static关键字修饰方法,编码方法因为继承,会需要额外的类型检查。

5.2、逃逸分析

逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。

5.2.1、栈上分配

我们知道,在Java中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对于分配在栈中的对象的创建和销毁来说,更消耗时间和性能。这个时候,逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上。

但是,因为HotSpot虚拟机目前的实现导致栈上分配实现比较复杂,可以说,在HotSpot中暂时没有实现这项优化。随着即时编译器的发展与逃逸分析技术的成熟,相信不久的将来HotSpot也会实现这项优化功能。

5.2.2、锁消除

在非线程安全的情况下,尽量不要使用线程安全容器,比如StringBuffer。由于StringBuffer中的append方法被Synchronized关键字修饰,会使用到锁,从而导致性能下降。

但实际上,在以下代码测试中,StringBuffer和StringBuilder的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候JIT编译会对这个对象的方法锁进行锁消除。

1
2
3
4
5
6
 public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

5.5.3、标量替换

逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换。

我们用以下代码验证:

1
2
3
4
5
6
public void foo() {
TestInfo info = new TestInfo();
info.id = 1;
info.count = 99;
...//to do something
}

逃逸分析后,代码会被优化为:

1
2
3
4
5
public void foo() {
id = 1;
count = 99;
...//to do something
}

我们可以通过设置 JVM 参数来开关逃逸分析,还可以单独开关同步消除和标量替换,在 JDK1.8 中 JVM 是默认开启这些操作的。

1
2
3
4
5
6
7
8
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启,其它版本未测试)
-XX:-DoEscapeAnalysis 关闭逃逸分析

-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateLocks 关闭锁消除

-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateAllocations 关闭就可以了

六、小结

在本篇文章中,我们主要讨论了JVM的即时编译,我们要知道的是:

  • 即时编译,就是将java字节码编译成机器可识别的机器码

  • 在HotSpot虚拟机中,主要有两种编译器:C1和C2

  • 热点探测技术是JIT优化的前提。

  • JIT优化的主要方法为:方法内联和逃逸分析


JVM即时编译
https://kuberxy.github.io/2020/09/20/JVM即时编译/
作者
Mr.x
发布于
2020年9月20日
许可协议