本文將是JVM 效能最佳化系列的第二篇文章(第一篇:傳送門),Java 編譯器將是本文討論的核心內容。
本文中,作者(Eva Andreasson)首先介紹了不同種類的編譯器,並對客戶端編譯,伺服器端編譯器和多層編譯的運行效能進行了比較。然後,在文章的最後介紹了幾種常見的JVM優化方法,如死程式碼消除,程式碼嵌入以及循環體優化。
Java最引以為傲的特性「平台獨立性」正是源自於Java編譯器。軟體開發人員盡其所能寫出最好的java應用程序,緊接著後台運行的編譯器產生高效的基於目標平台的可執行代碼。不同的編譯器適用於不同的應用需求,因而也產生不同的最佳化結果。因此,如果你能更好的理解編譯器的工作原理、了解更多種類的編譯器,那麼你就能更好的優化你的Java程式。
本篇文章突顯並解釋了各種Java虛擬機器編譯器之間的不同。同時,我也會探討一些及時編譯器(JIT)常用的最佳化方案。
什麼是編譯器?
簡單來說,編譯器就是以某種程式語言程式作為輸入,然後再以另一種可執行語言程式作為輸出。 Javac是最常見的一種編譯器。它存在於所有的JDK裡面。 Javac 以java程式碼作為輸出,轉換成JVM可執行的程式碼―字節碼。這些字節碼儲存在以.class結尾的檔案中,並在java程式啟動時裝載到java運行時環境。
字節碼並不能直接被CPU讀取,它還需要被翻譯成目前平台所能理解的機器指令語言。 JVM中還有另一個編譯器負責將字節碼翻譯成目標平台可執行的指令。有些JVM編譯器需要經過幾個等級的字節碼程式碼階段。例如,一個編譯器在將字節碼翻譯成機器指令之前可能還需要經歷幾種不同形式的中間階段。
從平台不可知論的角度出發,我們希望我們的程式碼能夠盡可能的與平台無關。
為了達到這個目的,我們在最後一個等級的翻譯―從最低的字節碼表示到真正的機器碼―才真正將可執行程式碼與一個特定平台的體系結構綁定。從最高的等級來劃分,我們可以將編譯器分成靜態編譯器和動態編譯器。 我們可以根據我們的目標執行環境、我們渴望的最佳化結果、以及我們需要滿足的資源限制條件來選擇合適的編譯器。在上一篇文章中我們簡單的討論了一下靜態編譯器和動態編譯器,在接下來的部分我們將更深入的解釋它們。
靜態編譯VS 動態編譯
我們前面提到的javac就是一個靜態編譯的例子。對於靜態編譯器,輸入程式碼被解釋一次,輸出即為程式將來被執行的形式。除非你更新原始程式碼並(透過編譯器)重新編譯,否則程式的執行結果將永遠不會改變:這是因為輸入是一個靜態的輸入並且編譯器是一個靜態的編譯器。
透過靜態編譯,下面的程式:
複製代碼代碼如下:
staticint add7(int x ){ return x+7;}
將會轉換成類似下面的字節碼:
複製代碼代碼如下:
iload0 bipush 7 iadd ireturn
動態編譯器動態的將一種語言編譯成另一種語言,所謂動態的是指在程式執行的時候進行編譯―邊運行邊編譯!動態編譯和最佳化的好處就是可以處理應用程式載入時的一些變化。 Java 執行時期常常運行在不可預測甚至變化的環境上,因此動態編譯非常適合Java 執行時間。大部分的JVM 使用動態編譯器,如JIT編譯器。值得注意的是,動態編譯和程式碼最佳化需要使用一些額外的資料結構、執行緒以及CPU資源。越高級的優化器或字節碼上下文分析器,消耗越多的資源。但是這些花銷相對於顯著的性能提升來說是微不足道的。
JVM種類以及Java的平台獨立性
所有JVM的實作都有共同的特點就是將字節碼編譯成機器指令。有些JVM在載入應用程式時會對程式碼進行解釋,並透過效能計數器來找出「熱」程式碼;有些JVM則透過編譯來實現。編譯的主要問題是集中需要大量的資源,但是它也能帶來更好的效能最佳化。
如果你是java新手,JVM的錯綜複雜一定會搞得你暈頭轉向。但好消息是你並不需要將它搞得特別清楚! JVM將管理程式碼的編譯和最佳化,你並不需要為機器指令以及採取什麼樣的方式寫程式碼才能最佳的匹配程式運行平台的體系結構而操心。
從java字節碼到可執行
一旦將你的java程式碼編譯成字節碼,接下來的一步就是將字節碼指令翻譯成機器碼。這一步可以透過解譯器來實現,也可以透過編譯器來實現。
解釋
解釋是編譯字節碼最簡單的方式。解釋器以查表的形式找到每個字節碼指令對應的硬體指令,然後將它傳送給CPU執行。
你可以將解釋器想像成查字典:每一個特定的單字(字節碼指令),都有一個具體的翻譯(機器碼指令)與之對應。因為解釋器每讀一條指令就會馬上執行該指令,所以該方式無法對一組指令集進行最佳化。同時每調用一個字節碼都要馬上解釋,因此解釋器運行速度是相當慢得。解釋器以非常準確的方式來執行程式碼,但是由於沒有對輸出的指令集進行最佳化,因此它對目標平台的處理器來說可能不是最優的結果。
編譯
編譯器則是將所有將要執行的程式碼全部載入到運行時。這樣當它翻譯字節碼時,就可以參考全部或部分的執行時間上下文。它所做的決定都是基於對程式碼圖分析的結果。如比較不同的執行分支以及參考運行時上下文資料。
將字節碼序列翻譯成機器碼指令集後,就可以基於這個機器碼指令集來進行最佳化。優化過的指令集儲存在一個叫代碼緩衝區的結構中。當這些字節碼再次執行時,就可以直接從這個程式碼緩衝區取得最佳化的程式碼並執行。在有些情況下編譯器並不使用最佳化器來進行程式碼最佳化,而是使用一種新的最佳化序列―「效能計數」。
使用程式碼快取器的優點是結果集指令可以立即執行而不再需要重新解釋或編譯!
這可以大大的降低執行時間,尤其是對一個方法被多次呼叫的java應用程式。
最佳化
透過動態編譯的引入,我們就有機會插入效能計數器。例如,編譯器插入效能計數器,每次字節碼塊(對應某個特定的方法)被呼叫時對應的計數器就會加一。編譯器透過這些計數器找到“熱塊”,因此就能確定哪些程式碼區塊的最佳化能為應用程式帶來最大的效能提升。執行時期效能分析資料能夠幫助編譯器在連線狀態下得到更多的最佳化決策,進而更進一步提升程式碼執行效率。因為得到越多越精確的程式碼效能分析數據,我們就可以找到更多的可優化點從而做出更好的優化決定,例如:怎樣更好的序列話指令、是否用更有效率的指令集來替代原有指令集,以及是否消除冗餘的操作等。
例如
考慮下面的java程式碼複製程式碼如下:
staticint add7(int x ){ return x+7;}
Javac 將靜態的將它翻譯成如下字節碼:
複製代碼代碼如下:
iload0
bipush 7
iadd
ireturn
當該方法被呼叫時,該字節碼將被動態的編譯成機器指令。當性能計數器(如果存在)達到指定的閥值時,此方法就可能被最佳化。優化後的結果可能類似下面的機器指令集:
複製代碼代碼如下:
lea rax,[rdx+7] ret
不同的編譯器適用於不同的應用
不同的應用程式擁有不同的需求。企業伺服器端應用程式通常需要長時間運行,所以通常希望對其進行更多的效能最佳化;而客戶端小程式可能希望更快的回應時間和更少的資源消耗。以下讓我們一起討論三種不同的編譯器以及他們的優缺點。
客戶端編譯器(Client-side compilers)
C1是一種大家熟知的最佳化編譯器。當啟動JVM時,新增-client參數即可啟動此編譯器。透過它的名字我們即可發現C1是一種客戶端編譯器。它非常適用於那種系統可用資源很少或要求能快速啟動的客戶端應用程式。 C1透過使用效能計數器來進行程式碼最佳化。這是一種方式簡單,且對原始碼介入較少的最佳化方式。
伺服器端編譯器(Server-side compilers)
對於那種長時間運行的應用程式(例如伺服器端企業級應用程式),使用客戶端編譯器可能遠遠無法滿足需求。這時我們應該選擇類似C2這樣的伺服器端編譯器。透過在JVM啟動行中加入server 即可啟動此最佳化器。因為大部分的伺服器端應用程式通常都是長時間運行的,與那些短時間運行、輕量級的客戶端應用相比,透過使用C2編譯器,你將能夠收集到更多的效能最佳化資料。因此你也將能夠應用更進階的最佳化技術和演算法。
提示:預熱你的服務端編譯器
對於伺服器端的部署,編譯器可能需要一些時間來最佳化那些「熱點」程式碼。所以伺服器端的部署常常需要一個「加熱」階段。所以當對伺服器端的部署進行效能測量時,務必確保你的應用程式已經達到了穩定狀態!給予編譯器充足的時間進行編譯將會為你的應用帶來許多好處。
伺服器端編譯器相比客戶端編譯器來說能夠得到更多的效能調優數據,這樣就可以進行更複雜的分支分析,從而找到效能更優的最佳化路徑。擁有越多的效能分析數據就能得到更優的應用程式分析結果。當然,進行大量的效能分析也需要更多的編譯器資源。如JVM若使用C2編譯器,那麼它將需要使用更多的CPU週期,更大的程式碼快取區等等。
多層編譯
多層編譯混合了客戶端編譯和伺服器端編譯。 Azul第一個在他的Zing JVM中實現了多層編譯。最近,這項技術已經被Oracle Java Hotspot JVM採用(Java SE7 之後)。多層編譯綜合了客戶端和伺服器端編譯器的優點。客戶端編譯器在以下兩種情況表現得比較活躍:應用啟動時;當效能計數器達到較低等級的閾值時進行效能最佳化。客戶端編譯器也會插入效能計數器以及準備指令集以備接下來的進階最佳化―伺服器端編譯器―使用。多層編譯是一種資源利用率很高的效能分析方式。因為它可以在低影響編譯器活動時收集數據,而這些數據可以在後面更進階的最佳化中繼續使用。這種方式與使用解釋性代碼分析計數器相比可以提供更多的資訊。
圖1所描述的是解釋器、客戶端編譯、伺服器端編譯、多層編譯的效能比較。 X軸是執行時間(時間單位),Y軸是性能(單位時間內的操作數)
圖1.編譯器效能比較
相對於純解釋性程式碼,使用客戶端編譯器可以帶來5到10倍的效能提升。獲得效能提升的多少取決於編譯器的效率、可用的最佳化器種類以及應用程式的設計與目標平台的吻合程度。但對應程式開發人員來講最後一條往往可以忽略。
相對於客戶端編譯器,伺服器端編譯器往往能帶來30%到50%的效能提升。在大多數情況下,效能的提升往往是以資源的損耗為代價的。
多層編譯綜合了兩種編譯器的優點。客戶端編譯有更短的啟動時間以及可以進行快速最佳化;伺服器端編譯則可以在接下來的執行過程中進行更進階的最佳化操作。
一些常見的編譯器最佳化
到目前為止,我們已經討論了優化程式碼的意義以及怎樣、何時JVM會進行程式碼最佳化。接下來我將以介紹一些編譯器實際用到的最佳化方式來結束本文。 JVM優化實際上發生在字節碼階段(或更底層的語言表示階段),但這裡將使用java語言來說明這些最佳化方式。我們不可能在本節涵蓋所有的JVM優化方式;當然啦,我希望透過這些介紹能激發你去學習數以百計的更高級的優化方式的興趣並在編譯器技術方面有所創新。
死代碼消除
死程式碼消除,顧名思義就是消除那些永遠不會被執行到的程式碼―即「死」程式碼。
如果編譯器在執行過程中發現一些多餘指令,它將這些指令從執行指令集裡面移除。例如,在列表1裡面,其中一個變數在對其進行賦值操作後永遠不會被用到,所有在執行階段可以完全地忽略該賦值語句。對應到字節碼層級的操作即是,永遠不需要將該變數值載入到暫存器中。不用載入意味著消耗更少的cpu時間,因此也就能加快程式碼執行,最終導致應用程式加快―如果該載入程式碼每秒被呼叫好多次,那麼最佳化效果將更明顯。
列表1 用java 程式碼列舉了一個對永遠不會被使用的變數賦值的例子。
列表1. 死碼複製程式碼如下:
int timeToScaleMyApp(boolean endlessOfResources){
int reArchitect =24;
int patchByClustering =15;
int useZing =2;
if(endlessOfResources)
return reArchitect + useZing;
else
return useZing;
}
在字節碼階段,如果一個變數被載入但是永遠不會被使用,編譯器可以偵測到並消除掉這些死程式碼,如列表2所示。如果永遠不執行該載入操作則可以節省cpu時間從而改善程式的執行速度。
列表2. 優化後的程式碼複製程式碼如下:
int timeToScaleMyApp(boolean endlessOfResources){
int reArchitect =24; //unnecessary operation removed here…
int useZing =2;
if(endlessOfResources)
return reArchitect + useZing;
else
return useZing;
}
冗餘消除是一種類似移除重複指令來改善應用效能的最佳化方式。
許多優化嘗試著消除機器指令層級的跳轉指令(如x86體系結構中得JMP). 跳轉指令將改變指令指標暫存器,從而轉移程式執行流程。這種跳躍指令相對其他ASSEMBLY指令來說是一種很耗資源的指令。這就是為什麼我們要減少或消除這種指令。程式碼嵌入就是一種很實用、很有名的消除轉移指令的最佳化方式。因為執行跳躍指令代價很高,所以將一些被頻繁呼叫的小方法嵌入函數體內將會帶來許多好處。列表3-5證明了內嵌的好處。
列表3. 呼叫方法複製程式碼如下:
int whenToEvaluateZing(int y){ return daysLeft(y)+ daysLeft(0)+ daysLeft(y+1);}
列表4. 被呼叫方法複製程式碼如下:
int daysLeft(int x){ if(x ==0) return0; else return x -1;}
列表5. 內嵌方法複製程式碼如下:
int whenToEvaluateZing(int y){
int temp =0;
if(y ==0)
temp +=0;
else
temp += y -1;
if(0==0)
temp +=0;
else
temp +=0-1;
if(y+1==0)
temp +=0;
else
temp +=(y +1)-1;
return temp;
}
在清單3-5中我們可以看到,一個小方法在另一個方法體內被呼叫了三次,而我們想說明的是:將被呼叫方法直接內嵌到程式碼中所花費的代價將小於執行三次跳轉指令所花費的代價。
內嵌一個不常被呼叫的方法可能並不會帶來太大的不同,但是如果內嵌一個所謂的「熱」方法(經常被調用的方法)則可以帶來很多的性能提升。內嵌後的程式碼常常還可以進行更進一步的最佳化,如列表6所示。
列表6. 程式碼內嵌後,更進一步的最佳化實作複製程式碼如下:
int whenToEvaluateZing(int y){ if(y ==0)return y; elseif(y ==-1)return y -1; elsereturn y + y -1;}
循環優化
循環優化在降低執行循環體所帶來的額外消耗方面起著重要的作用。這裡的額外消耗指的是昂貴的跳轉、大量的條件檢測,非最佳化管道(即,一系列無實際操作、消耗額外cpu週期的指令集)。這裡有很多種循環優化,接下來列舉一些比較受歡迎的循環優化:
循環體合併:當兩個相鄰的循環體執行相同次數的循環時,編譯器將試圖合併這兩個循環體。如果兩個循環體之間是完全獨立的,則它們還可以被同時執行(並行)。
反演循環: 最基本的,你用一個do-while循環來取代一個while循環。這個do-while迴圈被放置在一個if語句中。這個替換將減少兩次跳躍操作;但增加了條件判斷,因此增加了代碼量。這種最佳化是以適當的增加資源消耗換來更有效的程式碼的很棒的例子―編譯器對花費和收益進行衡量,在運行時動態的做出決定。
重組循環體: 重組循環體,使整個循環體能全部的儲存在快取器中。
展開循環體: 減少循環條件的偵測次數和跳轉次數。你可以把這想像成將幾次迭代“內嵌”執行,而不必進行條件檢測。循環體展開也會帶來一定的風險,因為它可能因為影響管線和大量的冗餘指令提取而降低效能。再一次,是否展開循環體由編譯器在執行時決定,如果能帶來更大的效能提升則值得展開。
以上就是編譯器在字節碼層級(或更低層級)如何改進應用程式在目標平台執行效能的一個概述。我們所討論的都是些常見、流行的最佳化方式。由於篇幅有限我們只舉了一些簡單的例子。我們的目的是希望透過上面簡單的討論來激起你深入研究優化的興趣。
結論:反思點和重點
根據不同的目的,選擇不同的編譯器。
1.解釋器是將字節碼翻譯成機器指令的最簡單形式。它的實作是基於一個指令查詢表。
2.編譯器可以基於效能計數器進行最佳化,但是需要消耗一些額外的資源(程式碼緩存,最佳化執行緒等)。
3.客戶端編譯器相對於解釋器可以帶來5到10倍的效能提升。
4.伺服器端編譯器相對於客戶端編譯器來說可以帶來30%到50%的效能提升,但需要消耗更多的資源。
5.多層編譯則綜合了兩者的優點。使用客戶端編譯來取得更快的回應速度,接著使用伺服器端編譯器來最佳化那些被頻繁呼叫的程式碼。
這裡有很多種可能的程式碼優化方式。編譯器的一個重要工作就是分析所有可能的最佳化方式,然後對各種最佳化方式所付出的代價與最終得到的機器指令帶來的效能提升進行權衡。