1. 程式人生 > >JVM 效能優化,第二部分:編譯器

JVM 效能優化,第二部分:編譯器

JVM 效能優化,第二部分:編譯器

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

 原文連線 譯者:Vitas

本文將是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

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

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

結論:反思點和重點

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

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

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