高級進階:JVM 性能優化之編譯器

原文鏈接:http://www.javaworld.com/article/2078635/enterprise-middleware/jvm-performance-optimization--part-2--compilers.html?page=2%20

譯者:Vitas

—為你的應用程序選擇正確的Java編譯器

本文將是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軸是性能(單位時間內的操作數)

高級進階:JVM 性能優化之編譯器

圖1.編譯器性能比較

相對於純解釋性代碼,使用客戶端編譯器可以帶來5到10倍的性能提升。獲得性能提升的多少取決於編譯器的效率、可用的優化器種類以及應用程序的設計與目標平臺的吻合程度。但對應程序開發人員來講最後一條往往可以忽略。

相對於客戶端編譯器,服務器端編譯器往往能帶來30%到50%的性能提升。在大多數情況下,性能的提升往往是以資源的損耗為代價的。

多層編譯綜合了兩種編譯器的優點。客戶端編譯有更短的啟動時間以及可以進行快速優化;服務器端編譯則可以在接下來的執行過程中進行更高級的優化操作。

一些常見的編譯器優化

到目前為止,我們已經討論了優化代碼的意義以及怎樣、何時JVM會進行代碼優化。接下來我將以介紹一些編譯器實際用到的優化方式來結束本文。JVM優化實際發生在字節碼階段(或者更底層的語言表示階段),但是這裡將使用java語言來說明這些優化方式。我們不可能在本節覆蓋所有的JVM優化方式;當然啦,我希望通過這些介紹能激發你去學習數以百計的更高級的優化方式的興趣並在編譯器技術方面有所創新。

死代碼消除

死代碼消除,顧名思義就是消除那些永遠不會被執行到的代碼—即“死”代碼。

如果編譯器在運行過程中發現一些多餘指令,它將會將這些指令從執行指令集裡面移除。例如,在列表1裡面,其中一個變量在對其進行賦值操作後永遠不會被用到,所有在執行階段可以完全地忽略該賦值語句。對應到字節碼級別的操作即是,永遠不需要將該變量值加載到寄存器中。不用加載意味著消耗更少的cpu時間,因此也就能加快代碼執行,最終導致應用程序加快—如果該加載代碼每秒被調用好多次,那優化效果將更明顯。

列表1 用java 代碼列舉了一個對永遠不會被使用的變量賦值的例子。

列表1. 死代碼

int timeToScaleMyApp(boolean endlessOfResources){ int reArchitect =24; int patchByClustering =15; intuseZing =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;elsetemp +=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語句中。這個替換將減少兩次跳轉操作;但增加了條件判斷,因此增加了代碼量。這種優化是以適當的增加資源消耗換來更有效的代碼的很棒的例子—編譯器對花費和收益進行衡量,在運行時動態的做出決定。
  • 重組循環體: 重組循環體,使整個循環體能全部的存儲在緩存器中。
  • 展開循環體: 減少循環條件的檢測次數和跳轉次數。你可以把這想象成將幾次迭代“內嵌”執行,而不必進行條件檢測。循環體展開也會帶來一定的風險,因為它可能因為影響流水線和大量的冗餘指令提取而降低性能。再一次,是否展開循環體由編譯器在運行時決定,如果能帶來更大的性能提升則值得展開。

以上就是對編譯器在字節碼級別(或更低級別)如何改進應用程序在目標平臺執行性能的一個概述。我們所討論的都是些常見、流行的優化方式。由於篇幅有限我們只舉了一些簡單的例子。我們的目的是希望通過上面簡單的討論來激起你深入研究優化的興趣。

結論:反思點和重點

根據不同的目的,選擇不同的編譯器。

  • 解釋器是將字節碼翻譯成機器指令的最簡單形式。它的實現基於一個指令查詢表。
  • 編譯器可以基於性能計數器進行優化,但是需要消耗一些額外的資源(代碼緩存,優化線程等)。
  • 客戶端編譯器相對於解釋器可以帶來5到10倍的性能提升。
  • 服務器端編譯器相對於客戶端編譯器來說可以帶來30%到50%的性能提升,但需要消耗更多的資源。
  • 多層編譯則綜合了兩者的優點。使用客戶端編譯來獲取更快的響應速度,接著使用服務器端編譯器來優化那些被頻繁調用的代碼。

這裡有很多種可能的代碼優化方式。編譯器的一個重要工作就是分析所有可能的優化方式,然後對各種優化方式所付出的代價與最終得到的機器指令帶來的性能提升進行權衡。

相關推薦

推薦中...