深入了解 JVM 啟動
1. 概述
在執行 Java 應用程式時,JVM 會執行一系列複雜的步驟,然後才會執行我們的程式碼。在本教程中,我們將了解從執行java命令到應用程式啟動期間所發生的一切。
我們將以一個簡單的 HelloWorld 程式為例,逐一分析流程的每個階段。理解這些內部機制可以顯著提高調試和效能調優的效率。
2. 從java指令到 JVM 啟動
JVM 在執行任何程式碼之前,必須先啟動、驗證輸入並配置其環境。本文將介紹早期啟動過程,從呼叫java指令到初始化 JVM 執行環境。
2.1. java命令和初始調用
當我們執行java指令時,JVM 啟動序列會先呼叫 JNI 方法JNI_CreateJavaVM() 。該方法執行幾個必要的初始化任務,為 Java 應用程式的運行做好準備。 Java本地介面 (JNI) 充當 JVM 和本地系統庫之間的橋樑,實現了 Java 程式碼與平台特定功能之間的無縫雙向通訊。
在本文中,我們將使用詳細的日誌記錄來觀察 JVM 的內部運作:
java -Xlog:all=trace HelloWorld
2.2 驗證用戶輸入
首先,JVM 會驗證我們傳遞的參數:
[0.006s][info][arguments] VM Arguments:
[arguments] jvm_args: -Xlog:all=trace:file=helloworld.log
[arguments] java_command: HelloWorld
[arguments] java_class_path (initial): .
[arguments] Launcher Type: SUN_STANDARD
JVM 會驗證目標工件、類別路徑以及所有 JVM 參數,確保它們有效後才會繼續執行。此驗證步驟有助於在啟動過程早期發現許多常見的配置錯誤,防止它們在後續階段引發問題。
2.3 檢測系統資源
接下來,JVM 會識別可用的系統資源,例如處理器數量、記憶體大小和關鍵系統服務:
[0.007s][debug][os ] Process is running in a job with 20 active processors.
[os ] Initial active processor count set to 20
[os ] Process is running in a job with 20 active processors.
[gc,heap ] Maximum heap size 4197875712
[gc,heap ] Initial heap size 262367232
[gc,heap ] Minimum heap size 6815736
[os ] Host Windows OS automatically schedules threads across all processor groups.
[os ] 20 logical processors found.
這些資訊指導 JVM 做出一些內部決策,例如預設選擇哪個垃圾回收器。可用 CPU 數量和總記憶體直接影響 JVM 的啟發式演算法。然而,大多數這些設定都可以透過明確的 JVM 參數進行覆寫。在此階段,JVM 還會檢查本機記憶體追蹤 (NMT) 是否可用,並驗證對它可能依賴的各種作業系統實用程式的存取權。我們可以使用 JVM 參數自訂系統資源。
2.4 環境準備
然後,JVM 透過產生 HotSpot 效能資料來準備執行環境。這些資料會被 JConsole 和 VisualVM 等效能分析工具使用:
[perf,datacreation] name = sun.rt._sync_Inflations, dtype = 11, variability = 2, units = 4, dsize = 8, vlen = 0, pad_length = 4, size = 56, on_c_heap = FALSE, address = 0x000001f3085f0020, data address = 0x000001f3085f0050
此效能資料通常儲存在系統的/tmp目錄中,並在整個啟動過程中與其他初始化任務同時產生。
3. 載入、連結和初始化
JVM環境準備好後,便開始準備我們的程式以供執行。
3.1. 選擇垃圾收集器
JVM 內部的一個主要步驟是選擇垃圾回收器。從 JDK23 開始,預設情況下, JVM 選擇 G1 GC,除非系統記憶體小於 1792MB 和/或系統是單處理器系統:
[gc ] Using G1
[gc,heap,coops ] Trying to allocate at address 0x0000000705c00000 heap of size 0xfa400000
[os ] VirtualAlloc(0x0000000705c00000, 4198498304, 2000, 4) returned 0x0000000705c00000.
[os,map ] Reserved [0x0000000705c00000 - 0x0000000800000000), (4198498304 bytes)
[gc,heap,coops ] Heap address: 0x0000000705c00000, size: 4004 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
[pagesize ] Heap: min=8M max=4004M base=0x0000000705c00000 size=4004M page_size=4K
我們可以選擇其他垃圾回收器:Parallel GC、ZGC 等,取決於 JDK 版本及其發行版。
3.2. 快取資料儲存加載
此時,JVM 開始尋求最佳化。 CDS是一個已經預先處理過的類別檔案歸檔,它可以提高 JVM 的啟動效能:
[cds] trying to map [Java home]/lib/server/classes.jsa
[cds] Opened archive [Java home]/lib/server/classes.jsa
然而,作為萊頓計劃的一部分,CDS 將被 AOT 取代,我們稍後將討論這一點。
3.3 建立方法區
JVM 隨後會建立方法區,這是一個特殊的堆外記憶體區域,用於儲存類別資料。 HotSpot JVM 實作將此區域稱為metaspace 。如果關聯的類別載入器不再處於作用域內,則儲存在此處的類別資料可以被刪除:
[metaspace,map ] Trying anywhere...
[metaspace,map ] Mapped at 0x000001f32b000000
雖然方法區不在 JVM 的堆中,但垃圾回收器仍然對其進行管理。
3.4 類別載入
類別載入是一個三步驟過程:找到類別的二進位表示、從中衍生出類,並將其載入到方法區。正是這種動態載入類別的能力,使得像 Spring 和 Mockito 這樣的框架能夠在 JVM 運行時按需載入產生的類別。
我們可以透過兩種方式載入類別:使用引導類別載入器或使用自訂類別載入器。
現在,借助HelloWorld類,讓我們來了解 JVM 首先必須做什麼:
public class HelloWorld extends Object {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
JVM 會先載入java.lang.Object及其所有相依性。類別在初始載入時處於一種基本隱藏的狀態,以便執行重要的驗證和清理步驟。
我們來看看java.lang.Object的方法:
public class Object {
public final native Class<?> getClass()
public String toString()
public boolean equals(Object obj)
}
這些方法引用了java.lang.Class和java.lang.String ,它們必須先載入。 JVM採用延遲載入策略,僅在類別被主動引用時才載入它們。然而,我們在本節前面討論的類別是預先載入的,因為它們對 JVM 的運作至關重要。在JNI_CreateJavaVM()期間實例化的引導類別載入器負責處理簡單HelloWorld程式的所有類別載入。
3.5. 類鏈接
類別連結包含三個子過程- Verification 、 Preparation,和Resolution 。這些步驟並非依序進行, Resolution可以在Verification,類別初始化之後的任何時間發生。 Verification確保類別結構正確:
[class,init] Start class verification for: HelloWorld
[verification] Verifying class HelloWorld with new format
[verification] Verifying method HelloWorld.<init>()V
CDS 內部的類別已經過驗證,因此可以跳過此步驟,從而提升啟動效能。這是 CDS 提供者的主要優點之一。在Preparation ,JVM 會使用預設值初始化靜態欄位。任何沒有明確初始化器的靜態變數都會自動獲得其預設值。
在Resolution ,JVM 會解析常數池中的符號參考。常數池儲存一個類別的所有符號引用,JVM 必須先解析這些引用,才能執行對應的指令。
我們可以使用javap來檢查這一點:
javap -verbose HelloWorld
這向我們展示了常量池:
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#13 = String #14 // Hello World
建構函數的字節碼並不直接包含位址。它引用常數池中的符號條目(例如#1 ),這些符號條目描述了方法和欄位。在解析過程中,JVM 會將這些符號條目轉換為可執行的實際記憶體參考:
public HelloWorld();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 2: 0
line 4: 4
第 1 行的invokespecial指令引用了常數池條目 #1,該條目提供了連結到java.lang.Object's建構函數所需的資訊。 init init這是一個由 javac 為每個建構子自動產生的特殊方法。 JVM會延遲執行解析,僅在嘗試執行類別中的指令時才觸發解析。並非所有已載入的類別都會執行其指令。
3.6 類別初始化
類別javac會為靜態欄位賦值並執行靜態初始化器。這與實例初始化不同,實例初始化發生在呼叫建構函式時。 javac 自動產生的特殊clinit方法負責處理類別初始化。
4. 優化 JVM 啟動性能
儘管JVM的啟動效率很高,但仍有改進空間。讓我們來看一些建議。
4.1 班級負荷的影響
為了測量 JVM 啟動、載入類別、連結類別並執行我們簡單程式所需的總時間,我們可以使用系統的time實用程式:
time java HelloWorld
這測量的是 JVM 進程從啟動到退出的實際運行時間。它包括類別載入、連結、JIT 預熱和程式執行——而不僅僅是用戶程式碼。對於HelloWorld ,JVM 在啟動期間大約會載入 400-450 個類別。在現代硬體上,即使啟用了詳細日誌記錄,整個過程也只需大約 60 毫秒即可完成。
4.2. 萊頓計劃
萊頓計畫旨在縮短啟動時間、縮短達到峰值效能所需的時間並減少記憶體佔用。 JDK 24 引入了 JEP 483:提前類別載入和鏈接,它會在啟動前而不是啟動時執行這些操作。
此功能會在訓練運行期間記錄 JVM 的行為,將其儲存在快取中,並在後續啟動時從快取載入。這將取代 CDS 縮寫的轉換,並最終使 AOT 更好地涵蓋這些新功能。
4.3 JVM 標誌和調優
雖然可以透過使用靜態欄位和初始化器來優化啟動效能,但我們應該謹慎對待。花時間重構類,將行為推入類別載入階段,可能不會產生可衡量的效果。畢竟,大部分執行的程式碼來自依賴項,而不是我們自己的應用程式程式碼。
5. 結論
本文探討了JVM在啟動過程中所經歷的複雜流程,從驗證使用者輸入、偵測系統資源到載入、連結和初始化類別。我們看到,即使是簡單的HelloWorld應用程序,JVM也會在執行程式碼之前準備整個運行時環境,載入數百個類別。
隨著 Project Leyden 的 AOT 功能等改善措施的推出,啟動效能將持續提升。