Java 應用程式效能長期是開發社群熱議話題。因語言設計初衷以解釋執行支援可攜性目標,早期 Java 執行環境效能遠低於 C、C++ 等編譯語言。雖然編譯語言提供更高效能,但產生的程式碼僅能在有限系統執行。過去十年,Java 執行環境廠商開發出複雜的動態編譯器,通稱即時(Just-in-time,JIT)編譯器。程式執行時,JIT 編譯器選擇最頻繁執行的方法編譯成本地碼。執行時而非執行前進行本地碼編譯(C/C++ 屬於後者),保證了可攜性需求。某些 JIT 編譯器甚至不使用解釋器即可編譯所有程式碼,但仍透過執行時操作保持 Java 應用可攜性。
得益於動態編譯技術多項改進,現代 JIT 編譯器在許多應用中能產生與 C/C++ 靜態編譯相當的效能。但許多開發人員仍認為——基於經驗或傳聞——動態編譯可能嚴重干擾程式操作,因編譯器必須與應用共享 CPU。部分開發者強烈呼籲 Java 程式碼靜態編譯,堅信可解決效能問題。對某些應用與執行環境,此觀點正確:靜態編譯能大幅提升 Java 效能,甚至是唯一實用選擇。但靜態編譯 Java 應用在獲取高效能同時帶來諸多複雜性,一般 Java 開發者可能未充分體會 JIT 動態編譯器優勢。
本文探討 Java 語言靜態與動態編譯相關議題,聚焦即時(RT)系統。簡述 Java 解釋器運作原理、現代 JIT 編譯器本地碼編譯優劣勢。介紹 IBM WebSphere Real Time 發布的 AOT 編譯技術及其優劣勢。比較兩種編譯策略,指出適合使用 AOT 編譯的應用領域與執行環境。關鍵在於:兩種編譯技術並不互斥,即使在各自最有效的應用中,也分別存在影響應用的優劣勢。
Java 程式最初由 Java SDK javac 編譯成平台無關的類別檔格式。此格式可視為 Java 平台,定義執行 Java 程式所需所有資訊。Java 程式執行引擎(Java Runtime Environment,JRE)包含為特定本地平台實作 Java 平台的虛擬機。例如 Linux Intel x86、Sun Solaris、AIX 上的 IBM System p,各擁有一個 JRE,實作所有本地支援以正確執行為 Java 平台編寫的程式。
Java 平台程式表示的重要部分是位元組碼序列,描述每個類別中每個方法執行的操作。位元組碼使用理論上無限大的運算元堆疊描述計算。此基於堆疊的表示提供平台無關性,因不依賴特定本地平台 CPU 可用暫存器數量。可在運算元堆疊執行的操作定義獨立於所有本地處理器指令集。Java 虛擬機(JVM)規範定義這些位元組碼的執行。執行 Java 程式時,任何特定本地平台的 JRE 都必須遵守 JVM 規範規則。
因基於堆疊的本地平台極少(Intel X87 浮點協處理器為例外),大多數本地平台無法直接執行 Java 位元組碼。早期 JRE 透過解釋位元組碼執行 Java 程式:JVM 在迴圈中重複操作——取得下一個位元組碼、解碼、從運算元堆疊取得運算元、依 JVM 規範執行操作、將結果寫回堆疊。優點在簡單性:JRE 開發者只需編寫處理每種位元組碼的程式碼;位元組碼少於 255 種,實作成本較低。缺點是效能:這是早期造成許多人對 Java 平台不滿的問題,儘管擁有其他優點。
解決與 C/C++ 等語言效能差距,意味著以不犧牲可攜性的方式開發 Java 平台本地碼編譯。
雖然「一次編寫,到處執行」口號可能非在所有情況嚴格成立,但對大量應用確實如此。本地編譯本質上是平台專屬的。Java 平台如何在不犧牲平台無關性下實現本地編譯效能?答案是使用 JIT 編譯器動態編譯,此法已使用十年(見圖 1):

使用 JIT 編譯器時,Java 程式按每次編譯一個方法形式編譯,因在本地處理器指令中執行以獲更高效能。此過程產生方法的內部表示,不同於位元組碼但層級高於目標處理器本地指令(IBM JIT 使用運算式樹序列表示方法操作)。編譯器執行一系列優化提高品質與效率,最後執行程式碼生成步驟將優化後內部表示轉換成目標處理器本地指令。產生的程式碼依賴執行環境執行某些活動,如確保型別轉換合法性、分配無法在程式碼中直接執行的某類物件。JIT 編譯器操作的編譯執行緒與應用程式執行緒分離,應用程式無需等待編譯完成。
圖 1 也描述用於觀察執行程式行為的分析框架,透過週期性對執行緒取樣找出頻繁執行的方法。該框架還為專門分析的方法提供工具,儲存此次執行中可能不變的動態值。
因 JIT 編譯過程在程式執行時發生,能保持平台無關性:發布的仍是中立的 Java 平台程式碼。C/C++ 等語言缺乏此優勢,因它們在程式執行前進行本地編譯;發布給本地執行環境的是本地碼。
雖透過 JIT 編譯保持平台無關性,但付出代價。因在程式執行時編譯,編譯程式碼時間計入程式執行時間。編寫過大型 C/C++ 程式的人都知道,編譯過程往往較慢。
為克服此缺點,現代 JIT 編譯器使用兩種方法任一(某些情況同時使用):第一,編譯所有程式碼但不執行耗時分析與轉換,快速產生程式碼。因生成速度快,編譯開銷雖可觀察但易被重複執行本地碼帶來的效能改善掩蓋。第二,將編譯資源僅分配給少量頻繁執行的方法(稱作熱方法)。低編譯開銷更易被重複執行熱程式碼帶來的效能優勢掩蓋。許多應用僅執行少量熱方法,此法有效實現編譯效能成本最小化。
動態編譯器主要複雜性在於權衡:了解編譯程式碼預期獲益,判斷方法執行對整體程式效能貢獻多大。極端例子:程式執行後,您非常清楚哪些方法對特定執行效能貢獻最大,但編譯這些方法毫無用處,因程式已結束。另一極端:程式執行前無法知曉哪些方法重要,但每種方法潛在受益最大化。大多數動態編譯器介於兩極端,透過權衡了解方法預期獲益的重要程度運作。
Java 語言需動態載入類別,對編譯器設計有重大影響。若待編譯程式碼引用的其他類別尚未載入怎麼辦?例如方法需讀取某未載入類別的靜態欄位值。Java 要求首次執行類別引用時載入並解析至當前 JVM。直到首次執行才解析引用,意味著無地址可供載入靜態欄位。編譯器如何處理?編譯器產生程式碼,在未載入類別時載入並解析。類別一旦解析,以執行緒安全方式修改原始程式碼位置以直接存取靜態欄位地址(此時已知地址)。
IBM JIT 編譯器投入大量精力使用安全高效的程式碼修補技術,使解析類別後,執行的本地碼只載入欄位值,彷彿編譯時已解析欄位。另一做法是產生程式碼,在查明欄位位置前持續檢查是否已解析欄位,再載入值。對於由未解析變為已解析且頻繁存取的欄位,這種簡單過程可能帶來嚴重效能問題。
動態編譯 Java 程式有重要優點,甚至能比靜態編譯語言更好地產生程式碼。現代 JIT 編譯器常向產生程式碼插入鉤子收集程式行為資訊,以便若選擇方法重新編譯,可更好優化動態行為。
絕佳範例是收集特定 arraycopy 操作長度。若發現每次執行長度基本不變,可為最常用長度產生專門程式碼,或調用調整為該長度的程式碼序列。因記憶體系統與指令集設計特性,通用記憶體複製例程執行速度通常比特定長度複製程式碼慢。例如複製 8 個位元組對齊資料可能僅需 1-2 條指令直接複製,而處理任意位元組數與對齊方式的通用複製迴路可能需 10 條指令複製相同 8 位元組。但即使專門程式碼為特定長度生成,產生程式碼也必須正確執行其他長度複製。程式碼只是使常見長度操作執行更快,平均下來效能改進。此類優化對大多數靜態編譯語言通常不實用,因所有可能執行中長度恆定操作比特定程式執行中長度恆定操作少得多。
另一重要範例是基於類別階層的優化。例如虛擬方法呼叫需查看接收者物件類別呼叫,找出哪個實際目標實作接收者虛擬方法。研究表明:大多數虛擬呼叫僅有一個目標對應所有接收者物件,JIT 編譯器可為直接呼叫產生比虛擬呼叫更有效率的程式碼。透過分析程式碼編譯後類別階層狀態,JIT 編譯器可為虛擬呼叫找到目標方法,產生直接呼叫目標方法的程式碼而非執行較慢虛擬呼叫。當然,若類別階層發生變化並出現額外目標方法,JIT 編譯器可修正最初產生程式碼以執行虛擬呼叫。實踐中極少需此類修正。此外,因可能需此類修正,靜態執行此優化非常麻煩。
因動態編譯器通常只專注編譯少量熱方法,可執行更積極分析產生更好程式碼,使編譯回報更高。事實上,大部分現代 JIT 編譯器也支援重新編譯被認為是熱方法的方法。可使用靜態編譯器(不強調編譯時間)常見的非常積極優化分析轉換這些頻繁執行方法,產生更好程式碼獲更高效能。
這些改進及其他類似改進的綜合效果:對大量 Java 應用,動態編譯已彌補與 C/C++ 等語言靜態本地編譯效能間的差距,某些情況甚至超越後者。
但動態編確確實實有缺點,使其在某些情況非理想方案。例如,因識別頻繁執行方法與編譯這些方法需時間,應用程式通常經歷準備過程,此過程中效能無法達最高值。準備過程出現效能問題有幾個原因:首先,大量初始編譯可能直接影響應用啟動時間。不僅編譯延遲應用達到穩定狀態時間(想像 Web 伺服器經歷初始階段後才能執行實際有用工作),且準備階段頻繁執行方法可能對應用穩定狀態效能作用不大。若 JIT 編譯延遲啟動又不能顯著改善長期效能,則執行此編譯非常浪費。雖所有現代 JVM 都執行調優減輕啟動延遲,但非所有情況能完全解決。
其次,某些應用完全無法容忍動態編譯帶來的延遲。GUI 介面等互動式應用即是例子。此情況下編譯活動可能對使用者體驗造成不利影響,同時又不能顯著改善應用效能。
最後,用於即時環境且具嚴格任務時限的應用,可能無法容忍編譯的不確定性效能影響或動態編譯器本身的記憶體開銷。
因此,雖 JIT 編譯技術已能提供與靜態語言效能相當(甚至更好)的效能水平,但動態編譯不適合某些應用。這些情況下,Java 程式碼提前(Ahead-of-time,AOT)編譯可能是合適方案。
大致說來,Java 語言本地編譯應是為傳統語言(如 C++、Fortran)開發的編譯技術簡單應用。不幸的是,Java 語言本身動態特性帶來額外複雜性,影響 Java 程式靜態編譯程式碼品質。但基本思想相同:程式執行前產生 Java 方法本地碼,以便程式執行時直接使用本地碼。目的在避免 JIT 編譯器執行時效能消耗或記憶體消耗,或避免解釋器早期效能開銷。

動態類別載入是動態 JIT 編譯器面臨挑戰,也是 AOT 編譯更重要的問題。只有執行程式碼引用類別時才載入該類別。因 AOT 編譯在程式執行前進行,編譯器無法預測載入哪些類別。即編譯器無法知曉任何靜態欄位地址、任何物件實例欄位偏移量、任何呼叫實際目標,甚至對直接呼叫(非虛擬呼叫)也是如此。執行時若證明對此類資訊預測錯誤,意味著程式碼錯誤並犧牲 Java 一致性。
因程式碼可在任何環境執行,類別檔可能與編譯時不同。例如 JVM 實例可能從磁碟特定位置載入類別,後續實例可能從不同位置甚至網路載入。設想正在進行錯誤修復的開發環境:類別檔內容可能隨不同應用執行而變化。此外,Java 程式碼可能在程式執行前根本不存在:如 Java 反射服務常在執行時產生新類別支援程式行為。
缺少靜態欄位、類別、方法資訊嚴重限制 Java 編譯器優化框架大部分功能。內聯或許是靜態或動態編譯器應用最重要的優化,但因編譯器無法知曉呼叫目標方法,無法再使用此優化。
AOT 程式碼因此必須在未解析每個靜態欄位、類別、方法引用情況下產生。執行時,每個引用必須利用當前執行環境正確值更新。此過程可能直接影響首次執行效能,因首次執行將解析所有引用。當然,後續執行將從修補程式碼獲益,更直接引用實例、靜態欄位或方法目標。
此外,為 Java 方法產生的本地碼通常需使用僅在單個 JVM 實例中使用的值。例如程式碼必須呼叫 JVM 執行環境中某些執行期例程執行特定操作,如查找未解析方法或分配記憶體。這些執行期例程地址可能在每次 JVM 載入記憶體時變化。因此 AOT 編譯程式碼需綁定至 JVM 當前執行環境才能執行。其他例子有字串地址與常數池項目內部位置。
WebSphere Real Time 中,AOT 本地碼編譯透過 jxeinajar 工具執行。該工具對 JAR 檔中所有類別所有方法應用本地碼編譯,也可選擇性編譯所需方法。結果儲存為 Java eXEcutable (JXE) 內部格式,也可輕易儲存至任意持久容器。
您可能認為對所有程式碼靜態編譯是最好方法,因可在執行時執行最大數量本地碼。但此處可權衡:編譯方法越多,程式碼佔用記憶體越多。編譯後本地碼大約比位元組碼大 10 倍:本地碼密度比位元組碼小,且必須包含程式碼額外中繼資料,以便綁定至 JVM、異常或請求堆疊追蹤時正確執行。構成普通 Java 應用的 JAR 檔通常包含許多極少執行的方法。編譯這些方法消耗記憶體卻無預期收益。相關記憶體消耗包括:儲存程式碼至磁碟、從磁碟取出程式碼裝入 JVM、將程式碼綁定至 JVM。除非多次執行程式碼,否則這些代價無法由本地碼相對解釋的效能優勢彌補。
與大小問題相悖的一事實:編譯過方法與解釋過方法間呼叫(編譯方法呼叫解釋方法或反之)可能比兩類方法各自內部呼叫開銷大。動態編譯器透過最終編譯所有被 JIT 程式碼頻繁呼叫的解釋方法減少此開銷,但若不使用動態編譯器,此開銷不可避免。因此若選擇性編譯方法,必須謹慎操作使已編譯到未編譯方法轉換最小化。為在所有可能執行中避免此問題選擇正確方法非常困難。
雖 AOT 編譯程式碼有上述缺點與挑戰,但提前編譯 Java 程式可提高效能,特別在無法將動態編譯器作為有效方案的環境中。
可透過謹慎使用 AOT 編譯程式碼加快應用啟動:雖此程式碼通常比 JIT 編譯程式碼慢,但比解釋程式碼快許多倍。此外,因載入綁定 AOT 編譯程式碼時間通常比檢測與動態編譯重要方法時間少,能在程式執行早期達到該效能。同理,互動式應用可快速從本地碼獲益,無需使用導致較差回應能力的動態編譯。
即時應用也能從 AOT 編譯程式碼獲重要收益:更具確定性的效能超越解釋效能。WebSphere Real Time 使用的動態 JIT 編譯器針對即時系統使用專門調整:編譯執行緒以低於即時任務優先級運作,並調整避免產生嚴重不確定性效能影響的程式碼。但在某些即時環境,出現 JIT 編譯器不可接受。此類環境通常需最嚴格時限控制。此例中 AOT 編譯程式碼可提供比解釋程式碼更好原始效能,又不影響現有確定性。消除 JIT 編譯執行緒甚至消除了啟動更高優先級即時任務時發生的執行緒搶佔效能影響。
動態(JIT)編譯器支援平台中立性,透過利用應用執行動態行為與載入類別及其階層資訊產生高品質程式碼。但 JIT 編譯器有有限編譯時間預算,且影響程式執行時效能。靜態(AOT)編譯器則犧牲平台無關性與程式碼品質,因無法利用程式動態行為,也不具載入類別或類別階層資訊。AOT 編譯擁有實際無限制編譯時間預算,因 AOT 編譯時間不影響執行時效能,但實踐中開發者不會長期等待靜態編譯步驟完成。
表 1 總結本文討論的 Java 動態與靜態編譯器特性:
| 特性 | 動態 (JIT) | 靜態 (AOT) |
|---|---|---|
| 平台無關性 | 有 | 無 |
| 程式碼品質 | 優秀 | 良好 |
| 利用動態行為 | 是 | 否 |
| 類別與階層知識 | 有 | 無 |
| 編譯時間 | 有限制,有執行時成本 | 限制很少,無執行時成本 |
| 執行時效能影響 | 有 | 無 |
| 編譯方式 | 需謹慎編譯,由 JIT 處理 | 需謹慎編譯,由開發者處理 |
兩種技術都需謹慎選擇編譯方法實現最高效能。動態編譯器由編譯器自行決策,靜態編譯器由開發者選擇。讓 JIT 編譯器選擇編譯方法是否為優點很難說,取決於編譯器在給定情況推斷能力好壞。大多數情況下我們認為這是優點。
因能最好優化執行中程式,JIT 編譯器在提供穩定狀態效能更勝一籌,這在大量生產 Java 系統最重要。靜態編譯可產生最佳互動式效能,因無執行時編譯行為影響使用者預期回應時間。透過調整動態編譯器可在某種程度上解決啟動與確定性效能問題,但靜態編譯在需要時可提供最快啟動速度與最高確定性等級。表 2 在四種執行環境比較兩種編譯技術:
| 環境 | 動態 (JIT) | 靜態 (AOT) |
|---|---|---|
| 啟動效能 | 可調整,但非最好 | 最好 |
| 穩定狀態效能 | 最好 | 良好 |
| 互動式效能 | 一般 | 良好 |
| 確定性效能 | 可調整,但非最好 | 最好 |

圖 3 展示啟動效能與穩定狀態效能總體趨勢:使用 JIT 編譯器初始階段效能很低,因需先解釋方法。隨編譯方法增多及 JIT 編譯所需時間縮短,效能曲線逐漸升高最後達到峰值。AOT 編譯程式碼啟動效能比解釋效能高許多,但無法達到 JIT 編譯器最高效能。靜態程式碼綁定至 JVM 實例產生開銷,故開始效能比穩定狀態低,但能比 JIT 編譯器更快達到穩定狀態效能水平。
沒有一種本地碼編譯技術適合所有 Java 執行環境。某技術擅長的通常正是另一技術弱項。因此需同時使用兩種編譯技術滿足 Java 應用開發者需求。事實上可結合使用靜態與動態編譯提供最大可能效能提升——但必須具備平台無關性,這是 Java 語言主要賣點,故不成問題。
本文探討 Java 語言本地碼編譯議題,主要介紹 JIT 編譯器形式動態編譯與靜態 AOT 編譯,比較兩者優缺點。
雖動態編譯器過去十年實現極大成熟,使大量各類 Java 應用趕上或超越 C++、Fortran 等靜態編譯語言效能。但動態編譯在某些應用類型與執行環境仍不太合適。雖 AOT 編譯號稱動態編譯缺點萬能解決方案,但因 Java 語言本身動態特性,也面臨提供本地編譯全部潛能的挑戰。
這兩種技術都無法解決 Java 執行環境中本地碼編譯所有需求,但反過來可在最有效處作為工具使用。兩技術可相互補充。能恰當使用兩種編譯模型的執行系統可使廣大應用開發環境中的開發者與使用者受益。