你可曾想過:當你在終端裏敲下 java,在 main 方法真正運行之前,JVM 為了“創造一個可運行你的程序的宇宙”,到底經歷了哪些步驟?從參數校驗、系統資源探測,到選擇垃圾回收器,再到類的加載、鏈接與初始化,這些看不見的過程決定了應用的啓動體驗與後續性能。本文用一個極簡的 HelloWorld 貫穿全程,結合詳細日誌,一步步洞察 JVM 的啓動機制,幫你在調試和性能優化時更有抓手。
1. 概覽
當我們運行一個 Java 應用時,JVM 在我們的代碼真正開始執行之前,會先完成一系列複雜步驟。本文將從執行 java 命令的那一刻開始,一直走到應用就緒。
我們以一個簡單的 HelloWorld 程序為例,拆解每一個階段。理解這些內部機制能顯著提升調試與性能調優的效果。
2. 從 java 命令到 JVM 啓動
在 JVM 執行任何代碼之前,它需要先啓動、校驗輸入並配置運行環境。下面按啓動順序走一遍早期流程:從調用 java 命令到初始化 JVM 運行時。
2.1. java 命令與初始調用
當我們運行 java 命令時,JVM 啓動序列會通過 JNI 方法 JNI_CreateJavaVM() 開始執行。該方法完成若干關鍵初始化任務,為執行 Java 應用準備環境。Java Native Interface(JNI)是 JVM 與原生系統庫之間的橋樑,使 Java 與平台特性可以雙向通信。
本文將使用詳細日誌觀察 JVM 的內部運作,例如:
java -Xlog:all=trace HelloWorldCopy
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 還會檢查是否支持 Native Memory Tracking,並驗證它可能依賴的各類操作系統工具的可用性。
2.4. 環境準備
隨後,JVM 會生成 HotSpot 性能數據。這些數據會被 JConsole、VisualVM 等工具用於檢查和分析 JVM:
[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 內部,一個關鍵步驟是選擇垃圾回收器(GC)。截至 JDK 23,默認情況下 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
當然,我們也可以選擇其它 GC:如 Parallel GC、ZGC 等,具體可用與默認策略依不同 JDK 版本與發行版而異。
3.2. 加載 CDS(類數據共享)
此時,JVM 會開始尋找進一步的優化機會。CDS 是一組已經經過預處理的類文件歸檔,可以改善 JVM 的啓動性能:
[cds] trying to map [Java home]/lib/server/classes.jsa
[cds] Opened archive [Java home]/lib/server/classes.jsa
不過,CDS 正在被 Project Leyden 中的 AOT(提前)機制逐步替代,後文會繼續討論。
3.3. 創建方法區
JVM 隨後會創建“方法區”,這是一個用於存儲類數據的特殊離堆內存區域。在 HotSpot 中,這一區域被稱為 metaspace。當關聯的類加載器不再可達時,存儲於此的類數據也會被移除:
[metaspace,map ] Trying anywhere...
[metaspace,map ] Mapped at 0x000001f32b000000
雖然方法區不在堆中,但它仍由 GC 管理。
3.4. 類加載
類加載包含三個步驟:定位二進制表示、根據其派生出類、並將其加載到方法區。正是這種動態加載能力,讓 Spring、Mockito 等框架可以在運行期按需生成並加載類。
類加載有兩種方式:引導類加載器(bootstrap class loader)或自定義類加載器。下面藉助一個簡單的 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 至關重要的類會被“搶先加載”。在一個簡單的 HelloWorld 程序裏,由 JNI_CreateJavaVM() 初始化的引導類加載器負責所有的類加載工作。
3.5. 類鏈接
類鏈接可以拆分為驗證(Verification)、準備(Preparation)與解析(Resolution),其發生順序並不固定:解析可能發生在驗證之前,也可能在類初始化之後。驗證確保類結構正確:
[class,init] Start class verification for: HelloWorld
[verification] Verifying class HelloWorld with new format
[verification] Verifying method HelloWorld.<init>()V
位於 CDS 中的類已經過驗證,因此會跳過該步驟,從而提升啓動性能。這是 CDS 的重要收益之一。在“準備”階段,JVM 會用默認值初始化靜態字段,沒有顯式初始化器的靜態變量會自動獲得默認值。
在“解析”階段,JVM 會解析常量池(Constant Pool)中的符號引用。常量池保存了類的所有符號引用,JVM 必須先將其解析為真實的內存引用,才能執行相關指令。
我們可以使用 javap 來觀察:
javap -verbose HelloWorldCopy
這將顯示常量池的內容:
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),這些條目描述了方法或字段。解析階段會將這些符號項轉為可執行的真實內存引用:
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 構造器所需的信息。<init> 表示這是由 javac 為每個構造器自動生成的特殊方法。JVM 採用“延遲解析”,只有在嘗試執行類中的某條指令時才觸發解析;並非所有已加載的類都會實際執行其指令。
3.6. 類初始化
類初始化會為靜態字段賦值並執行靜態初始化器,這與我們調用構造器的實例初始化不同。該過程由 javac 自動生成的特殊方法 clinit 負責。
4. 優化 JVM 啓動性能
儘管 JVM 的啓動已經很高效,但仍有提升空間。以下是一些方向。
4.1. 類加載的影響
我們可以使用系統的 time 工具來度量 JVM 啓動、加載類、鏈接並執行這個簡單程序的總耗時:
time java HelloWorldCopy
該工具會測量從 JVM 進程啓動到退出的掛鐘時間,包含類加載、鏈接、JIT 預熱與程序執行——不僅僅是用户代碼。對於 HelloWorld,JVM 在啓動期間通常會加載約 400~450 個類。在現代硬件上,即便開啓冗長日誌,整個過程也大約在 60 毫秒左右完成。
4.2. Project Leyden
Project Leyden 的目標是減少啓動時間、達到峯值性能的時間以及內存佔用。JDK 24 引入了 JEP 483:Ahead-of-Time Class Loading and Linking(提前類加載與鏈接),將這些操作從啓動時前移至 AOT 階段。
該特性會在“訓練運行”中記錄 JVM 的行為,將其存入緩存,並在後續啓動時從緩存加載。這將取代原先的 CDS 概念,並最終以 AOT 的更廣泛能力來統一表達。
4.3. JVM 參數與調優
雖然我們可以通過靜態字段與初始化器在某些場景中優化啓動性能,但應謹慎對待。為了將行為挪到類加載階段而進行重構,往往很難獲得可測量的收益——特別是考慮到運行時的大部分代碼來自依賴庫而非我們自己的應用。
5. 結論
本文從校驗用户輸入、檢測系統資源,到類的加載、鏈接與初始化,系統地梳理了 JVM 在啓動階段經歷的複雜流程。即便是一個簡單的 HelloWorld,JVM 也會在執行代碼之前構建起完整的運行環境,加載數百個類。
隨着 Project Leyden 等改進(例如 AOT)的到來,JVM 的啓動性能還將進一步提升。
更多相關技術乾貨分享可以關注這個Java專題