JVM性能優化, Part 2 ―― 編譯器

ImportNew注:本文是JVM性能優化 – 第2篇 《JVM性能優化, Part 2 ―― 編譯器》第一篇 《JVM性能優化, Part 1 ―― JVM簡介 》

作爲JVM性能優化系列文章的第2篇,本文將着重介紹Java編譯器,此外還將對JIT編譯器常用的一些優化措施進行討論(參見“JVM性能優化,Part 1″中對JVM的介紹)。Eva Andreasson將對不同種類的編譯器做介紹,並比較客戶端、服務器端和層次編譯產生的編譯結果在性能上的區別,此外將對通用的JVM優化做介紹,包括死代碼剔除、內聯以及循環優化。

Java編譯器存在是Java編程語言能獨立於平臺的根本原因。軟件開發者可以盡全力編寫程序,然後由Java編譯器將源代碼編譯爲針對於特定平臺的高效、可運行的代碼。不同類型的編譯器適合於不同應用程序的需求,使編譯結果可以滿足期望的性能要求。對編譯器基本原理了解得越多,在優化Java應用程序性能時就越能得心應手。

什麼是編譯器

簡單來說,編譯器就是將一種編程語言作爲輸入,輸出另一種可執行語言的工具。大家都熟悉的javac就是一個編譯器,所有標準版的JDK中都帶有這個工具。javac以Java源代碼作爲輸入,將其翻譯爲可由JVM執行的字節碼。翻譯後的字節碼存儲在.class文件中,在啓動Java進程的時候,被載入到Java運行時中。

標準CPU並不能識別字節碼,它需要被轉換爲當前平臺所能理解的本地指令。在JVM中,有專門的組件負責將字節碼編譯爲平臺相關指令,實際上,這也是一種編譯器。有些JVM編譯器可以處理多層級的編譯工作,例如,編譯器在最終將字節碼轉換爲平臺相關指令前,會爲相關的字節碼建立多層級的中間表示(intermediate representation)。

字節碼與JVM

如果你想了解更多有關字節碼與JVM的信息,請閱讀 “Bytecode basics”(Bill Venners, JavaWorld)

以平臺未知的角度看,我們希望儘可能的保持平臺獨立性,因此,最後一級的編譯,也就是從最低級表示到實際機器碼的轉換,是與具體平臺的處理器架構息息相關的。在最高級的表示上,會因使用靜態編譯器還是動態編譯器而有所區別。在這裏,我們可以選擇應用程序所以來的可執行環境,期望達到的性能要求,以及我們所面臨的資源限制。在本系列的第1篇文章的靜態編譯器與動態編譯器一節中,已經對此有過簡要介紹。我將在本文的後續章節中詳細介紹這部分內容。

靜態編譯器與動態編譯器

前文提到的javac就是使用靜態編譯器的例子。靜態編譯器解釋輸入的源代碼,並輸出程序運行時所需的可執行文件。如果你修改了源代碼,那麼就需要使用編譯器來重新編譯代碼,否則輸出的可執行性文件不會發生變化;這是因爲靜態編譯器的輸入是靜態的普通文件。

使用靜態編譯器時,下面的Java代碼

1
2
3
static int add7( int x ) {
     return x+7;
}

會生成類似如下的字節碼:

1
2
3
4
iload0
bipush 7
iadd
ireturn

動態編譯器會動態的將一種編程語言編譯爲另一種,即在程序運行時執行編譯工作。動態編譯與優化使運行時可以根據當前應用程序的負載情況而做出相應的調整。動態編譯器非常適合用於Java運行時中,因爲Java運行時通常運行在無法預測而又會隨着運行而有所變動的環境中。大部分JVM都會使用諸如Just-In-Time編譯器的動態編譯器。這裏面需要注意的是,大部分動態編譯器和代碼優化有時需要使用額外的數據結構、線程和CPU資源。要做的優化或字節碼上下文分析越高級,編譯過程所消耗的資源就越多。在大多數運行環境中,相比於經過動態編譯和代碼優化所獲得的性能提升,這些損耗微不足道。

 JVM的多樣性與Java平臺的獨立性

所有的JVM實現都有一個共同點,即它們都試圖將應用程序的字節碼轉換爲本地機器指令。一些JVM在載入應用程序後會解釋執行應用程序,同時使用性能計數器來查找“熱點”代碼。還有一些JVM會調用解釋執行的階段,直接編譯運行。資源密集型編譯任務對應用程序來說可能會產生較大影響,尤其是那些客戶端模式下運行的應用程序,但是資源密集型編譯任務可以執行一些比較高級的優化任務。更多相關內容請參見相關資源

如果你是Java初學者,JVM本身錯綜複雜結構會讓你暈頭轉向的。不過,好消息是你無需精通JVM。JVM自己會做好代碼編譯和優化的工作,所以你無需關心如何針對目標平臺架構來編寫應用程序才能編譯、優化,從而生成更好的本地機器指令。

從字節碼到可運行的程序

當你編寫完Java源代碼並將之編譯爲字節碼後,下一步就是將字節碼指令編譯爲本地機器指令。這一步會由解釋器或編譯器完成。

解釋

解釋是最簡單的字節碼編譯形式。解釋器查找每條字節碼指令對應的硬件指令,再由CPU執行相應的硬件指令。

你可以將解釋器想象爲一個字典:每個單詞(字節碼指令)都有準確的解釋(本地機器指令)。由於解釋器每次讀取一個字節碼指令並立即執行,因此它就沒有機會對某個指令集合進行優化。由於每次執行字節碼時,解釋器都需要做相應的解釋工作,因此程序運行起來就很慢。解釋執行可以準確執行字節碼,但是未經優化而輸出的指令集難以發揮目標平臺處理器的最佳性能。

編譯

另一方面,編譯執行應用程序時,*編譯器*會將加載運行時會用到的全部代碼。因爲編譯器可以將字節碼編譯爲本地代碼,因此它可以獲取到完整或部分運行時上下文信息,並依據收集到的信息決定到底應該如何編譯字節碼。編譯器是根據諸如指令的不同執行分支和運行時上下文數據等代碼信息來指定決策的。

當字節碼序列被編譯爲機器代碼指令集合時,就可以對這個指令集合做一些優化操作了,優化後的指令集合會被存儲到成爲code cache的數據結構中。當下一次執行這部分字節碼序列時,就會執行這些經過優化後被存儲到code cache的指令集合。在某些情況下,性能計數器會失效,並覆蓋掉先前所做的優化,這時,編譯器會執行一次新的優化過程。使用code cache的好處是優化後的指令集可以立即執行 —— 無需像解釋器一樣再經過查找的過程或編譯過程!這可以加速程序運行,尤其是像Java應用程序這種同一個方法會被多次調用應用程序。

優化

隨着動態編譯器一起出現的是性能計數器。例如,編譯器會插入性能計數器,以統計每個字節碼塊(對應與某個被調用的方法)的調用次數。在進行相關優化時,編譯器會使用收集到的數據來判斷某個字節碼塊有多“熱”,這樣可以最大程度的降低對當前應用程序的影響。運行時數據監控有助於編譯器完成多種代碼優化工作,進一步提升代碼執行性能。隨着收集到的運行時數據越來越多,編譯器就可以完成一些額外的、更加複雜的代碼優化工作,例如編譯出更高質量的目標代碼,使用運行效率更高的代碼替換原代碼,甚至是剔除冗餘操作等。

示例

考慮如下代碼:

1
2
3
static int add7( int x ) {
     return x+7;
}

這段代碼經過javac編譯後會產生如下的字節碼:

1
2
3
4
iload0
bipush 7
iadd
ireturn

當調用這段代碼時,字節碼塊會被動態的編譯爲本地機器指令。當性能計數器(如果這段代碼應用了性能計數器的話)發現這段代碼的運行次數超過了某個閾值後,動態編譯器會對這段代碼進行優化編譯。後帶的代碼可能會是下面這個樣子:

1
2
lea rax,[rdx+7]
ret

各擅勝場

不同的Java應用程序需要滿足不同的需求。相對來說,企業級服務器端應用程序需要長時間運行,因此可以做更多的優化,而稍小點的客戶端應用程序可能要求快速啓動運行,佔資源少。接下來我們考察三種編譯器設置及其各自的優缺點。

客戶端編譯器

即大家熟知的優化編譯器C1。在啓動應用程序時,添加JVM啓動參數“-client”可以啓用C1編譯器。正如啓動參數所表示的,C1是一個客戶端編譯器,它專爲客戶端應用程序而設計,資源消耗更少,並且在大多數情況下,對應用程序的啓動時間很敏感。C1編譯器使用性能計數器來收集代碼的運行時信息,執行一些簡單、無侵入的代碼優化任務。

服務器端編譯器

對於那些需要長時間運行的應用程序,例如服務器端的企業級Java應用程序來說,客戶端編譯器所實現的功能還略有不足,因此服務器端的編譯會使用類似C2這類的編譯器。啓動應用程序時添加命令行參數“-server”可以啓用C2編譯器。由於大多數服務器端應用程序都會長時間運行,因此相對於運行時間稍短的輕量級客戶端應用程序,在服務器端應用程序中啓用C2編譯器可以收集到更多的運行時數據,也就可以執行一些更高級的編譯技術與算法。

提示:給服務器端編譯器熱身

對於服務器端編譯器來說,在應用程序開始運行之後,編譯器可能會在一段時間之後纔開始優化“熱點”代碼,所以服務器端編譯器通常需要經過一個“熱身”階段。在服務器端編譯器執行性能優化任務之前,要確保應用程序的各項準備工作都已就緒。給予編譯器足夠多的時間來完成編譯、優化的工作才能取得更好的效果。(更多關於編譯器熱身與監控原理的內容請參見JavaWorld的文章”Watch your HotSpot compiler go“。)

在執行編譯任務優化任務時,服務器端編譯器要比客戶端編譯器綜合考慮更多的運行時信息,執行更復雜的分支分析,即對哪種優化路徑能取得更好的效果作出判斷。獲取的運行時數據越多,編譯優化所產生的效果越好。當然,要完成一些複雜的、高級的性能分析任務,編譯器就需要消耗更多的資源。使用了C2編譯器的JVM會消耗更多的資源,例如更多的線程,更多的CPU指令週期,以及更大的code cache等。

層次編譯

層次編譯綜合了服務器端編譯器和客戶端編譯器的特點。Azul首先在其Zing JVM中實現了層次編譯。最近(就是Java SE 7版本),Oracle Java HotSpot VM也採用了這種設計。在應用程序啓動階段,客戶端編譯器最爲活躍,執行一些由較低的性能計數器閾值出發的性能優化任務。此外,客戶端編譯器還會插入性能計數器,爲一些更復雜的性能優化任務準備指令集,這些任務將在後續的階段中由服務器端編譯器完成。層次編譯可以更有效的利用資源,因爲編譯器在執行一些對應用程序影響較小的編譯活動時仍可以繼續收集運行時信息,而這些信息可以在將來用於完成更高級的優化任務。使用層次編譯可以比解釋性的代碼性能計數器手機到更多的信息。

Figure 1中展示了純解釋運行、客戶端模式運行、服務器端模式運行和層次編譯模式運行下性能之間的區別。X軸表示運行時間(單位時間)Y軸表示性能(每單位時間內的操作數)。

Figure 1. Performance differences between compilers (click to enlarge)

編譯性能對比

相比於純解釋運行的的代碼,以客戶端模式編譯運行的代碼在性能(指單位時間執行的操作)上可以達到約5到10倍,因此而提升了應用程序的運行性能。其間的區別主要在於編譯器的效率、編譯器所作的優化,以及應用程序在設計實現時針對目標平臺做了何種程度的優化。實際上,最後一條不在Java程序員的考慮之列。

相比於客戶端編譯器,使用服務器端編譯器通常會有30%到50%的性能提升。在大多數情況下,這種程度的性能提升足以彌補使用服務器端編譯所帶來的額外資源消耗。

層次編譯綜合了服務器端編譯器和客戶端編譯器的優點,使用客戶端編譯模式實現快速啓動和快速優化,使用服務器端編譯模式在後續的執行週期中完成高級優化的編譯任務。

常用編譯優化手段

到目前爲止,已經介紹了優化代碼的價值,以及常用JVM編譯器是如何以及何時編譯代碼的。接下來,將用一些實際的例子做個總結。JVM所作的性能優化通常在字節碼這一層級(或者是更底層的語言表示),但這裏我將使用Java編程語言對優化措施進行介紹。在這一節中,我無法涵蓋JVM中所作的所有性能優化,相反,我希望可以激發你的興趣,使你主動挖掘並學習編譯器技術中所包含了數百種高級優化技術(參見相關資源)。

死代碼剔除

死代碼剔除指的是,將用於無法被調用的代碼,即“死代碼”,從源代碼中剔除。如果編譯器在運行時發現某些指令是不必要的,它會簡單的將其從可執行指令集中剔除。例如,在Listing 1中,變量被賦予了確定值,卻從未被使用,因此可以在執行時將其完全忽略掉。在字節碼這一層級,也就不會有將數值載入到寄存器的操作。沒有載入操作意味着可以更少的CPU時間,更好的運行性能,尤其是當這段代碼是“熱點”代碼的時候。

Listing 1中展示了示例代碼,其中被賦予了固定值的代碼從未被使用,屬於無用不必要的操作。

Listing 1. Dead code

1
2
3
4
5
6
7
8
9
10
int timeToScaleMyApp(boolean endlessOfResources) {
  int reArchitect = 24;
  int patchByClustering = 15;
  int useZing = 2;
 
  if(endlessOfResources)
      return reArchitect + useZing;
  else
      return useZing;
}

在字節碼這一層級,如果變量被載入但從未使用,編譯器會檢測到並剔除這個死代碼,如Listing 2所示。剔除死代碼可以節省CPU時間,從而提升應用程序的運行速度。

Listing 2. The same code following optimization

1
2
3
4
5
6
7
8
9
10
int timeToScaleMyApp(boolean endlessOfResources) {
  int reArchitect = 24;
  //unnecessary operation removed here...
  int useZing = 2;
 
  if(endlessOfResources)
      return reArchitect + useZing;
  else
      return useZing;
}

冗餘剔除是一種類似的優化手段,通過剔除掉重複的指令來提升應用程序性能。

內聯

許多優化手段都試圖消除機器級跳轉指令(例如,x86架構的JMP指令)。跳轉指令會修改指令指針寄存器,因此而改變了執行流程。相比於其他彙編指令,跳轉指令是一個代價高昂的指令,這也是爲什麼大多數優化手段會試圖減少甚至是消除跳轉指令。內聯是一種家喻戶曉而且好評如潮的優化手段,這是因爲跳轉指令代價高昂,而內聯技術可以將經常調用的、具有不容入口地址的小方法整合到調用方法中。Listing 3到Listing 5中的Java代碼展示了使用內聯的用法。

Listing 3. Caller method

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

Listing 4. Called method

1
2
3
4
5
6
int daysLeft(int x){
  if (x == 0)
     return 0;
  else
     return x - 1;
}

Listing 5. Inlined method

1
2
3
4
5
6
7
8
9
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;
}

在Listing 3到Listing 5的代碼中,展示了將調用3次小方法進行內聯的示例,這裏我們認爲使用內聯比跳轉有更多的優勢。

如果被內聯的方法本身就很少被調用的話,那麼使用內聯也沒什麼意義,但是對頻繁調用的“熱點”方法進行內聯在性能上會有很大的提升。此外,經過內聯處理後,就可以對內聯後的代碼進行進一步的優化,正如Listing 6中所展示的那樣。

Listing 6. After inlining, more optimizations can be applied

1
2
3
4
5
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語句塊中。這個替換操作可以節省兩次跳轉操作,但是,會增加一個條件檢查的操作,因此增加的代碼量。這種優化方式完美的展示了以少量增加代碼量爲代價換取較大性能的提升 —— 編譯器需要在運行時需要權衡這種得與失,並制定編譯策略。
  • 分塊循環:重新組織循環體,以便迭代數據塊時,便於緩存的應用。
  • 展開循環:減少判斷循環條件和跳轉的次數。你可以將之理解爲將一些迭代的循環體“內聯”到一起,而無需跨越循環條件。展開循環是有風險的,它有可能會降低應用程序的運行性能,因爲它會影響流水線的運行,導致產生了冗餘指令。再強調一遍,展開循環是編譯器在運行時根據各種信息來決定是否使用的優化手段,如果有足夠的收益的話,那麼即使有些性能損耗也是值得的。

至此,已經簡要介紹了編譯器對字節碼層級(以及更底層)進行優化,以提升應用程序在目標平臺的執行性能的幾種方式。這裏介紹的幾種優化手段是比較常用的幾種,只是衆多優化技術中的幾種。在介紹優化方法時配以簡單示例和相關解釋,希望可以洗髮你進行深度探索的興趣。更多相關內容請參見相關資源。

總結:回顧

爲滿足不同需要而使用不同的編譯器。

  • 解釋是將字節碼轉換爲本地機器指令的最簡單方式,其工作方式是基於對本地機器指令表的查找。
  • 編譯器可以基於性能計數器進行性能優化,但是需要消耗更多的資源(如code cache,優化線程等)。
  • 相比於純解釋執行代碼,客戶端編譯器可以將應用程序的執行性能提升一個數量級(約5到10倍)。
  • 相比於客戶端編譯器,服務器端編譯器可以將應用程序的執行性能提升30%到50%,但會消耗更多的資源。
  • 層次編譯綜合了客戶端編譯器和服務器端編譯器的優點,既可以像客戶端編譯器那樣快速啓動,又可以像服務器端編譯器那樣,在長時間收集運行時信息的基礎上,優化應用程序的性能。

目前,已經出現了很多代碼優化的手段。對編譯器來說,一個主要的任務就是分析所有的可能性,權衡使用某種優化手段的利弊,在此基礎上編譯代碼,優化應用程序的性能。

關於作者

Eva Andearsson對JVM計數、SOA、雲計算和其他企業級中間件解決方案有着10多年的從業經驗。在2001年,她以JRockit JVM開發者的身份加盟了創業公司Appeal Virtual Solutions(即BEA公司的前身)。在垃圾回收領域的研究和算法方面,EVA獲得了兩項專利。此外她還是提出了確定性垃圾回收(Deterministic Garbage Collection),後來形成了JRockit實時系統(JRockit Real Time)。在技術上,Eva與SUn公司和Intel公司合作密切,涉及到很多將JRockit產品線、WebLogic和Coherence整合的項目。2009年,Eva加盟了Azul System公,擔任產品經理。負責新的Zing Java平臺的開發工作。最近,她改換門庭,以高級產品經理的身份加盟Cloudera公司,負責管理Cloudera公司Hadoop分佈式系統,致力於高擴展性、分佈式數據處理框架的開發。

相關資源

    • “JVM性能優化, Part 1 ——JVM簡介”(原文作者Eva Andreasson, 於2012年8約發表於JavaWorld)是該系列的第一篇,對經典JVM的工作原理做了簡單介紹,包括Java“一次編寫,到處運行”的優勢,垃圾回收基礎和一些常用的垃圾回收算法。
    • 更多有關HotSpot優化原理以及JVM熱身的內容請參見Vladimir Roubtsov與2003年4約發表於JavaWorld.com的文章“Watch your HotSpot compiler go”
    • 如果你想對JVM和字節碼有更深入的瞭解,請參見Bill Venners在1996年發表於JavaWorld的文章“Bytecode basics”。文章對JVM中的字節碼指令集做了介紹,內容包括原生類型操作、類型轉換以及棧上操作等。
    • 在Java平臺的官方文檔中有對Java編譯器javac的詳細描述。
    • 更多有關JVM中JIT編譯器的內容,請參見IBM Research中有關Java JIT Compiler的內容。
    • 或者參見Oracle JRockit文檔中“Understanding Just-In-Time Compilation and Optimization”的相關內容.
    • Cliff Click博士在其博客上有關於層次編譯的完整教程。
    • 更多有關使用性能計數器完成JVM性能優化的文章:“Using Platform-Specific Performance Counters for Dynamic Compilation” (作者Florian Schneider與Thomas R. Gross;由ACM Digital Lirary發表在第18屆Languages and Compilers for Parallel Computing會議上)
    • Oracle JRockit: The Definitive Guide (Marcus Hirt, Marcus Lagergren; Packt Publishing, 2010): Oracle JRockit權威指南

 

英文原文:javaworld,翻譯:ImportNew - 曹旭東

譯文鏈接: http://www.importnew.com/2009.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章