JDK 24 功能:JEP 483 預先類別載入與連結為 Java 程式的啟動帶來效能突破

JEP 483: Ahead-of-Time Class Loading & Linking

Java 虛擬機器一直以來都支援跨平台和動態特性,但這種靈活性往往伴隨著效能損耗,特別是在程式啟動階段。Java 24JEP 483 引入了預先類別載入與連結機制,為這項長期存在的問題提供了優雅的解決方案。

本功能可讓 HotSpot JVM 在程式啟動時,直接使用預先準備好的類別資訊,以避免重複進行費時的載入與連結作業。根據初步測試,它可以為程式縮短 42% 的啟動時間,對於企業級別的應用場景特別有用。

前言

Java 平台的重要特色之一是高度動態性,包括動態類別載入、動態連結、動態配發,以及動態反射等機制,給予開發者極大的表達能力。例如:

  • 開發框架:可以透過反射來掃描應用程式的註解(Annotations),以此決定應用程式的組態
  • 開發函式庫:可以動態載入並連結外掛元件,以允許執行期間探索並使用新功能
  • 應用程式組裝:可以組合多個函式庫,並動態連結其他函式庫,可充分利用 Java 生態系的豐富資源

此外,動態編譯、動態反最佳化與動態記憶體回收等機制,也為 JVM 提供極大的彈性:

  • 動態編譯:JVM 可以監測程式的執行行為,並可以在適當時機將位元組碼轉換為原生機器碼,以提升執行效能
  • 動態反最佳化:JVM 可以投機性地最佳化。例如假設某段程式碼會頻繁執行,並進行針對性優化;但若該假設失效,則可以還原回位元組碼的解釋執行模式
  • 動態記憶體回收:JVM 會根據執行狀態決定何時進行垃圾回收,以移除不再使用的物件並提升記憶體使用效率

動態性帶來的啟動成本

然而,這種高度動態的機制是需要付出代價的,而且這個代價必須在程式每次啟動時付出。以一個典型的伺服器應用程式的啟動過程來說,JVM 會交錯執行多種工作:

  1. 掃描並解析 JAR 檔案
    • 讀取磁碟上的數百個 JAR 檔案
    • 解析並載入數千個類別檔到記憶體中
  2. 載入並連結類別
  3. 執行類別的靜態初始化器
    • 包含靜態變數初始化與 static { ... } 區塊
    • 可能會建立許多物件,甚至進行 I/O 操作,例如開啟日誌檔案

若是再加上應用框架的話(例如 Spring),那麼還會增加額外的啟動開銷。因為 Spring 會掃描 @Bean@Configuration 等註解,並進行組態探索,導致進一步影響啟動時間。

前述這些工作項目都是依需求,惰性地,即時完成的。而且由於其經過高度優化,許多 Java 程式都能在數毫秒內啟動。但即便如此,一個使用網頁應用程式框架,外加用於 XML 處理、資料庫持久化等函式庫的大型伺服器應用程式,仍可能需要數秒甚至數分鐘才能啟動。

應用程式啟動的重複性

應用程式往往會重複做相同的事情。例如每次啟動時,本質上都做著一樣的行為:

  • 掃描相同的 JAR 檔案
  • 讀取、解析、載入、連結相同的類別
  • 執行相同的靜態初始化器
  • 透過反射來組態相同的應用程式物件

這代表 Java 程式的啟動過程是高度可預測的。因此,若能預先(ahead of time)完成這些初始化步驟並快取其結果,非僅僅在即時(just in time)執行的話,就能夠顯著地改善並加速啟動時間。

因此,JEP 483 核心目標是讓 JVM 在程式首次執行時,記錄並快取這些重複性的啟動工作,然後在後續啟動時直接復用快取結果,而不是每次都重新執行。以 Project Leyden 的角度來看:

希望將某些啟動時的工作提前執行,而不是等到應用程式真正啟動時才進行。

這種方法可以顯著縮短應用程式的啟動時間,特別是對於大型伺服器應用程式而言,啟動時間可能從數秒到數分鐘不等。因此,任何改善都將帶來顯著的效益,並且可節省營運成本。

JEP 483 概觀

JEP 483 的目標是要改善 Java 程式的啟動時間,讓 HotSpot Java 虛擬機在啟動時,應用程式的類別就能立即可用,並處於已載入與已連結的狀態。實現方式為:

  • 監測應用程式的執行過程,並記錄其載入與連結的類別資訊
  • 將這些類別的載入與連結形式快取,供未來的 JVM 啟動時直接使用

此外,本提案也將奠定基礎,為未來進一步提升啟動時間與熱身時間(warmup time)提供可能性。

優點

  • 提升程式啟動速度:利用大多數程式的啟動方式大致相同的特性,透過快取機制減少每次啟動時的重複載入與連結開銷。測試顯示可改善高達 42% 的啟動時間
  • 不需修改應用程式、函式庫或框架:不需要開發人員修改現有程式碼,即可受益於加速效果
  • 不需更改 java 啟動指令:除了與此功能相關的額外指令參數外,不會影響應用程式的原有啟動方式
  • 不依賴 jlinkjpackage:不需要透過 jlink 建構自訂 JDK,也不要求使用 jpackage 來打包應用程式
  • 為未來的啟動與熱身時間最佳化奠定基礎:不僅加速初次啟動,還有助於 JVM 在運行期間更快達到峰值效能

缺點

  • 目前僅支援由 JDK 內建類別載入器(built-in class loader)所載入的類別,包括類別路徑、模組路徑和 JDK 內建類別
  • 不支援使用者自訂的類別載入器
  • 需要額外的訓練執行步驟來建立快取
  • 訓練執行與實際執行環境必須保持高度一致性
  • 某些特定情況下無法預先載入類別

介紹

預先快取機制

JEP 483 擴展了 HotSpot JVM 以提供「預先快取(ahead-of-time cache, AOT cache)」的機制,能夠記錄並儲存經過讀取、解析、載入和連結後的類別。一旦針對特定應用程式建立快取後,它可以在該程式後續的運行中重複使用,藉此改善啟動時間。

建立與使用預先快取

建立快取需要兩個步驟。首先,為了要錄製應用程式的 AOT 組態,我們要先做一次訓練執行。在下例中會將結果錄製到 app.aotconf 檔案中:

$ java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
       -cp app.jar com.example.App ...

其次,使用剛剛記錄的組態來建立預先快取,並將它存放在 app.aot 檔案中。本步驟並不會真的執行應用程式,只會建立快取:

$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
       -XX:AOTCache=app.aot -cp app.jar

隨後,在測試或生產環境中,只需將快取加入與應用程式一起執行即可:

$ java -XX:AOTCache=app.aot -cp app.jar com.example.App ...

如果快取檔案無法使用或不存在,那麼 JVM 會發出警告訊息並繼續正常執行。此外,AOT 組態(.aotconf)與快取(.aot)的格式未來可能會變更,因此無法保證會向後相容。

效能提升範例

藉由預先快取機制,JVM 原本會在程式執行時「即時」進行的類別讀取、解析、載入與連結等工作,被「提前」移到前一步驟的快取建立階段來完成。如此一來,在程式啟動時,所需的類別能立即從快取中取得,可明顯地加快啟動速度。

舉例來說,以下是一個雖然簡短但使用了 Stream API 的程式,因此會導致 JVM 讀取、解析、載入並連結將近 600 個 JDK 類別:

import java.util.*;
import java.util.stream.*;

public class HelloStream {
    public static void main(String ... args) {
        var words = List.of("hello", "fuzzy", "world");
        var greeting = words.stream()
            .filter(w -> !w.contains("z"))
            .collect(Collectors.joining(", "));
        System.out.println(greeting);  // hello, world
    }
}

執行結果:

  • JDK 23 執行時間:0.031 秒
  • JDK 24(啟用 AOT 快取)執行時間:0.018 秒
  • 效能提升:42%
  • AOT 快取大小:11.4 MB

另一個具有代表性的伺服器應用程式是 Spring PetClinic(版本 3.2.0),它在啟動時會載入並連結大約 21,000 個類別。底下是它的優化結果:

  • JDK 23 啟動時間:4.486 秒
  • JDK 24(啟用 AOT 快取)啟動時間:2.604 秒
  • 效能提升:42%(巧合與 HelloStream 相同)
  • AOT 快取大小:130 MB

如何訓練 JVM?

在訓練執行中,JVM 會記錄應用程式的組態與執行歷史,以便在後續的測試與正式執行時使用它們。因此,最理想的訓練執行就是直接使用正式執行)。然而,在許多情況下,使用正式執行來訓練並非切實可行,尤其是伺服器應用程式,因為它們通常會:

  • 產生日誌檔案
  • 開啟網路連線
  • 存取資料庫

因此,老喬建議對於這類應用建立一個盡可能模擬實際生產執行的訓練執行。例如:

  • 完整載入應用程式組態
  • 執行一般的正式環境程式碼路徑
  • 盡可能模擬應用程式的行為(如使用本機網路設定、模擬資料庫等)

一種有效的方法是在程式中,特別為訓練專門建立一個額外的 main 類別,例如 com.example.AppTrainer。它可以:

  1. 呼叫正式 main 類別
  2. 模擬應用的不同運行模式
  3. 使用暫存的日誌目錄、本機網路環境
  4. 連線模擬的資料庫(如果需要的話)

如果已經有類似的 main 類別,例如用來做整合測試的類別,就很適合直接用來做為訓練執行。

最佳化訓練執行的技巧

  1. 確保訓練執行載入與正式執行相同的類別

訓練執行的目標是優化啟動時間,因此應確保它載入與正式執行相同的類別。我們可以使用下列的方式來檢查實際載入的類別,或是使用 JDK Flight Recorder 的 jdk.ClassLoad 事件。

$ java -verbose:class -cp app.jar com.example.App
  1. 避免載入正式執行中不需要的類別

我們希望能夠減少預先快取的大小,所以要避免在訓練執行時載入了正式執行中不會使用的類別。另外,我們也要避免載入使用測試框架所撰寫的測試套件。例如: @Test void testSomething() { ... }。之後開發團隊可能會提供過濾機制,讓開發者可以篩選哪些類別應該被快取。

  1. 適當模擬外部服務

如果在正式環境中,程式會與網路上的其他主機互動或存取資料庫,那麼在訓練階段時我們可能會希望「模擬」這些互動,以確保相關的類別能夠被載入。不過,若這些模擬可能會導致在正式環境中不需要的額外類別也被快取。因此,未來可能會提供某種機制,讓我們可以從快取中過濾這類類別。

如果因為某些原因,我們無法模擬這類互動說導致它們未能包含在訓練階段中,那麼在正式環境中它們會如往常一樣,從類別路徑或模組中即時載入。

  1. 執行廣泛但快速的驗證測試

請將訓練的重點放在執行一組涵蓋範圍廣泛但時間短的驗證情境上,這類測試有時也稱為「冒煙測試(smoke tests)」或「健全性測試(sanity tests)」。這通常已足以載入你在正式環境中所需的大部分類別。

我們應該要避免執行那些涵蓋稀有邊界情況或不常使用功能的大型測試套件,也應避免執行壓力測試或回歸測試,因為這些測試往往無法與啟動優化無關。

請注意,快取的目的不是為了要涵蓋所有可能場景的使用範圍,而是為了要服務那些大部分最常見且典行的程式行為。

  1. 確保訓練執行與正式執行的相似性

預先快取的有效與否,取決於訓練階段所執行的內容與正式執行時的行為有多相似。若訓練階段無法涵蓋正式執行的重點或差異過大,那麼快取的效果就會降低。

訓練與正式執行需保持一致性

如果我們想要在正式執行時,享受到訓練階段產生的預先快取和效益,那麼訓練階段與之後的每一次執行,都必須在本質上保持一致。

  • 所有執行必須使用相同的 JDK 發行版本,並且使用相同的硬體架構(例如 x64 或 aarch64)與作業系統
  • 所有執行必須保持一致的類別路徑
    • 在正式執行中可以額外加上附加的類別路徑,但僅限於「加在訓練階段類別路徑的後方」
    • 除此之外,類別路徑必須完全相同
    • 類別路徑中只能包含 JAR 檔案,不支援資料夾路徑,因為 JVM 無法有效檢查資料夾內容的一致性
  • 所有執行必須擁有一致的模組命令列選項與模組圖
    • 如果使用了下列選項,則其參數必須完全相同:
      • -m
      • --module
      • -p
      • --module-path
      • --add-modules
      • --enable-native-access
    • 不允許使用下列選項:
      • --add-exports
      • --add-opens
      • --add-reads
      • --illegal-native-access
      • --limit-modules
      • --patch-module
      • --upgrade-module-path
  • 所有執行不得使用某些 JVMTI 代理:
    • 不能使用任意重寫類別檔案,特別是使用 ClassFileLoadHook 的 JVMTI 代理
    • 不得使用呼叫 AddToBootstrapClassLoaderSearchAddToSystemClassLoaderSearch 的 JVMTI 代理

若違反了上述任一條件,則 JVM 預設會發出警告並忽略該快取。

檢查 JVM 配置

如果我們想確認 JVM 是否已正確設定為使用預先快取,那麼我們可以在命令列中加入 -XX:AOTMode=on 選項:

$ java -XX:AOTCache=app.aot -XX:AOTMode=on \
       -cp app.jar com.example.App ...

在使用了此選項之後,如果上述任何一致性條件被違反、或指定的快取不存在時,JVM 將會直接報錯並結束執行。

⚠️ 注意:-XX:AOTMode=on 應僅用於診斷目的,避免在正式環境中使用。否則當遇到某些無法控制的情況(例如雲端服務商啟用了使用 JVMTI 或 ClassFileLoadHook 的監控功能)發生時,程式可能會無法啟動。

如果你需要完全關閉預先快取,可以使用 -XX:AOTMode=off,也可以使用 -XX:AOTMode=auto 設定為「自動模式」。這時時 JVM 會嘗試讀取 -XX:AOTCache 所指定的快取;若快取無法使用或不存在,JVM 會發出警告並繼續執行。

有兩項特別的例外情況,允許正式與訓練執行的階段不一致:

  1. 可以使用不同的垃圾回收器
  2. 可以使用不同的主類別,讓我們在設計訓練階段時擁有更多彈性

總結

JEP 483 為 Java 平台帶來的預先類別載入與連結機制,不僅大幅改善了應用程式的啟動效能,更重要的是它保留了 Java 的動態特性,讓開發者無需在彈性與效能之間做出取捨。這項改進特別適合企業級應用程式和微服務架構,能有效降低雲端部署的營運成本。

展望未來,這項技術還有很大的發展空間。開發團隊計畫簡化快取建立流程、提供更靈活的訓練選項,並擴展對 ZGC 等現代垃圾回收器的支援。隨著這些改進的逐步到位,我們可以期待 Java 應用程式在啟動效能方面有更多突破性的進展。

本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

17 − ten =

目錄
返回頂端