导读
编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序,例如C++,Golang等常见的编译型语言,都是在程序运行前将代码生成为机器码,然后运行在目标机器上,不过编译的时候要针对目标机器的CPU分别进行编译。
Java具有跨平台性“一次编译,到处运行”的能力,它把编译的过程进行拆解,先把.java文件编译成JVM可识别的.Class字节码,然后再由解释器逐条将字节码解释为机器码运行,这种解释型语言的程序好处就是可以移植到任何有适当解释器的机器上,但是它的运行速度是远不及编译语言。
在本文将重点介绍Java(HotSpot JVM)的即时编译,从而了解JVM是如何通过即时编译提升运行效率以及常用的调优手段
编译器类别
在java中编译器主要分为三类:
编译器执行过程
即时编译器(JIT)
即时(Just In Time) 编译器是Java虚拟机的核心,对JVM性能影响最大的莫过于编译器,如何选择编译器以及如何调优编译器则是需要了解和掌握的
解释器和编译器
从上图可以看到JVM执行代码时并不会立即将字节码编译为机器码,因为解释器与编译器两者各有优势: 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。
对于程序来说通常只有一部分代码被经常执行,应用的性能也取决于这些代码执行的速度,这些关键代码被称作为“热点代码”,当探测命中阈值后,这些热点代码才会被编译成机器码,以此来提升运行效率,HotSpot JVM的名字即来自于这里。
即时编译器类型
JVM提供两种编译器类型,分别为客户端编译器和服务端编译器。这两种编译器通常被称为client和server,两种编译器的主要区别在于编译代码的时机不同。client编译器开启编译比server编译器要早,意味着在代码执行的开始阶段(服务启动阶段)client编译器比server编译器要快,但server编译器在编译代码时可以更好的进行优化,server编译器生成的代码要比client代码快。
客户端编译器(Client Compiler)
客户端编译是一个相对简单快速的编译器,主要的关注点在于局部的优化,而放弃了许多耗时的全局优化手段。
服务端编译器(Server Compiler)
服务端编译器通常也称为C2 编译器,server 编译器,以server编译器模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升。原因是:当虚拟机运行在-client模式时,使用的是一个代号为C1的轻量级编译器,而-server模式启动的虚拟机采用相对重量级代号为C2的编译器。C2比C1编译器编译的相对彻底,服务起来之后,性能更高。
分层编译器(Tiered Compiler)
在 Java 7 以前,我们需要根据程序的特性选择对应的即时编译器。对于执行时间较短的,
或者对启动性能有要求的程序,我们采用编译效率较快的 C1,对应参数 -client。
对于执行时间较长的,或者对峰值性能有要求的程序,我们采用生成代码执行效率较快的
C2,对应参数 -server。
为了减少开发人员和部署运维人员的心智负担,jvm迭代升级在Java 7 引入了分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了 C1 的启动性能优势和 C2 的峰值性能优势。在启动时使用C1编译器,随着热点探测将热点代码使用server编译进行优化,这种技术就叫做分层编译。
Java 8 默认开启了分层编译。不管是开启还是关闭分层编译,原本用来选择即时编译器的
参数 -client 和 -server 都是无效的。当关闭分层编译的情况下,Java 虚拟机将直接采用
C2。
分层编译将 Java 虚拟机的执行状态分为了五个层次。“C1 代码”指代由 C1 生成的机器码,“C2 代码”指代由 C2 生成的机器码。五个层级分别是:
- 解释执行;
- 执行不带 profiling 的 C1 代码;
- 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
- 执行带所有 profiling 的 C1 代码;
- 执行 C2 代码。
通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。然而,对于 C1 代码的三
种状态,按执行效率从高至低则是 1 层 > 2 层 > 3 层。
其中 1 层的性能比 2 层的稍微高一些,而 2 层的性能又比 3 层高出 30%。这是因为
profiling 越多,其额外的性能开销越大。
编译路径:
即时编译的触发
热点代码
上面介绍了即时编译只会对热点代码进行编译,热点代码主要分为两类
前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代 码”是理所当然的。而后者则是为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存 在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”
触发阈值
在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时(使用 C1 时,该值为 1500;使用 C2 时,该
值为 10000),便会触发即时编译。
当启用分层编译时,Java 虚拟机将不再采用由参数 -XX:CompileThreshold 指定的阈值
(该参数失效),而是使用另一套阈值系统。在这套系统中,阈值的大小是动态调整的。
在比较阈值时,Java 虚拟机会将阈值与某个系数 s 相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。
系数的计算方法为:
s = queue_size_X / (TierXLoadFeedback * compiler_count_X) + 1
其中 X 是执行层次,可取 3 或者 4;
queue_size_X 是执行层次为 X 的待编译方法的数目;
TierXLoadFeedback 是预设好的参数,其中 Tier3LoadFeedback 为 5,Tier4LoadFeedback 为 3;
compiler_count_X 是层次 X 的编译线程数目。
编译器优化技术
上面介绍了即时编译的类型,过程以及触发编译的时机,本章节将介绍即时编译是怎样将字节码优化成高质量的机器码。
在OpenJDK的官方Wiki上,HotSpot虚拟机设计团队列出了一个相对比较全面的、即时编译器中采用的优化技术列表。
优化的技术点很多,我们重点关注下面几项优化技术:
方法内联
方法内联的主要目的有两个:一是去除方法调用的成本(如查找方法版本、建立栈帧等); 二是为其他优化建立良好的基础。方法内联膨胀之后可以便于在更大范围上进行后续的优化手段,可以获取更好的优化效果。因此各种编译器一般都会把内联优化放在优化序列最靠前的位置
优化前的原始代码:
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
内联后的代码:
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
逃逸分析
逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
逃逸状态:
优化手段:
使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析支持之后,用户可以使用参数 - XX:+EliminateAllocations 来开启标量替换,使用 +XX:+EliminateLocks来开启同步消除 , 使用参数 -XX:+PrintEliminateAllocations 查看标量的替换情况
公共子表达式消除
如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称 为全局公共子表达式消除(Global Common Subexpression Elimination)
假设存在如下代码:
int d = (c * b) * 12 + a + (a + b * c);
如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码将完全遵照Java源码的写法直译而成的。
当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到cb与bc是一样的表达式,而且在计算期间b与c的值是不变的。
优化后的代码:
int d = E * 12 + a + (a + E);
即时编译实战
此章节将介绍如何查看以及分析编译结果
测试代码:
package com.example.didilog;
public class Compiler {
public static final int NUM = 15000;
public static int doubleValue(int i) {
// 这个空循环用于后面演示JIT代码优化过程
for (int j = 0; j < 100000; j++) ;
return i * 2;
}
public static long calcSum() {
long sum = 0;
for (int i = 1; i