深入淺出 JIT 編譯器
JIT 簡介
JIT 是 just in time 的縮寫, 也就是即時編譯編譯器。使用即時編譯器技術,能夠加速 Java 程式的執行速度。下面,就對該編譯器技術做個簡單的講解。
首先,我們大家都知道,通常通過 javac 將程式原始碼編譯,轉換成 java 位元組碼,JVM 通過解釋位元組碼將其翻譯成對應的機器指令,逐條讀入,逐條解釋翻譯。很顯然,經過解釋執行,其執行速度必然會比可執行的二進位制位元組碼程式慢很多。為了提高執行速度,引入了 JIT 技術。
在執行時 JIT 會把翻譯過的機器碼儲存起來,以備下次使用,因此從理論上來說,採用該 JIT 技術可以接近以前純編譯技術。下面我們看看,JIT 的工作過程。
JIT 編譯過程
當 JIT 編譯啟用時(預設是啟用的),JVM 讀入.class 檔案解釋後,將其發給 JIT 編譯器。JIT 編譯器將位元組碼編譯成本機機器程式碼,下圖展示了該過程。
圖 1. JIT 工作原理圖
Hot Spot 編譯
當 JVM 執行程式碼時,它並不立即開始編譯程式碼。這主要有兩個原因:
首先,如果這段程式碼本身在將來只會被執行一次,那麼從本質上看,編譯就是在浪費精力。因為將程式碼翻譯成 java 位元組碼相對於編譯這段程式碼並執行程式碼來說,要快很多。
當然,如果一段程式碼頻繁的呼叫方法,或是一個迴圈,也就是這段程式碼被多次執行,那麼編譯就非常值得了。因此,編譯器具有的這種權衡能力會首先執行解釋後的程式碼,然後再去分辨哪些方法會被頻繁呼叫來保證其本身的編譯。其實說簡單點,就是 JIT 在起作用,我們知道,對於 Java 程式碼,剛開始都是被編譯器編譯成位元組碼檔案,然後位元組碼檔案會被交由 JVM 解釋執行,所以可以說 Java 本身是一種半編譯半解釋執行的語言。Hot Spot VM 採用了 JIT compile 技術,將執行頻率很高的位元組碼直接編譯為機器指令執行以提高效能,所以當位元組碼被 JIT 編譯為機器碼的時候,要說它是編譯執行的也可以。也就是說,執行時,部分程式碼可能由 JIT 翻譯為目標機器指令(以 method 為翻譯單位,還會儲存起來,第二次執行就不用翻譯了)直接執行。
第二個原因是最優化,當 JVM 執行某一方法或遍歷迴圈的次數越多,就會更加了解程式碼結構,那麼 JVM 在編譯程式碼的時候就做出相應的優化。
我們將在後面講解這些優化策略,這裡,先舉一個簡單的例子:我們知道 equals() 這個方法存在於每一個 Java Object 中(因為是從 Object class 繼承而來)而且經常被覆寫。當直譯器遇到 b = obj1.equals(obj2) 這樣一句程式碼,它則會查詢 obj1 的型別從而得知到底執行哪一個 equals() 方法。而這個動態查詢的過程從某種程度上說是很耗時的。
暫存器和主存
其中一個最重要的優化策略是編譯器可以決定何時從主存取值,何時向暫存器存值。考慮下面這段程式碼:
清單 1. 主存 or 暫存器測試程式碼
1 2 3 4 5 6 7 8 9 |
|
在某些時刻,sum 變數居於主存之中,但是從主存中檢索值是開銷很大的操作,需要多次迴圈才可以完成操作。正如上面的例子,如果迴圈的每一次都是從主存取值,效能是非常低的。相反,編譯器載入一個暫存器給 sum 並賦予其初始值,利用暫存器裡的值來執行迴圈,並將最終的結果從暫存器返回給主存。這樣的優化策略則是非常高效的。但是執行緒的同步對於這種操作來說是至關重要的,因為一個執行緒無法得知另一個執行緒所使用的暫存器裡變數的值,執行緒同步可以很好的解決這一問題,有關於執行緒同步的知識,我們將在後續文章中進行講解。
暫存器的使用是編譯器的一個非常普遍的優化。
回到之前的例子,JVM 注意到每次執行程式碼時,obj1 都是 java.lang.String 這種型別,那麼 JVM 生成的被編譯後的程式碼則是直接呼叫 String.equals() 方法。這樣程式碼的執行將變得非常快,因為不僅它是被編譯過的,而且它會跳過查詢該呼叫哪個方法的步驟。
當然過程並不是上面所述這樣簡單,如果下次執行程式碼時,obj1 不再是 String 型別了,JVM 將不得不再生成新的位元組碼。儘管如此,之後執行的過程中,還是會變的更快,因為同樣會跳過查詢該呼叫哪個方法的步驟。這種優化只會在程式碼被執行和觀察一段時間之後發生。這也就是為什麼 JIT 編譯器不會理解編譯程式碼而是選擇等待然後再去編譯某些程式碼片段的第二個原因。
初級調優:客戶模式或伺服器模式
JIT 編譯器在執行程式時有兩種編譯模式可以選擇,並且其會在執行時決定使用哪一種以達到最優效能。這兩種編譯模式的命名源自於命令列引數(eg: -client 或者 -server)。JVM Server 模式與 client 模式啟動,最主要的差別在於:-server 模式啟動時,速度較慢,但是一旦執行起來後,效能將會有很大的提升。原因是:當虛擬機器執行在-client 模式的時候,使用的是一個代號為 C1 的輕量級編譯器,而-server 模式啟動的虛擬機器採用相對重量級代號為 C2 的編譯器。C2 比 C1 編譯器編譯的相對徹底,服務起來之後,效能更高。
通過 java -version 命令列可以直接檢視當前系統使用的是 client 還是 server 模式。例如:
圖 2. 檢視編譯模式
中級編譯器調優
大多數情況下,優化編譯器其實只是選擇合適的 JVM 以及為目標主機選擇合適的編譯器(-cient,-server 或是-xx:+TieredCompilation)。多層編譯經常是長時執行應用程式的最佳選擇,短暫應用程式則選擇毫秒級效能的 client 編譯器。
優化程式碼快取
當 JVM 編譯程式碼時,它會將彙編指令集儲存在程式碼快取。程式碼快取具有固定的大小,並且一旦它被填滿,JVM 則不能再編譯更多的程式碼。
我們可以很容易地看到如果程式碼快取很小所具有的潛在問題。有些熱點程式碼將會被編譯,而其他的則不會被編譯,這個應用程式將會以執行大量的解釋程式碼來結束。
這是當使用 client 編譯器模式或分層編譯時很頻繁的一個問題。當使用普通 server 編譯器模式時,編譯合格的類的數量將被填入程式碼快取,通常只有少量的類會被編譯。但是當使用 client 編譯器模式時,編譯合格的類的數量將會高很多。
在 Java 7 版本,分層編譯預設的程式碼快取大小經常是不夠的,需要經常提高程式碼快取大小。大型專案若使用 client 編譯器模式,則也需要提高程式碼快取大小。
現在並沒有一個好的機制可以確定一個特定的應用到底需要多大的程式碼快取。因此,當需要提高程式碼快取時,這將是一種湊巧的操作,一個通常的做法是將程式碼快取變成預設大小的兩倍或四倍。
可以通過 –XX:ReservedCodeCacheSize=Nflag(N 就是之前提到的預設大小)來最大化程式碼快取大小。程式碼快取的管理類似於 JVM 中的記憶體管理:有一個初始大小(用-XX:InitialCodeCacheSize=N 來宣告)。程式碼快取的大小從初始大小開始,隨著快取被填滿而逐漸擴大。程式碼快取的初始大小是基於晶片架構(例如 Intel 系列機器,client 編譯器模式下程式碼快取大小起始於 160KB,server 編譯器模式下程式碼快取大小則起始於 2496KB)以及使用的編譯器的。重定義程式碼快取的大小並不會真正影響效能,所以設定 ReservedCodeCacheSize 的大小一般是必要的。
再者,如果 JVM 是 32 位的,那麼執行過程大小不能超過 4GB。這包括了 Java 堆,JVM 自身所有的程式碼空間(包括其本身的庫和執行緒棧),應用程式分配的任何的本地記憶體,當然還有程式碼快取。
所以說程式碼快取並不是無限的,很多時候需要為大型應用程式來調優(或者甚至是使用分層編譯的中型應用程式)。比如 64 位機器,為程式碼快取設定一個很大的值並不會對應用程式本身造成影響,應用程式並不會記憶體溢位,這些額外的記憶體預定一般都是被作業系統所接受的。
編譯閾值
在 JVM 中,編譯是基於兩個計數器的:一個是方法被呼叫的次數,另一個是方法中迴圈被回彈執行的次數。回彈可以有效的被認為是迴圈被執行完成的次數,不僅因為它是迴圈的結尾,也可能是因為它執行到了一個分支語句,例如 continue。
當 JVM 執行一個 Java 方法,它會檢查這兩個計數器的總和以決定這個方法是否有資格被編譯。如果有,則這個方法將排隊等待編譯。這種編譯形式並沒有一個官方的名字,但是一般被叫做標準編譯。
但是如果方法裡有一個很長的迴圈或者是一個永遠都不會退出並提供了所有邏輯的程式會怎麼樣呢?這種情況下,JVM 需要編譯迴圈而並不等待方法被呼叫。所以每執行完一次迴圈,分支計數器都會自增和自檢。如果分支計數器計數超出其自身閾值,那麼這個迴圈(並不是整個方法)將具有被編譯資格。
這種編譯叫做棧上替換(OSR),因為即使迴圈被編譯了,這也是不夠的:JVM 必須有能力當迴圈正在執行時,開始執行此迴圈已被編譯的版本。換句話說,當迴圈的程式碼被編譯完成,若 JVM 替換了程式碼(前棧),那麼迴圈的下個迭代執行最新的被編譯版本則會更加快。
標準編譯是被-XX:CompileThreshold=Nflag 的值所觸發。Client 編譯器模式下,N 預設的值 1500,而 Server 編譯器模式下,N 預設的值則是 10000。改變 CompileThreshold 標誌的值將會使編譯器相對正常情況下提前(或推遲)編譯程式碼。在效能領域,改變 CompileThreshold 標誌是很被推薦且流行的方法。事實上,您可能知道 Java 基準經常使用此標誌(比如:對於很多 server 編譯器來說,經常在經過 8000 次迭代後改變次標誌)。
我們已經知道 client 編譯器和 server 編譯器在最終的效能上有很大的差別,很大程度上是因為編譯器在編譯一個特定的方法時,對於兩種編譯器可用的資訊並不一樣。降低編譯閾值,尤其是對於 server 編譯器,承擔著不能使應用程式執行達到最佳效能的風險,但是經過測試應用程式我們也發現,將閾值從 8000 變成 10000,其實有著非常小的區別和影響。
檢查編譯過程
中級優化的最後一點其實並不是優化本身,而是它們並不能提高應用程式的效能。它們是 JVM(以及其他工具)的各個標誌,並可以給出編譯工作的可見性。它們中最重要的就是--XX:+PrintCompilation(預設狀態下是 false)。
如果 PrintCompilation 被啟用,每次一個方法(或迴圈)被編譯,JVM 都會打印出剛剛編譯過的相關資訊。不同的 Java 版本輸出形式不一樣,我們這裡所說的是基於 Java 7 版本的。
編譯日誌中大部分的行資訊都是下面的形式:
清單 2. 日誌形式
1 |
|
這裡 timestamp 是編譯完成時的時間戳,compilation_id 是一個內部的任務 ID,且通常情況下這個數字是單調遞增的,但有時候對於 server 編譯器(或任何增加編譯閾值的時候),您可能會看到失序的編譯 ID。這表明編譯執行緒之間有些快有些慢,但請不要隨意推斷認為是某個編譯器任務莫名其妙的非常慢。
用 jstat 命令檢查編譯
要想看到編譯日誌,則需要程式以-XX:+PrintCompilation flag 啟動。如果程式啟動時沒有 flag,您可以通過 jstat 命令得到有限的可見性資訊。
Jstat 有兩個選項可以提供編譯器資訊。其中,-compile 選項提供總共有多少方法被編譯的總結資訊(下面 6006 是要被檢查的程式的程序 ID):
清單 3 程序詳情
1 2 3 |
|
注意,這裡也列出了編譯失敗的方法的個數資訊,以及編譯失敗的最後一個方法的名稱。
另一種選擇,您可以使用-printcompilation 選項得到最後一個被編譯的方法的編譯資訊。因為 jstat 命令有一個引數選項用來重複其操作,您可以觀察每一次方法被編譯的情況。舉個例子:
Jstat 對 6006 號 ID 程序每 1000 毫秒執行一次: %jstat –printcompilation 6006 1000,具體的輸出資訊在此不再描述。
高階編譯器調優
這一節我們將介紹編譯工作剩下的細節,並且過程中我們會探討一些額外的調優策略。調優的存在很大程度上幫助了 JVM 工程師診斷 JVM 自身的行為。如果您對編譯器的工作原理很感興趣,這一節您一定會喜歡。
編譯執行緒
從前文中我們知道,當一個方法(或迴圈)擁有編譯資格時,它就會排隊並等待編譯。這個佇列是由一個或很多個後臺執行緒組成。這也就是說編譯是一個非同步的過程。它允許程式在程式碼正在編譯時被繼續執行。如果一個方法被標準編譯方式所編譯,那麼下一個方法呼叫則會執行已編譯的方法。如果一個迴圈被棧上替換方式所編譯,那麼下一次迴圈迭代則會執行新編譯的程式碼。
這些佇列並不會嚴格的遵守先進先出原則:哪一個方法的呼叫計數器計數更高,哪一個就擁有優先權。所以即使當一個程式開始執行,並且有大量的程式碼需要編譯,這個優先權順序將幫助並保證最重要的程式碼被優先編譯(這也是為什麼編譯 ID 在 PrintComilation 的輸出結果中有時會失序的另一個原因)。
當使用 client 編譯器時,JVM 啟動一個編譯執行緒,而 server 編譯器有兩個這樣的執行緒。當分層編譯生效時,JVM 會基於某些複雜方程式預設啟動多個 client 和 server 執行緒,涉及雙日誌在目標平臺上的 CPU 數量。如下圖所示:
分層編譯下 C1 和 C2 編譯器執行緒預設數量:
圖 3. C1 和 C2 編譯器預設數量
編譯器執行緒的數量可以通過-XX:CICompilerCount=N flag 進行調節設定。這個數量是 JVM 將要執行佇列所用的執行緒總數。對於分層編譯,三分之一的(至少一個)執行緒被用於執行 client 編譯器佇列,剩下的(也是至少一個)被用來執行 server 編譯器佇列。
在何時我們應該考慮調整這個值呢?如果一個程式被執行在單 CPU 機器上,那麼只有一個編譯執行緒會更好一些:因為對於某個執行緒來說,其對 CPU 的使用是有限的,並且在很多情況下越少的執行緒競爭資源會使其執行效能更高。然而,這個優勢僅僅侷限於初始預熱階段,之後,這些具有編譯資格的方法並不會真的引起 CPU 爭用。當一個股票批處理應用程式執行在單 CPU 機器上並且編譯器執行緒被限制成只有一個,那麼最初的計算過程將比一般情況下快 10%(因為它沒有被其他執行緒進行 CPU 爭用)。迭代執行的次數越多,最初的效能收益就相對越少,直到所有的熱點方法被編譯完效能收益也隨之終止。
結束語
本文詳細介紹了 JIT 編譯器的工作原理。從優化的角度講,最簡單的選擇就是使用 server 編譯器的分層編譯技術,這將解決大約 90%左右的與編譯器直接相關的效能問題。最後,請保證程式碼快取的大小設定的足夠大,這樣編譯器將會提供最高的編譯效能。
相關主題