21 深入jVM即时编译器JIT,优化Java编译

290 阅读11分钟

大家好,我是小水珠。

说到编译,我猜你一定会想到.java文件被编译成.class文件的过程,这个编译我们一般称为前端编译。Java的编译和运行过程非常复杂,除了前端编译,还有运行时编译。由于机器无法直接运行Java生成的字节码,所以在运行时,JIT或解释器会将字节码转换成机器码,这个过程就叫运行时编译。

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

一 类编译加载执行过程

在这之前,我们先了解下Java从编译到运行的整个过程,为后面的学习打下基础。请看下图:

21-类加载执行.jpg

1.类编译

下面我们通过javap反编译来看看一个class文件结构中主要包含了哪些信息:

21-反编译.png

2.类加载

当一个类被创建实例或者被其它对象引用时,虚拟机在没有加载过该类的情况下,会通过类加载器将字节码文件加载到内存中。

3.类连接

类在加载进来之后,会进行连接,初始化,最后次才会被使用。在连接过程中,又包括验证,准备和解析三部分。

4.类初始化

类初始化时类加载过程的最后阶段,在这个阶段,JVM首先将执行构造器方法,编译器会在将.java文件编译成.class文件时,收集所有类初始化代码,包括静态变量赋值语句,静态代码块,静态方法,收集在一起成为clinit方法。

初始化类的静态变量和静态代码块为用户自定义的值,初始化的顺序和Java源码从上到下的顺序一致。例如:

微信图片_20220820181327.jpg

此时运行结果为:

0

再来看看以下代码:

微信图片_202208201813271.jpg

此时运行结果为:

1

子类初始化时首先会调用父类的clinit方法,再执行子类的clinit方法,运行以下代码:

微信图片_202208201813272.jpg

运行结果为:

微信图片_202208201813273.jpg

二 即时编译

初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译。

1.即时编译类型

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

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

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

  • 第0层:程序解释执行,默认开启性能监控功能,如果不开启,可触发第二层编译;

  • 第1层:可称为C1编译,将字节码编译为本地代码,进行简单,可靠的优化,不开启性能监控功能。

  • 第2层:也称为C1编译,开启性能监控功能,仅执行带方法调用次数和循环回边执行次数性能监控功能的C1编译;

  • 第3层:也称为C1编译,执行所有带性能监控功能的C1编译;

  • 第4层:可称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

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

通过java -version 命令可以直接查看到当前系统使用的编译模式,如下图:

21-编译模式.jpg

三 热点探测

在HotSpot虚拟机下中的热点探测是JIT优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法。

1.方法调用计数器

用于统计方法被调用的次数,方法调用计数器的默认阈值在C1模式下是1500次,在C2模式下是10000次,可通过-XX:CompileThreshold来设定;而在分层编译的情况下,-XX:CompileThreshold制定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发JIT编译器。

2.回边计数器

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

四 编译优化技术

1.方法内联

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

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

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

例如以下代码:

微信图片_202208201813274.jpg

最终会被优化为:

微信图片_202208201813275.jpg

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

  • 经常执行的方法,默认情况下,方法体大小小于325字节的都会进行内联,我们可以通过—XX:MaxFreqInlineSize=N来设置大小值;

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

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

  • 通过设置JVM参数来减小热点阈值或增加方法体的阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多的内存;

  • 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;

  • 尽量使用final,private,static关键字修饰方法,编码方法因为继承,会需要额外的类型检查。

2.逃逸分析

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

  • 栈上分配

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

以下是通过循环获取学生年龄的案例,方法中创建一个学生对象,我们现在通过案例来看看打开逃逸分析和关闭逃逸分析后,堆内存对象创建的数量对比。

微信图片_20220820183513.jpg

然而,运行结果并没有达到我们想要的优化效果,也许你怀疑是JDK版本的问题,我分别在1.6到1.8版本都测试过了,效果还是一样的:

21-栈上分配1.jpg

21-栈上分配2.jpg

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

  • 锁消除

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

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

微信图片_20220820183354.jpg

  • 标量替换

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

我们用以下代码验证:

微信图片_202208201833541.jpg

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

微信图片_202208201833542.jpg

五 总结

我们今天主要了解了JDK1.8以及之前的类的编译和加载过程,Java源程序是通过javac编译器编译成.class文件,其中文件中包含的代码格式我们称之为Java字节码。

这种代码格式无法直接运行,但可以被不同平台JVM中的Interpreter解释执行。由于Interpreter的效率低下,JVM中的JIT会在运行时有选择性地将运行次数较多的方法编译成二进制代码,直接运行在底层硬件上。

在Java8之前,HotSpot集成了两个JIT,用C1和C2来完成JVM中的即时编译。虽然JIT又花了代码,但收集监控信息会消耗运行时的性能,且编译过程会占用程序的运行时间。

到了Java9,AOT编译器被引入。和JIT不同,AOT是在程序运行前进行的静态编译,这样就可以避免运行时的编译消耗和内存消耗,且.class文件通过AOT编译器是可以编译成.so的二进制文件的。

到了Java10,一个新的JIT编译器Graal被引入。Graal是一个以Java为主要编程语言,面向Java bytecode的编译器。与用C++实现的C1和C2相比,它的模块化更加明显,也更容易维护。Graal既可以作为动态编译器,在运行时编译热点方法;也可以作为静态编译器,实现AOT编译。