Java是一种与平台无关的语言。程序在编译后被转换为字节码。这种字节码在运行时被转换为机器码。解释器模拟抽象机器的字节码指令在特定物理机器上的执行。及时编译(JIT)发生在执行过程中的某个时刻,而超前编译(AOT)发生在构建时间。
本文解释了解释器何时发挥作用以及JIT和AOT何时发生。我还讨论了JIT和AOT之间的权衡。
源代码、字节码、机器码
应用程序一般是用C、C++或Java等编程语言编写的。使用高级编程语言编写的指令集被称为源代码。源代码是人类可读的。为了在目标机器上执行它,源代码需要转换成机器代码,而机器代码是机器可读的。源代码通常由编译器转换为机器代码。
然而,在Java中,源代码首先被转换为一种叫做字节码的中间形式。这种字节码是独立于平台的,这就是为什么Java被称为独立于平台的编程语言。主要的Java编译器javac ,将Java源代码转换为字节码。然后,由解释器对字节码进行解释。
下面是一个小的Hello.java 程序。
//Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("Inside Hello World!");
}
}
使用javac 编译它,生成一个包含字节码的Hello.class 文件。
$ javac Hello.java
$ ls
Hello.class Hello.java
现在,使用javap 来反汇编Hello.class 文件的内容。javap 的输出取决于所使用的选项。如果你不选择任何选项,它就会打印出基本信息,包括这个类文件是从哪个源文件中编译出来的,包的名称,公共和保护字段,以及类的方法。
$ javap Hello.class
Compiled from "Hello.java"
public class Hello {
public Hello();
public static void main(java.lang.String[]);
}
要查看.class 文件中的字节码内容,请使用-c 选项。
$ javap -c Hello.class
Compiled from "Hello.java"
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Inside Hello World!
5: invokevirtual #4 // Method
java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
要获得更详细的信息,请使用-v 选项。
$ javap -v Hello.class
解释器、JIT、AOT
解释器负责模拟抽象机器的字节码指令在特定物理机器上的执行。当使用javac 编译源代码并使用java 命令执行时,解释器在运行时运行并达到其目的。
$ javac Hello.java
$ java Hello
Inside Hello World!
JIT编译器也在运行时运行。当解释器解释一个Java程序时,另一个被称为运行时剖析器的组件正在默默地监视程序的执行,以观察哪一部分代码被解释,以及解释了多少次。这些统计数据有助于检测程序的热点,也就是那些经常被解释的代码部分。一旦它们被解释的次数超过设定的阈值,它们就有资格被JIT编译器直接转换为机器代码。JIT编译器也被称为配置文件指导的编译器。字节码到本地代码的转换是在飞行中进行的,因此被称为及时性。JIT减少了解释器将同一组指令仿真为机器代码的开销。
AOT编译器在构建时编译代码。在构建时生成经常解释的和JIT编译的代码,可以改善Java虚拟机(JVM)的预热时间。这个编译器是在Java 9中作为实验性功能引入的。jaotc 工具使用Graal编译器,它本身是用Java编写的,用于AOT编译。
下面是一个Hello程序的用例。
//Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("Inside Hello World!");
}
}
$ javac Hello.java
$ jaotc --output libHello.so Hello.class
$ java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=./libHello.so Hello
Inside Hello World!
解释和编译何时发生作用:一个例子
这个例子说明了Java何时使用解释器,以及JIT和AOT何时介入。考虑一个简单的Java程序,Demo.java 。
//Demo.java
public class Demo {
public int square(int i) throws Exception {
return(i*i);
}
public static void main(String[] args) throws Exception {
for (int i = 1; i <= 10; i++) {
System.out.println("call " + Integer.valueOf(i));
long a = System.nanoTime();
Int r = new Demo().square(i);
System.out.println("Square(i) = " + r);
long b = System.nanoTime();
System.out.println("elapsed= " + (b-a));
System.out.println("--------------------------------");
}
}
}
这个简单的程序有一个main 方法,创建一个Demo 对象实例,并调用square 方法,显示for 循环迭代值的平方根。现在,编译并运行该代码。
$ javac Demo.java
$ java Demo
1 iteration
Square(i) = 1
Time taken= 8432439
--------------------------------
2 iteration
Square(i) = 4
Time taken= 54631
--------------------------------
.
.
.
--------------------------------
10 iteration
Square(i) = 100
Time taken= 66498
--------------------------------
现在的问题是,输出是解释器、JIT还是AOT的结果。在这种情况下,它完全是解释器的结果。我是怎么得出这个结论的?好吧,为了让JIT对编译做出贡献,代码的热点必须被解释到一个定义的阈值以上。然后,也只有在这个时候,这些代码才会被排队进行JIT编译。要找到JDK 11的阈值。
$ java -XX:+PrintFlagsFinal -version | grep CompileThreshold
intx CompileThreshold = 10000 {pd product} {default}
[...]
openjdk version "11.0.13" 2021-10-19
OpenJDK Runtime Environment 18.9 (build 11.0.13+8)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.13+8, mixed mode, sharing)
上面的输出显示,某段代码应该被解释10,000次才有资格进行JIT编译。这个阈值是否可以手动调整,是否有一些JVM标志表明某个方法是否被JIT编译了?是的,有多个选项可以达到这个目的。
了解一个方法是否被JIT编译的一个选项是-XX:+PrintCompilation 。除了这个选项外,标志-Xbatch ,以更可读的方式提供输出。如果解释和JIT都是平行进行的,-Xbatch 标志有助于区分两者的输出。使用这些标志的方法如下。
$ java -Xbatch -XX:+PrintCompilation Demo
34 1 b 3 java.util.concurrent.ConcurrentHashMap::tabAt (22 bytes)
35 2 n 0 jdk.internal.misc.Unsafe::getObjectVolatile (native)
35 3 b 3 java.lang.Object::<init> (1 bytes)
[...]
210 269 n 0 java.lang.reflect.Array::newArray (native) (static)
211 270 b 3 java.lang.String::substring (58 bytes)
[...]
--------------------------------
10 iteration
Square(i) = 100
Time taken= 50150
--------------------------------
上述命令的输出太长了,所以我把中间的部分截掉了。请注意,与Demo程序代码一起,JDK的内部类函数也在被编译。这就是为什么输出如此冗长。因为我的重点是Demo.java 代码,我将使用一个选项,可以通过排除内部包函数来尽量减少输出。命令 -XX:CompileCommandFile 禁用了内部类的JIT。
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler Demo
由-XX:CompileCommandFile 引用的文件hotspot_compiler 包含这段代码来排除特定的包。
$ cat hotspot_compiler
quiet
exclude java/* *
exclude jdk/* *
exclude sun/* *
在第一行中,quiet 指示JVM不要写任何关于被排除的类。要调整JIT的阈值,可以使用-XX:CompileThreshold ,数值设置为5,这意味着在解释五次之后,就是JIT的时间。
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler \
-XX:CompileThreshold=5 Demo
47 1 n 0 java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native)
(static)
47 2 n 0 java.lang.invoke.MethodHandle::invokeBasic(LLLLL)L (native)
47 3 n 0 java.lang.invoke.MethodHandle::linkToSpecial(LLLLLLL)L (native)
(static)
48 4 n 0 java.lang.invoke.MethodHandle::linkToStatic(L)I (native) (static)
48 5 n 0 java.lang.invoke.MethodHandle::invokeBasic()I (native)
48 6 n 0 java.lang.invoke.MethodHandle::linkToSpecial(LL)I (native)
(static)
[...]
1 iteration
69 40 n 0 java.lang.invoke.MethodHandle::linkToStatic(ILIIL)I (native)
(static)
[...]
Square(i) = 1
78 48 n 0 java.lang.invoke.MethodHandle::linkToStatic(ILIJL)I (native)
(static)
79 49 n 0 java.lang.invoke.MethodHandle::invokeBasic(ILIJ)I (native)
[...]
86 54 n 0 java.lang.invoke.MethodHandle::invokeBasic(J)L (native)
87 55 n 0 java.lang.invoke.MethodHandle::linkToSpecial(LJL)L (native)
(static)
Time taken= 8962738
--------------------------------
2 iteration
Square(i) = 4
Time taken= 26759
--------------------------------
10 iteration
Square(i) = 100
Time taken= 26492
--------------------------------
输出仍然与解释的输出没有区别!这是因为,按照Oracle的文档,-XX:CompileThreshold 标志只有在TieredCompilation 被禁用时才有效。
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler \
-XX:-TieredCompilation -XX:CompileThreshold=5 Demo
124 1 n java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native) (static)
127 2 n java.lang.invoke.MethodHandle::invokeBasic(LLLLL)L (native)
[...]
1 iteration
187 40 n java.lang.invoke.MethodHandle::linkToStatic(ILIIL)I (native) (static)
[...]
(native) (static)
212 54 n java.lang.invoke.MethodHandle::invokeBasic(J)L (native)
212 55 n java.lang.invoke.MethodHandle::linkToSpecial(LJL)L (native) (static)
Time taken= 12337415
[...]
--------------------------------
4 iteration
Square(i) = 16
Time taken= 37183
--------------------------------
5 iteration
214 56 b Demo::<init> (5 bytes)
215 57 b Demo::square (16 bytes)
Square(i) = 25
Time taken= 983002
--------------------------------
6 iteration
Square(i) = 36
Time taken= 81589
[...]
10 iteration
Square(i) = 100
Time taken= 52393
现在这段代码在第五次解释后被JIT编译了。
--------------------------------
5 iteration
214 56 b Demo::<init> (5 bytes)
215 57 b Demo::square (16 bytes)
Square(i) = 25
Time taken= 983002
--------------------------------
square()与square() 方法一起,构造器也被JIT编译了,因为在调用for 循环之前有一个Demo实例。因此,它也将达到阈值并被JIT编译。这个例子说明了JIT是在解释之后发挥作用的。
要查看代码的编译版本,请使用-XX:+PrintAssembly flag ,它只在库路径中有反汇编程序的情况下发挥作用。对于OpenJDK,使用hsdis 反汇编程序。下载一个合适的反汇编库--在本例中是hsdis-amd64.so--并将其放在Java_HOME/lib/server 。请确保在-XX:+PrintAssembly 之前使用-XX:+UnlockDiagnosticVMOptions 。否则,JVM会给你一个警告。
整个命令如下。
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler \ -XX:-TieredCompilation -XX:CompileThreshold=5 -XX:+UnlockDiagnosticVMOptions \ -XX:+PrintAssembly Demo
[...]
5 iteration
178 56 b Demo::<init> (5 bytes)
Compiled method (c2) 178 56 Demo::<init> (5 bytes)
total in heap [0x00007fd4d08dad10,0x00007fd4d08dafe0] = 720
relocation [0x00007fd4d08dae88,0x00007fd4d08daea0] = 24
[...]
handler table [0x00007fd4d08dafc8,0x00007fd4d08dafe0] = 24
[...]
dependencies [0x00007fd4d08db3c0,0x00007fd4d08db3c8] = 8
handler table [0x00007fd4d08db3c8,0x00007fd4d08db3f8] = 48
----------------------------------------------------------------------
Demo.square(I)I [0x00007fd4d08db1c0, 0x00007fd4d08db2b8] 248 bytes
[Entry Point]
[Constants]
# {method} {0x00007fd4b841f4b0} 'square' '(I)I' in 'Demo'
# this: rsi:rsi = 'Demo'
# parm0: rdx = int
# [sp+0x20] (sp of caller)
[...]
[Stub Code]
0x00007fd4d08db280: movabs $0x0,%rbx ; {no_reloc}
0x00007fd4d08db28a: jmpq 0x00007fd4d08db28a ; {runtime_call}
0x00007fd4d08db28f: movabs $0x0,%rbx ; {static_stub}
0x00007fd4d08db299: jmpq 0x00007fd4d08db299 ; {runtime_call}
[Exception Handler]
0x00007fd4d08db29e: jmpq 0x00007fd4d08bb880 ; {runtime_call ExceptionBlob}
[Deopt Handler Code]
0x00007fd4d08db2a3: callq 0x00007fd4d08db2a8
0x00007fd4d08db2a8: subq $0x5,(%rsp)
0x00007fd4d08db2ad: jmpq 0x00007fd4d08a01a0 ; {runtime_call DeoptimizationBlob}
0x00007fd4d08db2b2: hlt
0x00007fd4d08db2b3: hlt
0x00007fd4d08db2b4: hlt
0x00007fd4d08db2b5: hlt
0x00007fd4d08db2b6: hlt
0x00007fd4d08db2b7: hlt
ImmutableOopMap{rbp=NarrowOop }pc offsets: 96
ImmutableOopMap{}pc offsets: 112
ImmutableOopMap{rbp=Oop }pc offsets: 148 Square(i) = 25
Time taken= 2567698
--------------------------------
6 iteration
Square(i) = 36
Time taken= 76752
[...]
--------------------------------
10 iteration
Square(i) = 100
Time taken= 52888
输出很冗长,所以我只包括与Demo.java 有关的输出。
现在是进行AOT编译的时候了。这个选项是在JDK9中引入的。AOT是一个静态编译器,用于生成.so 库。通过AOT,感兴趣的类可以被编译成一个.so 库,可以直接执行而不是解释或JIT编译。如果JVM没有找到任何AOT编译的代码,就会进行通常的解释和JIT编译。
用于AOT编译的命令如下。
$ jaotc --output=libDemo.so Demo.class
要查看共享库中的符号,请使用以下内容。
$ nm libDemo.so
要使用生成的.so 库,使用-XX:AOTLibrary ,同时使用-XX:+UnlockExperimentalVMOptions ,如下所示。
$ java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=./libDemo.so Demo
1 iteration
Square(i) = 1
Time taken= 7831139
--------------------------------
2 iteration
Square(i) = 4
Time taken= 36619
[...]
10 iteration
Square(i) = 100
Time taken= 42085
这个输出看起来就像它本身是一个解释版本。为了确保利用AOT编译后的代码,使用-XX:+PrintAOT 。
$ java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=./libDemo.so -XX:+PrintAOT Demo
28 1 loaded ./libDemo.so aot library
80 1 aot[ 1] Demo.main([Ljava/lang/String;)V
80 2 aot[ 1] Demo.square(I)I
80 3 aot[ 1] Demo.<init>()V
1 iteration
Square(i) = 1
Time taken= 7252921
--------------------------------
2 iteration
Square(i) = 4
Time taken= 57443
[...]
10 iteration
Square(i) = 100
Time taken= 53586
只是为了确保JIT编译还没有发生,使用如下。
$ java -XX:+UnlockExperimentalVMOptions -Xbatch -XX:+PrintCompilation \ -XX:CompileCommandFile=hotspot_compiler -XX:-TieredCompilation \ -XX:CompileThreshold=3 -XX:AOTLibrary=./libDemo.so -XX:+PrintAOT Demo
19 1 loaded ./libDemo.so aot library
77 1 aot[ 1] Demo.square(I)I
77 2 aot[ 1] Demo.main([Ljava/lang/String;)V
77 3 aot[ 1] Demo.<init>()V
77 2 aot[ 1] Demo.main([Ljava/lang/String;)V made not entrant
[...]
4 iteration
Square(i) = 16
Time taken= 43366
[...]
10 iteration
Square(i) = 100
Time taken= 59554
如果对受AOT影响的源代码做了任何小的改动,必须确保再次创建相应的.so 。否则,陈旧的AOT编译的.so 不会有任何效果。例如,对平方函数做一个小的改动,使其现在是计算立方体。
//Demo.java
public class Demo {
public int square(int i) throws Exception {
return(i*i*i);
}
public static void main(String[] args) throws Exception {
for (int i = 1; i <= 10; i++) {
System.out.println("" + Integer.valueOf(i)+" iteration");
long start = System.nanoTime();
int r= new Demo().square(i);
System.out.println("Square(i) = " + r);
long end = System.nanoTime();
System.out.println("Time taken= " + (end-start));
System.out.println("--------------------------------");
}
}
}
现在,再次编译Demo.java 。
$ java Demo.java
但是,不要使用jaotc 创建libDemo.so 。相反,使用这个命令。
$ java -XX:+UnlockExperimentalVMOptions -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:-TieredCompilation -XX:CompileThreshold=3 -XX:AOTLibrary=./libDemo.so -XX:+PrintAOT Demo
20 1 loaded ./libDemo.so aot library
74 1 n java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native) (static)
2 iteration
sqrt(i) = 8
Time taken= 43838
--------------------------------
3 iteration
137 56 b Demo::<init> (5 bytes)
138 57 b Demo::square (6 bytes)
sqrt(i) = 27
Time taken= 534649
--------------------------------
4 iteration
sqrt(i) = 64
Time taken= 51916
[...]
10 iteration
sqrt(i) = 1000
Time taken= 47132
尽管旧版本的libDemo.so 已经被加载,但JVM检测到它是一个陈旧的版本。每次创建.class 文件时,都会有一个指纹进入类文件,AOT库中也会保存一个类指纹。因为类的指纹与AOT库中的指纹不同,所以不使用AOT编译的本地代码。相反,该方法现在是JIT编译的,因为-XX:CompileThreshold 被设置为3。
AOT还是JIT?
如果你的目的是减少JVM的预热时间,那么就使用AOT,这样可以减少运行时的负担。问题是,AOT不会有足够的数据来决定哪一段代码需要预编译为本地代码。 相比之下,JIT会在运行时介入,并影响预热时间。然而,它将有足够的分析数据来更有效地编译和反编译代码。