當我們在編寫Java應用的時候,很少會注意Java程式是如何被執行的,如何被作業系統管理和排程的。帶著好奇心,探索一下Java虛擬機器器啟動過程。
從Java原始碼
、Java位元組碼
、Java虛擬機器器
、作業系統
四個角度分解啟動過程。
public class HelloWorld {
public static void main(String[] args) {
System.out.println("HelloWorld!");
}
}
利用Java環境提供的可執行命令javac
將原始碼編譯成位元組碼檔案,編譯後的位元組碼檔案與平臺無關,可跨平臺執行。注意區分javac
命令是一個獨立的編譯應用,原始碼編譯完成,程序終止。java
命令啟動的虛擬機器器程序的編譯過程是將位元組碼指令編譯成組合指令(二進位制指令)。
Java位元組碼無法直接在作業系統上建立程序,因此需要藉助已經啟動的虛擬機器器程序來解析位元組碼,處理位元組碼有兩種常見方式:解釋型
和編譯型
。
在命令列中每執行java
命令代表啟動一個Java虛擬機器器程序,各虛擬機器器相互獨立,通過命令列引數分別對虛擬機器器程序進行設定。
Java虛擬機器器準備啟動完畢後,便可以依次解析位元組碼指令,正式執行Java程式碼
部分。
作業系統通過程序管理和排程Java虛擬機器器,無法感知虛擬機器器間接解析Java位元組碼部分。Java位元組碼通過虛擬機器器的抽象,完成了在作業系統上執行。
當執行Java應用時,需要先安裝Java環境,然而安裝的Java環境與Java應用有什麼關係,Java應用是如何執行起來的,下面一探究竟。
二進位制可執行程式${JAVA_HOME}/bin/java
是C++編寫經過GCC編譯器編譯後形成的,探索Java虛擬機器器的執行原理,首先需要找到相應的原始碼。
當在安裝Java環境時,會看到一個src.zip
壓縮檔案,解壓后里面launcher/java.c
檔案便是可執行檔案java
命令的主要原始碼。
虛擬機器器的啟動入口位於
launcher/java.c
的main方法
,整個流程分為如下幾個步驟: 設定JVM裝載環境;解析虛擬機器器引數;設定執行緒棧大小;執行Java main方法
從作業系統載入環境變數、硬體資訊等執行環境資訊,為後續建立JVM程序做準備。
裝載完JVM環境之後,需要對啟動時命令列引數進行解析,該過程通過ParseArguments方法
實現,並呼叫AddOption方法
將解析完成的引數儲存到JavaVMOption中。
比如常見的JavaVMOption引數在此步驟解析:
-Xms:設定堆的初始值InitialHeapSize,也是堆的最小值;
-Xmx:設定堆的最大值MaxHeapSize;
JVM調優各引數解析便是在此步驟完成的。
執行緒棧大小確定後,通過ContinueInNewThread方法
建立新執行緒,並執行JavaMain函數,大概流程如下:
InitializeJVM方法呼叫InvocationFunctions的CreateJavaVM方法,即呼叫JVM.dll函數JNI_CreateJavaVM,新建一個JVM範例,該過程比較複雜。
通常在命令列中執行如下命令即指明入口類路徑
# 直接指名入口類路徑
java HelloWorld.class
# 通過包類設定入口類路徑
java -jar HelloWorld.jar
通過GetStaticMethodID方法查詢指定main方法名的靜態方法。
通過JavaCalls::call
回撥執行main方法。需要注意的是,這裡執行main方法不是Java語言的方法,是經過虛擬機器器解釋(或者編譯)後,作業系統能夠理解的二進位制可執行方法。
iconst_1 將 1 放入棧頂
iconst_1 將 1 放入棧頂
iadd 將棧頂的 2 個數相加後結果放入棧頂
istore_0 將相加的結果放入區域性變數表
基於棧的指令集優點是虛擬機器器直譯器是可跨平臺移植的,換句話說不同平臺的虛擬機器器直譯器程式碼可以複用。
mov eax,1 把 EAX 暫存器的值設為 1
add eax,1 再把這個值加 1 ,結果儲存在了 EAX 暫存器
基於暫存器指令集的優點是執行速度相對於棧較快,原因是出棧入棧本身就涉及了大量的指令,而且棧是在記憶體中實現的,更底層的組合指令效能更高。
基於暫存器指令集的缺點是虛擬機器器直譯器是不可跨平臺移植,需要針對不同平臺的虛擬機器器做不同實現。考慮到不同平臺已經使用不同的虛擬機器器程式,因此此過程多使用者透明。
虛擬機器器通過直譯器來翻譯位元組碼檔案中的指令比較順其自然,可是對於伺服器端高頻執行的程式來說,中間的翻譯過程相對耗時。解釋位元組碼的方式適用於對啟動效能要求高,並且執行頻率較低的應用程式。
最初,JVM 中的位元組碼是由直譯器( Interpreter )完成編譯的,當虛擬機器器發現某個方法或程式碼塊的執行特別頻繁的時候,就會把這些程式碼認定為熱點程式碼
。
為了提高熱點程式碼的執行效率,在執行時,即時編譯器(JIT,Just In Time)會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各層次的優化,然後儲存到記憶體中。
在 HotSpot 虛擬機器器中,內建了兩種 JIT,分別為C1 編譯器
和C2 編譯器
,這兩個編譯器的編譯過程是不一樣的。
C1 編譯器是一個簡單快速的編譯器,主要的關注點在於區域性性的優化,適用於執行時間較短或對啟動效能有要求的程式,也稱為Client Compiler
,例如,GUI 應用對介面啟動速度就有一定要求。
C2 編譯器是為長期執行的伺服器端應用程式做效能調優的編譯器,適用於執行時間較長或對峰值效能有要求的程式,也稱為Server Compiler
,例如,伺服器上長期執行的 Java 應用對穩定執行就有一定的要求。
分層編譯將 JVM 的執行狀態分為了 5 個層次:
第 0 層:程式解釋執行,預設開啟效能監控功能(Profiling),如果不開啟,可觸發第二層編譯;
第 1 層:可稱為 C1 編譯,將位元組碼編譯為原生程式碼,進行簡單、可靠的優化,不開啟 Profiling;
第 2 層:也稱為 C1 編譯,開啟 Profiling,僅執行帶方法呼叫次數和迴圈回邊執行次數 profiling 的 C1 編譯;
第 3 層:也稱為 C1 編譯,執行所有帶 Profiling 的 C1 編譯;
第 4 層:可稱為 C2 編譯,也是將位元組碼編譯為原生程式碼,但是會啟用一些編譯耗時較長的優化,甚至會根據效能監控資訊進行一些不可靠的激進優化。
通常情況下,C2 的執行效率比 C1 高出30%以上。
在 Java8 中,預設開啟分層編譯。如果只想開啟 C2,可以關閉分層編譯(-XX:-TieredCompilation
),如果只想用 C1,可以在開啟分層編譯的同時,使用引數:-XX:TieredStopAtLevel=1
。
通過 java -version
命令列可以檢視到當前虛擬機器器解析位元組碼的方式,mixed mode
表示既有解釋模式也有即是編譯模式。
java version "1.8.0_261"
Java(TM) SE Runtime Environment (build 1.8.0_261-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, mixed mode)
mixed mode
代表是預設的混合編譯模式,除了這種模式外,我們還可以使用-Xint
引數強制虛擬機器器執行於只有直譯器的編譯模式下;也可以使用引數-Xcomp
強制虛擬機器器執行於只有 JIT 的編譯模式下。
僅使用解釋模式
通過命令java -Xint -version
設定僅使用解釋模式,interpreted mode
表示解釋模式。
java version "1.8.0_261"
Java(TM) SE Runtime Environment (build 1.8.0_261-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, interpreted mode)
僅使用編譯模式
通過命令java -Xcomp -version
設定僅使用編譯模式,compiled mode
表示編譯模式。在編譯模式下,程式啟動能感覺到明顯的卡頓。
java version "1.8.0_261"
Java(TM) SE Runtime Environment (build 1.8.0_261-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, compiled mode)
通過對Java虛擬機器器啟動過程的解析,特別是即時編譯
環節的理解,Java應用執行並不慢。當應用中熱點程式碼普遍被編譯成組合指令(二進位制可執行命令)存放於記憶體中時,可近似達到C語言原生程式的執行速度。
隨著算力與記憶體成本日漸降低,通過空間複雜度置換時間複雜度的策略顯然是合理的,使用Java語言編寫需求萬千變化的應用是第一選擇:既有跨平臺、記憶體安全、框架生態豐富的優點,也在執行效率方面積極改善,這種折中選擇與市場反饋保持一致。