Java 22 的 JEP 458 為 Java 開發流程帶來了一項非常重大的改變,使得開發人員擁有更便捷的程式開發體驗,特別是在專案初期和原型開發時期。這項新功能允許我們直接使用命令列去執行多個 Java 原始碼檔案,而無需事先編譯它們。
本文將深入介紹這項新功能的細節、設計理念、以及對開發流程的影響。下面將透過幾個實際的範例來展示它如何能夠簡化我們的開發過程,並介紹它們的應用方式。
目錄
前言
我們都知道 Java 程式語言擅長於大型且複雜的應用程式,它們通常會由大團隊開發並維護達數年之久。不過,在許多大型應用程式的早期階段,我們會開發較小的原型程式並驗證其功能可行性,而不會直接撰寫完整可交付的成品。此時的專案結構可能還不存在,而且即使出現,也會產生頻繁的變化。在這個階段,快速迭代和徹底變更是常常發生的事。
因此,在目前的 Java 開發環境中,我們常常面臨著這樣普遍的困境:當專案從單一檔案擴展到多個檔案時,開發過程往往需要一個明顯的轉變。這種轉變通常牽涉到專案架構的變化、建構流程的修改、學習新的指令,或是依賴某些 IDE 的功能,這對於初學者來說特別具有挑戰性。
近年來,JDK 中增加了幾個有助於快速修改和探索的功能,包括用於測試程式碼片段的交互式 JShell,以及用於快速構建 Web 應用原型的簡易 Web 伺服器。在 JDK 11 中,JEP 330 加強了 java 應用程式啟動器,使其能夠直接執行副檔名為 .java
的原始碼檔案,而不需要先行編譯。
例如,假設檔案 Prog.java 宣告了兩個類別:
class Prog {
public static void main(String[] args) { Helper.run(); }
}
class Helper {
static void run() { System.out.println("Hello!"); }
}
然後在命令列執行下面的指令時,會在記憶體中直接編譯兩個類別,並執行該檔案中宣告的第一個類別的 main
方法:
$ java Prog.java
為什麼我們需要擴展這項功能?
這種低儀式感的程式執行方式有著一個重要的限制:應用程式的所有原始碼都必須放在同一個 .java 檔案之中。如果我們有數個 .java 檔案,就必須改回使用 javac 先行編譯全部檔案。對於有經驗的開發人員來說,通常會建立專案設定來進行建構工作(build job),例如 IDE 設定或 Maven / Gradle 等等。
如果我們只是想進行實驗或驗證想法的話,將隨意的測試塗鴨程式轉變為正式的專案結構是非常煩人的。特別是在專案的早期階段,開發人員需要頻繁地修改和測試程式碼,而重頭建置一個繁瑣的編譯過程多多少少都會降低開發效率,特別是傳統的編輯→編譯→執行循環在快速迭代開發時顯得特別冗長。
除此之外,對於初學者來說,從單一 .java 檔案過渡到兩個或更多檔案的過程中有著明顯的學習階段變化:他們必須暫停學習語法,並改為學習操作 javac,或者學習第三方建構工具,或者學會依賴 IDE 的魔力。更重要的是,許多現代的 Java 開發者已經不太熟悉或不太習慣直接使用 javac 編譯,他們更傾向依賴於建構工具,使得從簡單程式過渡到複雜專案時的學習曲線變得更陡峭。
如果我們可以將設置專案與建構的階段延後處理,甚至是在快速測試後就丟棄原型並避免設置的話,我們就可以省下許多的時間和力氣。而一些簡單的程式很可能會永遠保持其原型的原始碼形式。
以上種種促使社群想要加強現有的 java 啟動器,讓它能夠在不用先行編譯所有 .java 原始檔案的前提下執行程式。傳統的編輯→編譯→執行週期變成了簡單的編輯→執行。我們可以自行決定何時開始設定專案建構流程,而不是因為工具的限制所以被強迫這樣做。
JEP 458 概觀
JEP 458 的目的是增強 java
應用程式啟動器,使其能夠執行由多個 Java 原始碼檔案組成的程式,而無需事先編譯。它讓小型或原型程式轉移到大型程式的過程能夠更加平滑,使開發人員能夠選擇是否以及何時需要設定建構工具。
啟動器會自動編譯所需的類別,並呼叫指定類別的 main
主方法。它也支援 Java 模組,允許在模組路徑上指定模組,並在啟動時指定主類別。
優點
- 無需額外的編譯步驟與設定複雜的建構工具即可直接執行多檔案程式
- 支援快速修改和測試程式碼,縮短開發循環並提升開發效率,適用於小型專案和原型設計實驗
- 特別適合初學者和教學場景,可以降低學習與開發門檻
- 開發人員可以自行決定何時引入建構工具以進行專案轉換
缺點
- 不適合大型專案,因為複雜專案仍需要建構工具支援更好的組織結構、依賴管理和優化
- 啟動器需要即時編譯原始碼,因此可能會延遲啟動並影響效能
- 不支援跨模組的原始碼程式執行,也不支援在原始碼程式中輕鬆使用外部函式庫依賴
介紹
底下舉一個實際的例子。假設目錄中包含了兩個檔案 Prog.java 和 Helper.java,而每個檔案皆宣告一個類別:
// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); }
}
// Helper.java
class Helper {
static void run() { System.out.println("Hello!"); }
}
當我們輸入並執行 java Prog.java
時,會在記憶體中編譯 Prog
類別並呼叫它的 main
方法。因為 Prog
類別中引用了 Helper
類別,所以 java 啟動器會在檔案系統中找到 Helper.java 檔案,並在記憶體中編譯該類別。如果 Helper
類別中的程式碼引用了其他類別,例如 HelperAux
,那麼啟動器會找到 HelperAux.java 並編譯它。
也就是說啟動器會自動:
- 找到並編譯 Prog.java
- 識別並編譯相關的 Helper.java
- 反覆上述過程直到所需的類別皆被識別且編譯完成
- 執行程式的主要類別的主方法
這個過程是漸進式的,只有被實際使用到的類別才會被編譯,因此大大提升了開發效率。
互相引用與優先級
當多個 .java 檔案中的類別互相引用時,java 啟動器不保證 .java 檔案編譯的任何特定順序或時機。例如,啟動器可能會在 Prog.java 之前先編譯 Helper.java。部分程式碼可能在程式開始執行之前編譯完成,而其餘的程式碼可能在執行過程時才延遲編譯。編譯和執行原始檔程式的過程將在下面詳細描述。
由於只有程式碼引用到的 .java 檔案才會被編譯,所以我們可以放心地試驗新版程式碼,而不必擔心舊版會被意外地編譯。例如,假設目錄裡有 OldProg.java 檔案,裡面的舊版 Prog
類別呼叫 Helper
類別中名為 go
而不是 run
的方法。當運行 Prog.java 時,OldProg.java 的存在及其潛在錯誤是無關緊要的(即 go
方法已改名為 run
)。
單一 .java 檔案中可以宣告多個類別,而它們都會被一起編譯。在主要 .java 檔案中已宣告的類別會優先於在其他 .java 檔案中宣告的類別。例如,假設前例的 Prog.java 檔案中也宣告了一個 Helper
類別,儘管該名稱的類別已經在 Helper.java 中宣告,但是當 Prog.java 中的程式碼引用 Helper
時,會優先使用在 Prog.java 中共同宣告的 Helper
類別,啟動器會略過並且不會搜尋 Helper.java 檔案。
原始碼程式中重複的類別宣告是被禁止的。也就是說,不允許在同一個 .java 檔案中,或在程式的不同 .java 檔案中出現兩個同名類別的宣告。例如,下面的 Prog.java 和 Helper.java 中,Aux
類別意外地在兩者中都被宣告:
// Prog.java
class Prog {
public static void main(String[] args) { Helper.run(); Aux.cleanup(); }
}
class Aux {
static void cleanup() { System.out.println("Aux cleanup in Prog"); }
}
// Helper.java
class Helper {
static void run() { System.out.println("Helper run"); }
}
class Aux {
static void cleanup() { System.out.println("Aux cleanup in Helper"); }
}
當執行 java Prog.java
時:
- 先編譯 Prog.java 中的
Prog
和Aux
類別 - 呼叫
Prog
的main
方法 - 發現
main
對Helper
的引用 - 啟動器找到 Helper.java 並編譯該檔案中的
Helper
和Aux
類別 - 由於
Aux
類別已在第一步宣告並編譯,因此不允許重複宣告 Helper.java 中的同名Aux
類別 - 啟動器停止執行並報告錯誤

java 啟動器的原始檔模式僅會觸發單一 .java 檔案。如果命令列後方列出了其他 .java 檔名,則它們會變成第一個檔案 main
方法的傳入值。例如,java Prog.java Helper.java
會生成含有字串 "Helper.java"
的陣列,並會傳給 Prog
類別的 main
方法做為傳入參數使用。
如何使用已編譯好的類別
原始檔啟動功能可以設定其他類別檔案或函式庫的路徑,以便使用已編譯好的 .class 檔或是 .jar 檔。假設目錄中有兩個小程式和一個輔助類別,以及一些函式庫 JAR 檔案:
- Prog1.java
- Prog2.java
- Helper.java
- library1.jar
- library2.jar
我們可以設定 --class-path '*'
來設定類別和函式庫的路徑。選項中的 '*'
參數會將目錄中所有的 JAR 檔案放在類別路徑上。另外,星號用引號括起來以免被 shell 展開:
$ java --class-path '*' Prog1.java
$ java --class-path '*' Prog2.java
老喬建議大家可以將多個 JAR 檔案放在一個單獨的 libs 目錄中,這樣就可以透過 --class-path 'libs/*'
去使用它們,會比較方便。
啟動器如何找到原始檔
java 啟動器要求多檔案原始碼程式中的檔案必需遵守既有的 java 套件目錄結構排列,其中目錄結構依循著套件結構。這代表:
- 根目錄中的原始檔必須宣告成未命名套件中的類別,並且
- 根目錄下子目錄 foo/bar 中的原始檔必須宣告為命名套件
foo.bar
中的類別
假設某個專案的根目錄下有 Prog.java,它宣告未命名套件中的類別,還有一個子目錄 pkg,其中的 Helper.java 在套件 pkg
中宣告 Helper
類別。那麼它們的檔案內容會有下面的套件宣告:
// Prog.java 沒有宣告套件,並且放在根目錄中
class Prog {
public static void main(String[] args) { pkg.Helper.run(); }
}
// pkg/Helper.java 有宣告套件,並且放在根目錄下的子目錄 pkg/ 中
package pkg;
public class Helper {
public static void run() { System.out.println("Helper run"); }
}
執行 java Prog.java
時會在 pkg 子目錄中找到 Helper.java,並且編譯產生 Prog
類別中程式碼所需的 pkg.Helper
類別。如果我們在 Prog.java 檔案中加上套件宣告,或者在 Helper.java 中未指定或指定 pkg
以外套件的話,那麼 java Prog.java
將會失敗。


計算根目錄
java 啟動器會根據初始 .java 檔案的套件名稱和檔案系統位置去計算原始碼檔案樹的根目錄。以上例中的 java Prog.java
來說,初始檔案是 Prog.java,它在未命名套件中宣告了一個類別,因此原始碼檔案樹的根目錄即是包含了 Prog.java 的目錄。另一方面,如果我們修改 Prog.java 並增加宣告命名套件 a.b.c
的話,那麼它必須放在套件層次結構中相應的目錄裡:
dir/
a/
b/
c/
Prog.java
此外,啟動指令必須改成 java dir/a/b/c/Prog.java
,此時原始碼檔案樹的根目錄是 dir。
- 如果將 Prog.java 的套件宣告改成
b.c
,那麼根目錄將是 dir/a - 如果將 Prog.java 的套件宣告改成
c
,那麼根目錄將是 dir/a/b - 如果沒有宣告套件,那麼根將是 dir/a/b/c
- 如果 Prog.java 宣告成其他套件,例如
p
,由於該套件名稱與檔案系統中檔案路徑的後綴無法對應,所以程式無法啟動
模組化原始碼程式
到目前為止的 JEP 458 範例中,從 .java 檔案編譯的類別都位於未命名模組中。如果原始碼檔案樹的根包含 module-info.java 檔案,則該程式會視為模組化,並且在原始碼樹中的類別會屬於 module-info.java 所聲明的命名模組中。
我們可以用下列指令去使用當前目錄中的模組化程式庫:
$ java -p . pkg/Prog1.java
$ java -p . pkg/Prog2.java
或者,如果模組化的 JAR 檔案位於 libs 目錄下,則可以加上 -p libs
。
啟動時的語義
自從 JDK 11 以來,啟動器的原始檔模式其工作方式如同下列指令:
java <other options> --class-path <path> <.java file>
非正式地等同於:
javac <other options> -d <memory> --class-path <path> <.java file>
java <other options> --class-path <memory>:<path> <first class in .java file>
有了 JEP 458 啟動多檔案原始碼程式的能力後,此模式現在的工作方式非正式地等同於:
javac <other options> -d <memory> --class-path <path> --source-path <root> <.java file>
java <other options> --class-path <memory>:<path> <launch class of .java file>
其中:
- <root> 即為前述定義所計算出來的根目錄
- <launch class of .java file> 即是 .java 檔案的啟動類別(請參考下方定義)
--source-path
選項指定根目錄,表示初始 .java 檔案中所宣告的類別(如上例中的Prog
),可能會引用到原始碼樹 <root> 中其他 .java 檔案裡宣告的類別(如上例中的Helper
)
位於初始 .java 檔案中的類別會優先於其他 .java 檔案中的類別。如果 Prog.java 中宣告了 Helper
類別,那麼執行 javac --source-path dir dir/Prog.java
就不會編譯 Helper.java。
啟動時的操作
當 java 啟動器在原始檔模式下執行(例如,java Prog.jav
a)時,它會採取以下的步驟:
- 如果檔案是 “shebang” 形式(意即第一行以
#!
開頭)則設定給編譯器的原始碼路徑為空,以避免編譯其他原始檔。繼續執行步驟 4。 - 計算原始碼檔案樹的根目錄(請參考前述的「計算根目錄」章節)。
- 確定原始碼程式的模組:
- 如果根目錄中存在 module-info.java 檔案,則使用該模組宣告去定義一個模組,並設定它包含所有從原始碼檔案樹中 .java 檔案編譯出來的類別。
- 如果 module-info.java 不存在,則所有編譯出來的類別都將屬於未命名模組。
- 編譯初始 .java 檔案中的所有類別,以及其他延伸的 .java 檔案(意即宣告了初始檔案中引用到的類別),並將這些生成的 class 檔案儲存在記憶體的快取中。
- 確定初始 .java 檔案的啟動類別:
- 如果初始檔案中的第一個頂層類別宣告了標準
main
方法,或存在 JEP 463 中定義的其他標準main
入口點(站內介紹),則該類別為啟動類別。 - 否則,如果初始檔案中存在著與檔案同名的頂層類別宣告了標準
main
方法,則該類別為啟動類別。 - 否則,沒有啟動類別,啟動器會報告錯誤並停止。
- 如果初始檔案中的第一個頂層類別宣告了標準
- 使用自定義類別載入器從記憶體快取中載入啟動類別,然後呼叫該類別的標準
main
方法。
步驟 5 中,選擇啟動類別的過程保持了與 JEP 330 的相容性(啟動單一原始碼程式),並確保當原始碼程式從單一檔案增長到多個檔案時,會使用相同的 main
方法。它也確保了 “shebang” 檔案能夠繼續工作,因為在檔案中宣告的類別名稱可能會與檔案名稱不同。
最後,JEP 458 保持了接近於啟動已編譯過程式的體驗,所以當單一原始碼程式增長到需要先執行 javac 編譯再執行 class 檔案時,可以使用相同的啟動類別。
自定義類別載入器的搜尋演算法
當步驟 6 中使用自定義類別載入器去載入類別時——無論是啟動類別或是任何程式執行時所需要載入的其他類別——載入器都會搜尋去模擬編譯時 javac 的 -Xprefer:source
選項的順序。如果某個類別同時存在於原始碼樹中(在 .java 檔案中宣告)和類別路徑上(在 .class 檔案中),則會優先選擇原始碼樹中的類別。舉例來說,載入器對於某個類別 C
的搜尋演算法是:
- 如果在記憶體快取中有找到
C
的類別檔案,則載入器會將快取的類別檔案定義給 JVM 並完成載入C
。 - 若無,則載入器會委託應用程式類別載入器去搜尋是否有類別
C
:- 如果原始碼程式屬於命名模組(有設定模組檔案),則會根據該模組的設定去其引用到的模組中搜尋(被引用的模組需存在於模組路徑或 JDK 中)。
- 如果原始碼程式屬於未命名模組(未設定模組檔案),則會搜尋 JDK 中的預設模組。
- 如果找到,則由應用程式類別載入器去載入
C
。
- 若無,則載入器會尋找與類別名稱(如果請求的類別是成員類別,則為封閉類別)同名的 .java 檔案,即 C.java,該檔案位於與套件相對應的目錄中。
- 如果找到,則編譯 .java 檔中宣告的所有類別。
- 如果編譯成功,則將類別檔案儲存在記憶體快取中,然後載入器會將快取的類別檔案定義給 JVM 並完成載入
C
。 - 如果編譯失敗,則啟動器報告錯誤並以非零退出狀態終止。
- 在編譯 C.java 時,啟動器可能會選擇先編譯其他 C.java 所引用到的 .java 檔,並將生成的類別檔案儲存在記憶體快取中。這種選擇基於啟發式方法,可能會在 JDK 版本之間發生變化。
- 若無,如果原始碼程式屬於未命名模組,則載入器會委託應用程式類別載入器在類別路徑上搜尋
C
的類別檔案。- 如果找到,則由應用程式類別載入器完成載入
C
。
- 如果找到,則由應用程式類別載入器完成載入
- 若無,則代表找不到名為
C
的類別,載入器拋出ClassNotFoundException
。
從類別路徑或模組路徑載入的類別不能引用 .java 檔案即時編譯的類別。也就是說,當遇到已編譯類別中的引用時,永遠不會查詢原始碼樹。
編譯時和啟動時編譯的差異
javac 編譯器和 java 啟動器的原始檔模式在「如何編譯程式碼」的行為上存在著下列的主要差異:
- 在 java 的原始檔模式下,在 .java 檔案中找到的類別宣告可能會在程式執行期間遞增地按需編譯,而不會在執行開始前一次性全部編譯。這代表程式有可能會在已經開始執行後,因為啟動器發生編譯錯誤而終止。為了讓「編輯→執行週期」能夠在原始檔模式下快速有效地工作,導致它與 javac 編譯的原型設計不同。
- 通過反射存取的類別其載入方式與直接存取的類別相同。例如,如果程式呼叫
Class.forName("pkg.Helper")
,那麼啟動器的自定義類別載入器將嘗試載入套件pkg
中的Helper
類別,這可能會導致編譯 pkg/Helper.java。類似地,如果透過Package::getAnnotations
查詢套件的註釋,則原始碼樹中適當放置的 package-info.java 檔案(如果存在)將在記憶體中編譯並載入。 - 註釋處理被禁用,類似於將
-proc:none
傳遞給 javac 時的情況。 - 無法執行 .java 檔案跨越多個模組的原始碼程式。
最後兩個限制可能會在未來被移除。
總結
JEP 458 的推出代表了 Java 平台在提升開發者體驗方面邁出了重要的一步,為 Java 開發流程帶來了更大的靈活性。特別是在早期開發階段或小型專案中,讓開發人員能夠更專注於程式碼本身,並且也照顧到了初學者的需求,讓開發過程更加流暢和直觀。
雖然這項功能不會取代傳統建構工具在大型專案中的地位,但它確實為 Java 開發提供了一個更靈活的選擇。這種漸進式的開發方式,將使 Java 程式設計的入門門檻更低。然而,對於大型專案或需要更高性能的場景,傳統的編譯和建構工具仍然是更佳的選擇。
本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!