JVM 解释和编译指南

通过理解解释、即时编译和提前编译之间的差异,有效率地使用它们。
1 位读者喜欢此文。

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 编译器也称为profile-guided编译器。 字节码到本机代码的转换是动态发生的,因此得名即时。 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 的文档,只有在禁用 TieredCompilation 时,-XX:CompileThreshold 标志才有效

$ 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() 方法之外,构造函数也经过 JIT 编译,因为在调用 square() 之前,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 不会有任何效果。 例如,对 square 函数进行一个小更改,使其现在计算立方体

//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 在运行时介入并影响预热时间。 但是,它将有足够的分析数据来更有效地编译和反编译代码。

标签
User profile image.
Jayashree Huttanagoudar 是 Red Hat India Pvt ltd 的高级软件工程师。她与 Middleware OpenJDK 团队合作。她总是渴望学习新事物,这有助于她的工作。

评论已关闭。

Creative Commons License本作品根据知识共享署名-相同方式共享 4.0 国际许可协议获得许可。
© . All rights reserved.