1. 程式人生 > >JVM優化系列-第二部分-編譯器

JVM優化系列-第二部分-編譯器

作者:Eva Andreasson    原文連結  譯者:sooerr   校對:趙峰

JVM效能優化系列中,第二篇章裡Java編譯器是主要部分。Eva Andreasson介紹了不同型別的編譯器,並且就客戶端、伺服器端、分層編譯進行了效能對比。她也總結了JVM優化的一些概況,例如死程式碼的消除、程式碼內嵌和迴圈優化。

Java編譯器是Java著名的跨平臺特性的根源。軟體開發者寫出了一個理想的Java應用程式,在編寫高效、平穩的程式碼的背後,正是編譯器可以保證其執行在潛在的目標平臺。不同種類的編譯器可以滿足多種應用程式的需要,產生出具體期望的效能測試結果。你對編譯器瞭解的越多,例如工作原理和可用種類,那麼你將更具備Java應用程式調優的能力。

JVM調優系列的第二篇文章展示並說明了各種虛擬機器編譯器的不同點。同時,我也會討論使用Just-In-Time (JIT) 編譯器可以實現的共同的優化點。(JVM總攬和介紹請看本系列第一部分”JVM效能調優PART1″)

編譯器是什麼?

簡而言之,編譯器就是將程式語言作為一種輸入,然後生產出一種可執行語言。大家都知道這個工具便是javac了,所有標準JDK都包含它。 javac將Java程式碼作為輸入,將其翻譯為位元組碼(JVM可執行語言)。位元組碼儲存在”.class”檔案之中,當java程式啟動時,這些檔案將被載入到java執行時環境。

位元組碼不能直接被標準CPU讀取,它需要被轉譯為指令語言,以便底層的執行平臺能夠解讀。JVM中負責將位元組碼轉譯為可執行平臺命令的元件是另外一個編譯器。有些JVM編譯器可以處理多個級別的轉譯,例如,有一個編譯器,在位元組碼最終被轉譯為實際執行機器的命令之前,可以建立不同級別的位元組碼中間表示形式。

位元組碼和JVM

如果讀者想了解更多位元組碼知識,請閱讀Bytecode basics” (Bill Venners, JavaWorld)。

就一個平臺來說,從不可知觀點看,我們希望儘可能的保持程式碼的平臺獨立化,以便於最後一層的轉譯處理——從最低階的表達到真正的機器程式碼——可以作為在具體某平臺處理器構架上執行的有力控制點。最高級別的層級劃分應該是在靜態與動態編譯器之間。這樣,我們就可以根據執行環境、期望的效能測試結果、有限的資源作出我們的選擇。在Part1我已經簡要的論述了靜態和動態編譯器。接下來的部分,我將會解釋更多內容。

靜態編譯 vs 動態編譯

靜態編譯器的典型案例就是之前提到過的javac。通過靜態編譯器,輸入的程式碼會被進行一次解析,輸出的可執行格式檔案在程式執行的時候會被使用。除非你改變源程式並且重新編譯程式碼,不然之前所輸出的程式碼的執行結果將不會被改變。因為此類輸入是一種靜態輸入,並且編譯器是靜態編譯器。

靜態編譯,以下是Java程式碼

staticint add7(int x){
   return x+7;
}

將產生出一些相似的位元組碼:

iload0
bipush 7
iadd
ireturn

動態編譯器可以動態的將一種語言轉譯為另一種語言,這意味著轉譯的過程伴隨著程式碼的執行,在執行時!動態編譯和優化給執行時帶來了一個優勢,即在應用載入中,執行時環境能夠及時對變化作出調整。動態編譯器十分適合Java執行時,因為它都是在難以預測並且千變萬化的環境下執行的。大部分JVM使用動態編譯器,例如JIT編譯器。關鍵是動態編譯器和程式碼優化有時需要額外的資料結構、執行緒和CPU資源。優化或位元組碼分析操作越優異,編譯過程開銷的資源就越多。因此在大部分環境中,和位元組碼的重要效能收益相比,動態編譯器和程式碼優化的開銷並不算大。

JVM變數和JAVA平臺獨立性

所有JVM的實現都具備同一個原則,那就是要把應用的位元組碼轉譯為機器指令。有些JVM在載入的時候解析應用程式碼,並使用效能計數器來關注“熱點程式碼”(頻繁使用的程式碼)。有些JVM跳過解析環節,並只依賴於編譯環節。這樣的話,編譯資源的密集性可能會成為個更大的問題(特別是對客戶端應用),但是這仍然支援了更多的高階優化。

如果你是個Java初學者,JVM的錯綜複雜將會讓你十分困擾。好訊息是其實你根本就沒必要困擾!JVM掌管著程式碼編譯和優化,因此你不需要擔心機器命令,以及編寫對底層平臺構架的程式碼的優化。

Java位元組碼的只讀儲存執行(校對:從Java位元組碼到執行)

一旦你將java程式碼編譯成位元組碼,下一步將是把位元組碼指令轉譯為機器指令。這可以由一個解析器或者編譯器完成。

解析

最簡單的位元組碼編譯形式稱為解析。解析器只是簡單地為每個位元組碼指令查詢對應的硬體指令,並將其傳送至CPU去執行。

你可以將解析比作使用字典:每個具體的單詞(位元組碼指令)都有一個準確的翻譯(機器程式碼指令)。自從解析器讀取並即刻執行了一個位元組碼指令那一刻,就已經沒有機會去優化指令集了。解析器同樣,在每次位元組碼被讀取時,不得不執行解析操作,並且這個過程是十分緩慢的。解析是一種能夠執行程式碼的精確方法,但是這種不可優化的輸出指令集,對目標平臺處理器來說,可能不會得到最高效能的指令序列。

編譯

另一方面,編譯器將全部要執行的程式碼載入到執行時環境。當它轉譯位元組碼的時候,它還具備檢查整體或部分執行時上下文的能力,並且判斷如何準確的轉譯這些程式碼。它的判斷是基於程式碼圖的分析,例如不同的指令執行分支和執行時上下文資料。

當一個位元組碼序列被轉譯為機器指令集,並且可以對指令集進行優化時,用以替換的指令集(優化過的)被儲存進一個叫做程式碼快取的結構中。下一次這些位元組碼再被執行,之前優化過的程式碼可以被快速的從程式碼快取中定位到,並且用於執行。有些情況下,效能計數器可能去除並重寫之前的優化,即編譯器運行了新的優化。程式碼快取的優點是,結果指令集可以被即刻執行——不需要解析查詢或者編譯!這樣就提高了執行速度,特別是對於java應用,同樣的方法要被呼叫很多次。

優化

使用動態編譯器可以帶來插入效能計數器的機會。比如,編譯器可能會插入一個性能計數器,在每次位元組碼程式碼塊被呼叫時,進行計算。編譯器使用“熱點程式碼”相關的資料,來決定在正在執行的應用程式中,哪部分的程式碼優化給程式最好的影響。執行時的切面資料使編譯器製作出一些正在執行、或長遠提高效能的程式碼優化方案。隨著更多的提煉的程式碼切面資料有效利用,它可以用於更多更好的優化決策,例如,如何在語言編譯中,獲得更好的輸出指令,是否需要替換為更有效的指令集,甚至是否需要去除多餘的操作。

參考如下程式碼

staticint add7(int x){
    return x+7;
}

以下是使用javac靜態編譯後的指令

iload0
bipush 7
iadd
ireturn

當這個方法被呼叫時,位元組碼塊將被動態地編譯為機器指令。當效能計數器(如果監控了此段程式碼)觸發門限時,它也可能被優化。最終結果可能和以下機器指令集相似:

lea rax,[rdx+7]
ret

不同應用程式的不同編譯器

不同的Java應用具有不同需求。需要長期執行的企業級伺服器端應用程式需要更多的優化,相對來說小型一些的客戶端應用程式也許需要以最小得資源開銷快速執行。讓我們看看三種不同編譯器套件和他們各自的好處於不足。

客戶端編譯器

C1是一個著名的優化編譯器,可以通過JVM啟動選-client來生效。正如它的啟動名稱一樣,C1是個客戶端編譯器。它被設計為幫助客戶端應用使用更少的資源,並且在很多情況下,它對應用程式啟動時間有所感知。C1使用效能計數器對程式碼現狀切面進行簡單的、毫無侵入性的優化。

伺服器端編譯器

對於長期執行的應用程式,例如企業級伺服器段Java程式來說,一個客戶端編譯器可能不夠用。伺服器端編譯器例如C2可以拍上用場了。通常C2是通過JVM啟動命令-server來生效的。由於大部分伺服器端程式需要長時間的執行,啟動了C2,和短時間執行的輕量級客戶端應用相比,使用者可以收集更多的切面資料。因此,使用者可以實施更高階的優化技術和演算法。

貼士:為你的伺服器端編譯器熱身

由於伺服器端部署的應用在編譯器對一些初始”熱點”程式碼產生優化之前會消耗一些時間,因此伺服器端通常需要一個”熱身”環節。在伺服器端開始實施一些效能度量之前,請確保你的應用程式已經到達平穩的執行狀態!從而給編譯器足夠的時間進行適當的編譯,來為你創造收益。(想了解更多編譯器熱身和切面原理,請看JavaWorld文章”Watch your HotSpot compiler go“)

伺服器編譯器比客戶端編譯器解讀更多的切面資料(校對:分析資料),並且允許更復雜的分支分析,這意味著它能夠評估出哪個優化方法更有效。擁有更多的切面資料(校對:分析資料)可以產生更好的結果。當然,實施更大範圍的切面(校對:分析)和分析(校對:計算)需要擴充套件編譯器的使用資源。使用了C2的JVM將使用更多的執行緒和CPU週期,需要更大的程式碼快取等等。

分層編譯器

分層編譯結合了客戶端和伺服器端編譯。Azul最早在他的Zing虛擬機器中,製造了分層編譯。最近(Java SE 7)它已經被Oracle Java Hotspot JVM所採納。分層編譯吸取了客戶端和伺服器端編譯的優點。客戶端編譯器在應用啟動的時期更加主動,並且通過一些較低的效能指標閥指來觸發優化處理的動作。客戶端編譯器也可以插入效能計數器,並且為更高階的優化準備指令集,將在後期階段被伺服器端編譯所處理。分層編譯器是一個非常高效利用資源的切面(校對:分析)方法,因為它可以在對編譯器活動影響極低的時候進行資料收集,在後期高階優化時可以使用。和僅僅使用程式碼解析計數器相比,這個方法還會生產出更多的資訊。

以下圖表描繪了純解析、客戶端、伺服器端和混合編譯的效能區別。X軸程式碼執行時間,Y軸代表性能。(校對:插入圖表)


和單純的程式碼解析相比,使用客戶端編譯器可以提高大約5-10倍的執行效能,實際上提高了應用的效能。當然,收益的變化還是依賴於編譯器效能如何,哪些優化生效了或者被實現了,還有就是對於目標執行平臺來說應用程式設計的有多好。後者是Java開發人員從來不需要擔心的。

和客戶端編譯器相比,伺服器端編譯器通常能夠提升可度量的30%-50%的程式碼效率。在大部分情況下,這效能的提高將平衡掉多餘的資源開銷。

分層編譯結合了兩種編譯器的優點。客戶端編譯產生了快速的啟動時間和及時的優化,伺服器端編譯在執行週期的後期,可以提供更多的高階優化。

共同的編譯器優化

目前我討論了程式碼優化的價值,以及如何、什麼時候JVM編譯器能優化程式碼。我將通過編譯器實際的優化情況進行總結。JVM優化實際發生在位元組碼級別(或在更低的語言級別),但是我將講解使用java語言的優化。在這個部分中,我不可能覆蓋所有JVM的優化,我更多的是啟發使用者進行自我探索,並且瞭解大量的高階優化,以及編譯器技術的發明(請看Resources)。

去除死程式碼

死程式碼去除就像聽上去的一樣:程式碼的去除從沒有被稱為”死程式碼”。如果一個編譯器在執行過程中發現了一些執行是沒有必要的,它將輕易的將這些指令從執行的指令集中刪除。例如,列表1中,被指定了確切值的變數從來沒有被使用過,並且完全可以在執行時被省略掉。在位元組碼級別,這相當於不需要執行將值載入到暫存器。不做不必要載入意味著更少的CPU時間,並促進了程式碼執行時間,因此特別是熱點的程式碼以及一秒鐘要呼叫很多此的程式碼,要注意此操作。

清單1 展示了java程式碼中典型的沒用的變數和不需要的操作。

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;
}

多餘性的刪除是一個相似的優化操作,它將會移除重複的指令,來提高應用的效能。

程式碼嵌入

很多優化都是嘗試去除主機層的jump指令(例如x86構架的JMP指令)。jump指令改變了指令指標暫存器,因此轉變了執行流程。和其他ASSEMBLY類指令相比,這是個高成本的操作,這就是為什麼要減少或消除jump指令。一個非常有用和著名的優化就是程式碼嵌入。既然jump非常昂貴,那麼這種方式能有所幫助,即在呼叫區間內,內嵌很多可以頻繁呼叫的,不同入口地址的小方法。程式碼清單3-5示範了內嵌程式碼的好處。

呼叫方法

int whenToEvaluateZing(int y){
    return daysLeft(y)+ daysLeft(0)+ daysLeft(y+1);
}

被呼叫方法

int daysLeft(int x){
    if(x ==0) return0;
    else return x -1;
}

內嵌方法

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所示。

內嵌,可以採用更多的優化

int whenToEvaluateZing(int y){
    if(y ==0) return y;
    else if(y ==-1)return y -1;
    else return y + y -1;
}

迴圈優化

迴圈優化在降低執行迴圈程式碼的開銷有著重要的作用。在這種情況下,系統開銷意味著昂貴的指標轉移、大量的條件判斷、沒有選擇(校對:優化)的指令管道(也就是說,大量的指令集會導致無操作或CPU的額外週期)。迴圈優化有很多種,以及大量的優化組合。典型的包括:

  • 混合迴圈:當兩個相鄰的迴圈被迭代,迴圈次數相同,這個編譯器能夠嘗試混合迴圈的主體,在相同的時間被執行(並行),當然兩個迴圈體內部不能有相互的引用,也就是說,他們必須是完全的相互獨立。
  • 反向迴圈:基本上你可以使用do-while迴圈替代while迴圈。因為do-while迴圈具備一個if子句。這個替換可以減少兩次指標轉移。然而,這也增加了條件判斷和增加了程式碼數量。這種優化是一個極好的例子,即如何多付出一點資源來換取更加高效的程式碼,在動態執行時,編譯器不得不評估和決定開銷和收益的平衡
  • Tiling loops:重組迴圈,以便於迭代的資料塊尺寸適合快取
  • Unrolling loops:能降低迴圈條件的判斷次數和指標轉移次數。你可以將其想象為內嵌兩三個要執行的迭代體,並且不需要接觸到迴圈條件。unrolling loops執行有風險,因為它可能會造成管道減少和多餘的指令操作,從而降低效能。重申一下,這個判斷是編譯器執行時作出的,也就是說,如果收益足夠,那麼付出的開銷也是值得的

這就是一個概要,即在位元組碼級別(或更低級別)編譯器做了些什麼來改進應用在目標平臺上的執行效率。這些優化是共通而普遍的,但是隻有一些可選的短小示範。這些非常簡單而寬泛的講解也是為了激發讀者的興趣,從而進行更深度的探索。

綜上所述:反射點和亮點

為不同的需求選擇不同的編譯器

  • 轉譯是一個位元組碼翻譯為機器指令的最簡單形式,並且基於指令查詢表工作
  • 編譯器基於效能計數器的優化,但將需要一些附加的資源開銷(程式碼快取、優化執行緒等)
  • 和轉譯程式碼相比,客戶端編譯器可以大大提高程式碼執行效能(5-10倍)
  • 伺服器端編譯器比客戶端編譯器能提高應用效能30%-50%,但是消耗更多的資源
  • 分層編譯器提供了兩者的最佳能力。具備客戶端編譯能力而提高程式碼執行效能,並且服務端編譯隨時間而定,而使頻繁執行的程式碼效能更好。

有很多重可行的程式碼優化。對於編譯器來說一個種要的任務就是分析所有可能性,並且基於輸出的主機程式碼的執行速度衡量採用優化的開銷。