Java最初都是通过解释器解释执行,随后引入编译器将频繁调用的代码编译成本地代码
《Java虚拟机规范》中并未规定编译器是JVM必需的组成部分,接下来依据HotSpot中的实现讲解
字节码转为本地机器码称为后端编译,分为
即时编译器(JIT,Just In Time Compiler),如HotSpot的C1、C2、Graal
提前编译器(AOT,Ahead Of Time Compiler),如JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET
即时编译器在运行时将热点代码编译成本地机器码并优化,分为
即时编译器编译本地代码需要占用程序运行时间,且优化程度越高所需时间越长,想要编译出优化程度高的代码,还需让解释器收集性监控信息,同时制约了解释执行速度
为了达到平衡,JDK7引入分层编译
以上层次并非固定不变,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次编译之间的交互、转换关系如下
即时编译器的编译对象是热点代码,包括
上述两种情况,编译的目标对象都是方法体,而不是循环体,当热点代码的编译发生在方法执行的过程中,称为栈上替换(On Stack Replacement,OSR)
确定热点代码的行为称为热点探测(Hot Spot Code Detection),分为
HotSpot为每个方法建立了方法调用计数器和回边计数器
对于方法调用计数器
对于回边计数器
即时编译在后台的编译线程中进行
Client的编译过程如下
Service编译过程类似Client,但提供更多的优化
对于如下程序
public class Test {
public static final int NUM = 15000;
public static int doubleValue(int i) {
for (int j = 0; j < 100000; j++) ; //空循环用于演示即时编译优化
return i * 2;
}
public static long calcSum() {
long sum = 0;
for (int i = 1; i <= 100; i++) {
sum += doubleValue(i);
}
return sum;
}
public static void main(String[] args) {
for (int i = 0; i < NUM; i++) {
calcSum();
}
}
}
若想在即时编译时打印被编译成本地代码的方法名,可使用
-XX:+PrintCompilation
输出如下,带有%的输出说明是由回边计数器触发的栈上替换编译,可看到calcSum()和doubleValue()被编译
242 110 % 3 Test::doubleValue @ 5 (18 bytes)
242 111 3 Test::doubleValue (18 bytes)
242 112 % 4 Test::doubleValue @ 5 (18 bytes)
242 110 % 3 Test::doubleValue @ -2 (18 bytes) made not entrant
242 113 4 Test::doubleValue (18 bytes)
243 111 3 Test::doubleValue (18 bytes) made not entrant
243 114 3 Test::calcSum (26 bytes)
243 115 % 4 Test::calcSum @ 7 (26 bytes)
244 116 4 Test::calcSum (26 bytes)
245 114 3 Test::calcSum (26 bytes) made not entrant
若想要输出方法内联信息,可使用
-XX:+PrintCompilation (-XX:+UnlockDiagnosticVMOptions) -XX:+PrintInlining
输出如下,可看到doubleValue()内联到calcSum(),JVM下一次执行calcSum()时,doubleValue()不会被实际调用
74 25 % 3 Test::doubleValue @ 5 (18 bytes)
75 26 3 Test::doubleValue (18 bytes)
75 27 % 4 Test::doubleValue @ 5 (18 bytes)
75 25 % 3 Test::doubleValue @ -2 (18 bytes) made not entrant
75 28 4 Test::doubleValue (18 bytes)
75 26 3 Test::doubleValue (18 bytes) made not entrant
76 29 3 Test::calcSum (26 bytes)
@ 9 Test::doubleValue (18 bytes) inlining prohibited by policy
76 30 % 4 Test::calcSum @ 7 (26 bytes)
@ 9 Test::doubleValue (18 bytes) inline (hot)
77 31 4 Test::calcSum (26 bytes)
@ 9 Test::doubleValue (18 bytes) inline (hot)
78 29 3 Test::calcSum (26 bytes) made not entrant
若要进一步查看生成的中间代码表示,可使用
-XX:+PrintOptoAssembly //用于Service
-XX:+PrintLIR //用于Client
如下为calcSum()部分伪汇编代码
000 N221: # B1 <- BLOCK HEAD IS JUNK Freq: 1
000 # breakpoint
nop # 11 bytes pad for loops and calls
010 B1: # B12 B2 <- BLOCK HEAD IS JUNK Freq: 1
010 # stack bang (112 bytes)
pushq rbp # Save rbp
subq rsp, #32 # Create frame
01c movl RBP, [RSI] # int
01e movq RBX, [RSI + #8 (8-bit)] # long
022 movq RDI, RSI # spill
025 call_leaf,runtime OSR_migration_end
No JVM State Info
#
032 cmpl RBP, #100
035 jg B12 P=0.009901 C=100498.000000
想再进一步跟踪本地代码生成过程,可使用
-XX:+PrintCFGToFile //用于Client
-XX:PrintIdealGraphFile //用于Service
将编译过程中的数据输出到文件中,可使用如下工具分析
Service编译器的中间代码称为理想图,如下使用
-XX:PrintIdealGraphLevel=2 -XX:PrintIdealGraphFile=ideal.xml
生成一个包含编译代码过程信息的ideal.xml文件,用Ideal Graph Visualizer打开,如下左侧为编译过的方法列表和优化过程,右侧为理想图,节点表示程序的元素,边表示数据或控制流
对于doubleValue(),若忽略语言安全检查的基本块,可简化为
而for (int j = 0; j < 100000; j++) ;空循环路径流程对应下图
Outline右击Difference to current graph(或拖动小圆圈),软件自动分析两阶段理想图的差异,若被消除则为红色
可看到在After matching阶段,空循环已被优化掉了,而到Final Code阶段,许多语言安全保障措施和GC安全点的轮询操作也被一起消除了
提前编译的主要有两大分支
分别解决以下问题
但提前编译仍无法取代即时编译的以下优势
JDK9 引入Jaotc,用于对Class文件和模块提前编译,以减少程序的启动时间和到达全速性能的预热时间,如对于以下程序
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
先编译为class,再生成so
javac HelloWorld.java
jaotc --output libHelloWorld.so HelloWorld.class
通过如下方式调用
java -XX:AOTLibrary=./libHelloWorld.so HelloWorld
下面演示用Jaotc编译java.base模块,有些方法还不支持提前编译,新建文件java.base-list.txt将其排除
# jaotc: java.lang.StackOverflowError
exclude sun.util.resources.LocaleNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources.TimeZoneNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources.cldr.LocaleNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources..*.LocaleNames_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.LocaleNames_.*_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.TimeZoneNames_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.TimeZoneNames_.*_.*.getContents\(\)\[\[Ljava/lang/Object;
# java.lang.Error: Trampoline must not be defined by the bootstrap classloader
exclude sun.reflect.misc.Trampoline.()V
exclude sun.reflect.misc.Trampoline.invoke(Ljava/lang/reflect/Method;Ljava/lang/Object;[Ljava/lang/Object;)Ljava/# JVM asserts
exclude com.sun.crypto.provider.AESWrapCipher.engineUnwrap([BLjava/lang/String;I)Ljava/security/Key;
exclude sun.security.ssl.*
exclude sun.net.RegisteredDomain.()V
# Huge methods
exclude jdk.internal.module.SystemModules.descriptors()[Ljava/lang/module/ModuleDescriptor;
使用如下命令编译,-J后接JVM参数
jaotc -J-XX:+UseCompressedOops -J-XX:+UseG1GC -J-Xmx4g --compile-for-tiered --info --compile-commands java.base-list.txt --output libjava.base-coop.so --module java.base
接下来即可使用提前编译版本的java.base模块来运行Java程序
可使用如下参数,确认哪些方法使用了提前编译的版本
-XX:+PrintAOT
若不使用libjava.base-coop.so,就只有HelloWord的构造函数和main()方法是提前编译,加上后使用的API都是提前编译好的
代码优化建立在代码的中间表示或者是机器码之上,如下为方便讲解,使用Java演示
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
...
z = b.get();
sum = y + z;
}
第一步进行方法内联
public void foo() {
y = b.value;
...
z = b.value;
sum = y + z;
}
第二步进行冗余访问消除
public void foo() {
y = b.value;
..
z = y;
sum = y + z;
}
第三步进行复写传播,程序没必要使用额外的变量z,因为和y相等,所以用y代替z
public void foo() {
y = b.value;
...
y = y;
sum = y + y;
}
第四步进行无用代码消除,无用代码是不会被执行或没有意义的代码,如上面的y=y
public void foo() {
y = b.value;
...
sum = y + y;
}
优化代码和原代码效果一致,但省略了许多语句,转换的字节码和机器码更少,效率更高
只有以下方法会在编译期进行解析
其他Java方法调用都必须在运行时进行多态选择,可能存在多个版本的方法调用
即Java语言中默认的实例方法都是虚方法,故编译器无法静态内联确定方法版本
为此,JVM引入了类型继承关系分析,用于确定已加载的类或接口是否有多个实现、是否存在子类、子类是否覆盖了父类的虚方法等信息
用于分析对象动态作用域,当对象定义在方法里
对于不会线程逃逸的对象,可采取栈上分配和同步消除
对于不会方法逃逸的对象,可采取标量替换
如下模拟逃逸分析过程,Point是包含x和y坐标的对象
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
第一步,将构造函数和getX()内联
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法
p.x = xx;
p.y = 42
return p.x;
}
第二步,经过逃逸分析,对象Point不会发生逃逸,进行标量替换
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42
return px;
}
第三步,通过数据流分析,进行无用代码消除xx、px、py
public int test(int x) {
return x + 2;
}
若表达式E已经被计算过,且E中变量的值未改变,则下次使用时无需再次计算,可直接用之前的结果代替
int d = (c * b) * 12 + a + (a + b * c);
若存在如上代码,计算期间b与c的值不变,可转为
int d = E * 12 + a + (a + E);
在此基础进一步进行代数化简
int d = E * 13 + a + a;
对于数组arr[i],在Java中访问会自动进行范围检查,若 i 不在 [0, arr.length)范围内,则抛出ArrayIndexOutOfBoundsException
数组检查可避免溢出,但若每次读写时都进行判断会在一定程度上影响性能,若能分析出数组下标不会越界,则可以消除检查