JVM 解釋和編譯指南
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
時如果不指定任何選項,它將列印基本信息,包括編譯這個 .class
文件的源文件、包名稱、公共和受保護欄位以及類的方法。
$ 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) 的 預熱 時間。(LCTT 譯註:Java 程序啟動後首先位元組碼被解釋執行,此時執行效率較低。等到程序運行了足夠的時間後,代碼熱點被檢測出來,JIT 開始發揮作用,程序運行效率提升。JIT 發揮作用之前的過程就是預熱。)AOT 是在 Java 9 中引入的一個實驗性特性。jaotc
使用 Graal 編譯器(它本身也是用 Java 編寫的)來實現 AOT 編譯。
以 Hello.java
為例:
//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 何時參與進來。這裡有一個簡單的程序 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 編譯了呢?答案是肯定的,而且有多種方式可以達到這個目的。
使用 -XX:+PrintCompilation
選項可以查看一個方法是否被 JIT 編譯。除此之外,使用 -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 不要輸出任何關於被排除類的內容。用 -XX:CompileThreshold
將 JIT 閾值設置為 5。這意味著在解釋 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 編譯了。在 for
循環中調用 square()
之前要先構造 Demo
實例,所以構造方法的解釋次數同樣達到 JIT 編譯閾值。這個例子說明了在解釋發生之後何時 JIT 會介入。
要查看編譯後的代碼,需要使用 -XX:+PrintAssembly
標誌,該標誌僅在庫路徑中有反彙編器時才起作用。對於 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:+UnlockExperimentalVMOptions
和 -XX:AOTLibrary
:
$ 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 編譯庫文件不會起作用。例如,修改 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
但不重新生成 libDemo.so
。使用下面命令運行 Demo
:
$ 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 在運行時起作用,卻對預熱時間有一定的影響。然而,它將有足夠的分析數據來更高效地編譯和反編譯代碼。
(題圖:MJ/ed3e6e15-56c7-4c1d-aff1-84a225faeeeb)
via: https://opensource.com/article/22/8/interpret-compile-java
作者:Jayashree Huttanagoudar 選題:lkxed 譯者:toknow-gh 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive