深入理解 Java 虛擬機--晚期(運行期)優化

摘自《深入理解 Java 虛擬機:JVM 高級特性與最佳實踐》(第二版)

        從計算機程序出現的第一天起,對效率的追求就是程序天生的堅定信仰,這個過程猶如一場沒有終點、永不停歇的 F1 方程式競賽,程序員是車手,技術平臺則是賽道上飛馳的賽車。

概述

        在部分的商用虛擬機(Sun HotSpot、IBM J9)中,Java 程序最初是通過解釋器(Interpreter)進行解釋執行的,當虛擬機發現某個方法或代碼塊的運行特別頻繁時,就會把這些代碼認定爲“熱點代碼” (Hot Spot Code)。爲了提高熱點代碼的執行效率,在運行時,虛擬機將會把這些代碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器稱爲即時編譯器(Just In Time Compiler,下文中簡稱 JIT 編譯器)。

        即時編譯器並不是虛擬機必需的部分,Java 虛擬機規範並沒有規定 Java 虛擬機內必需要有即時編譯器存在,更沒有限定或指導即時編譯器應該如何去實現。但是,即時編譯器編譯性能的好壞、代碼優化程度的高低卻是衡量一款商用虛擬機優秀與否的最關鍵指標之一,它也是虛擬機內中最核心且最能體現虛擬機技術水平的部分。在本章中,我們將走進虛擬機的內部,探索即時編譯器的運作過程。

        由於 Java 虛擬機規範沒有具體的約束規則去限制即時編譯器應該如何實現,所以這部分功能完全是與虛擬機具體實現(Implementation Specific)相關的內容,如無特殊說明,本章提及的編譯器、即時編譯器都是指 HotSpot 虛擬機內部的即時編譯器,虛擬機也是特指 HotSpot 虛擬機。不過,本章的大部分內容是描述即時編譯器的行爲,涉及編譯器實現層面的內容較少,而主流虛擬機中即時編譯器的行爲又有很多相似和想通之處,因此,對其他虛擬機來說也具有較高的參考意義。

HotSpot 虛擬機內的即時編譯器

        在本節中,我們將要了解 HotSpot 虛擬機內的即時編譯器的運作過程,同時,還要解決以下幾個問題:

  • 爲何 HotSpot 虛擬機要使用解釋器與編譯器並存的架構?
  • 爲何 HotSpot 虛擬機要實現兩個不同的即時編譯器?
  • 程序何時使用解釋器執行?何時使用編譯器執行?
  • 哪些程序代碼會被編譯爲本地代碼?如何編譯爲本地代碼?
  • 如何從外部觀察即時編譯器的編譯過程和編譯結果?

解釋器與編譯器

        儘管並不是所有的 Java 虛擬機都採用解釋器與編譯器並存的架構,但許多主流的商用虛擬機,如 HotSpot、J9 等,都同時包含解釋器與編譯器。解釋器與編譯器兩者各有優勢:當程序需要迅速啓動和執行的時候,解釋器可以首先發揮作用,省去編譯的時間,立即執行。在程序運行後,隨着時間的推移,編譯器逐漸發揮作用,把越來越多的代碼編譯成本地代碼之後,可以獲取更高的執行效率。當程序運行環境中內存資源限制較大(如部分嵌入式系統中),可以使用解釋執行節約內存,反之可以使用編譯執行來提升效率。同時,解釋器還可以作爲編譯器激進優化時的一個 “逃生門”,讓編譯器根據概率選擇一些大多數時候都能提升運行速度的優化手段,當激進優化的假設不成立,如加載了新類後類型繼承結構出現變化、出現 “罕見陷阱”(Uncommon Trap)時可以通過逆優化(Deoptimization)退回到解釋狀態繼續執行(部分沒有解釋器的虛擬機中也會採用不進行激進優化的 C1(注:在虛擬機中習慣將 Client Compiler 稱爲 C1,將 Server Compiler 稱爲 C2) 編譯器擔任 “逃生門” 的角色),因此,在整個虛擬機執行架構中,解釋器與編譯器經常配合工作,如圖 11-1 所示。

        HotSpot 虛擬機中內置了兩個即時編譯器、分別稱爲 Client Compiler 和 Server Compiler 或者簡稱爲 C1 編譯器和 C2 編譯器(也叫 Opto 編譯器)。目前主流的 HotSpot 虛擬機(Sun 系列 JDK 1.7 及之前版本的虛擬機)中,默認採用解釋器與其中一個編譯器直接配合的方式工作,程序使用哪個編譯器,取決於虛擬機運行的模式,HotSpot 虛擬機會根據自身版本與宿主機器的硬件性能自動選擇運行模式,用戶也可以使用 “-client” 或 “-server” 參數去強制指定虛擬機運行在 Client 模式或 Server 模式。

        無聊採用的編譯器是 Client Compiler 還是 Server Compiler,解釋器與編譯器搭配使用的方式在虛擬機中成爲 “混合模式” (Mixed Mode),用戶可以使用參數 “-Xint” 強制虛擬機運行於 “解釋模式”(Interpreted Mode),這是編譯器完全不介入工作,全部代碼都使用解釋方式執行。另外,也可以使用參數 “-Xcomp” 強制虛擬機運行於 “編譯模式”(Compiled Mode),這時將優先採用編譯方式執行程序,但是解釋器仍然要在編譯無法進行的情況下介入執行過程,可以通過虛擬機的 “-version” 命令的輸出結果顯示出這 3 種模式,如代碼清單 11-1 所示,請注意黑體字部分。

  1. C:\Users\mk>java -version  
  2. java version "1.7.0_60"  
  3. Java(TM) SE Runtime Environment (build 1.7.0_60-b19)  
  4. Java HotSpot(TM) 64-Bit Server VM (build 24.60-b09, <strong>mixed mode</strong>)  
  5.   
  6. C:\Users\mk>java -Xint -version  
  7. java version "1.7.0_60"  
  8. Java(TM) SE Runtime Environment (build 1.7.0_60-b19)  
  9. Java HotSpot(TM) 64-Bit Server VM (build 24.60-b09, <strong>interpreted mode</strong>)  
  10.   
  11. C:\Users\mk>java -Xcomp -version  
  12. java version "1.7.0_60"  
  13. Java(TM) SE Runtime Environment (build 1.7.0_60-b19)  
  14. Java HotSpot(TM) 64-Bit Server VM (build 24.60-b09, <strong>compiled mode</strong>)  

        由於即時編譯器編譯本地代碼需要佔用程序運行時間,要編譯出優化程度更高的代碼,所花費的時間可能更長:而且想要編譯出優化程度更高的代碼,解釋器可能還要替編譯器收集性能監控信息,這對解釋執行的速度也有影響。爲了在程序啓動響應速度與運行效率之間達到最佳平衡,HotSpot 虛擬機還會逐漸啓用分層編譯(Tiered Compilation)的策略,分層編譯的概念在 JDK 1.6 時期出現,後來一直處於改進階段,最終在 JDK 的 Server 模式虛擬機中作爲默認編譯策略被開啓。分層編譯根據編譯器編譯、優化的規模與耗時,劃分出不同的編譯層次,其中包括:
  • 第 0 層,程序解釋執行,解釋器不開啓性能監控功能(Profiling),可觸發第 1 層編譯。
  • 第 1 層,也稱爲 C1 編譯,將字節碼編譯爲本地代碼,進行簡單、可靠的優化,如有必要將加入性能監控的邏輯。
  • 第 2 層(或 2 層以上),一稱爲 C2 編譯,也是將字節碼編譯爲本地代碼,但是會啓用一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。

        實施分層編譯後,Client Compiler 和 Server Compiler 將會同時工作,許多代碼都可能會被多次編譯,用 Client Compiler 獲取更高的編譯速度,用 Server Compiler 來獲取更高的編譯質量,在解釋執行的時候也無須再承擔收集性能監控信息的任務。

編譯對象與觸發條件

        上文中提到過,在運行過程中會被即時編譯器編譯的 “熱點代碼” 有兩類,即:

  • 被多次調用的方法。
  • 被多次執行的循環體。

        前者很好理解,一個方法被調用得多了,方法體內代碼執行的次數自然就多,它成爲 “熱點代碼” 是理所當然的。而後者則爲了解決一個方法只被調用過一次或少量的幾次,但是方法體內部存在循環次數較多的循環體內部存在循環次數較多的循環體的問題,這樣循環次數較多的循環體的問題,這樣循環體的代碼也被重複執行多次,因此這些代碼也應該認爲是 “熱點代碼”。

        對於第一種情況,由於是由方法調用觸發的編譯,因此編譯器理所當然地會以整個方法作爲編譯對象,這種編譯也是虛擬機中標準的 JIT 編譯方式。而對於後一種情況,儘管編譯動作是由循環體所觸發的,但編譯器依然會以整個方法(而不是單獨的循環體)作爲編譯對象。這種編譯方式因爲編譯發生在方法執行過程之中,因此形象地稱之爲棧上替換On Stack Replacement,簡稱爲 OSR 編譯,即方法棧幀還在棧上,方法就被替換了)。

        讀者可能還會有疑問,在上面的文字描述中,無論是 “多次執行的方法”,還是 “多次執行的代碼塊”,所謂 “多次” 都不是一個具體、嚴謹的用語,那到底多少次纔算 “多次” 呢?還有一個問題,就是虛擬機如何統計一個方法或一段代碼被執行過多少次呢?解決了這兩個問題,也就回答了即時編譯被觸發的條件。

        判斷一段代碼是不是熱點代碼,是不是需要觸發即時編譯,這樣的行爲稱爲熱點探測(Hot Spot Detection),其實進行熱點探測並不一定要知道方法具體被調用了多少次,目前主要的熱點探測判定方式有兩種(注:還有其他熱點代碼的探測方式,如基於“蹤跡”(Trace)的熱點探測再最近相當流行,像 Firefox 中的 TraceMonkey 和 Dalvik 中新的 JIT 編譯器都用了這種熱點探測方式),分別如下。

  • 基於採樣的熱點探測(Sample Based Hot Spot Detection):採用這種方法的虛擬機會週期性地檢查各個線程的棧頂,如果發現某個(或某些)方法經常出現在棧頂,那這個方法就是 “熱點方法”。基於採樣的熱點探測的好處是實現簡單、高效,還可以很容易地獲取方法調用關係(將調用堆棧展開即可),缺點是很難精確地確認一個方法的熱度,容易因爲受到線程阻塞或別的外界因素的影響而擾亂熱點探測。
  • 基於計數器的熱點探測(Counter Based Hot Spot Detection):採用這種方法的虛擬機會爲每個方法(甚至是代碼塊)建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值就認爲它是 “熱點方法”。這種統計方法實現起來麻煩一些,需要爲每個方法建立並維護計數器,而且不能直接獲取到方法的調用關係,但是它的統計結果相對來說更加精確和嚴謹。

        在 HotSpot 虛擬機中使用的是第二種——基於計數器的熱點探測方法,因此它爲每個方法準備了兩類計數器:方法調用計數器(Invocation Counter)和回邊計數器(Back Edge Counter)。

        在確定虛擬機運行參數的前提下,這兩個計數器都有一個確定的閾值,當計數器超過閾值溢出了,就會觸發 JIT 編譯。

        我們首先來看看方法調用計數器。顧名思義,這個計數器就用於統計方法被調用的次數,它的默認閾值在 Client 模式下是 1500 此,在 Server 模式下是 10 000 次,這個閾值可以通過虛擬機參數-XX:CompileThreshold來人爲設定。當一個方法被調用時,會先檢查該方法是否存在被 JIT 編譯過的版本,如果存在,則優先使用編譯後的本地代碼來執行。如果不存在已被編譯過的版本,則將此方法的調用計數器值加 1,然後判斷方法調用計數器與回邊計數器值之和是否查過方法調用計數器的閾值。如果已超過閾值,那麼將會向即時編譯器提交一個該方法的代碼編譯請求。

        如果不做任何設置,執行引擎並不會同步等待編譯請求完成,而是繼續進入解釋器按照解釋方式執行字節碼,直到提交的請求被編譯器編譯完成。當編譯工作完成之後,這個方法調用入口地址就會被系統自動改成新的,下一次調用該方法時就會使用已編譯的版本。整個 JIT 編譯的交互過程如圖 11-2 所示。

        如果不做任何設置,方法調用計數器統計的並不是方法被調用的絕對次數,而是一個相對的執行頻率,即一段時間之內方法被調用的次數。當超過一定的時間限度,如果方法的調用次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的調用計數器就會被減少一半,這個過程稱爲方法調用計數器熱度的衰減(Counter Decay),而這段時間就稱爲此方法統計的半衰週期(Counter Half Life Time)。進行熱度衰減的動作是在虛擬機進行垃圾收集時順便進行的,可以使用虛擬機參數 -XX: -UseCounterDecay 來關閉熱度衰減,讓方法計數器統計方法調用的絕對次數,這樣,只要系統運行時間足夠長,絕大部分方法都會被編譯成本地代碼。另外,可以使用 -XX: CounterHalfLifeTime 參數設置半衰週期的時間,單位是秒。

        現在我們再來看看另一個計數器——回邊計數器,它的作用是統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱爲 “回邊”(Back Edge)。顯然,建立回邊計數器統計的目的就是爲了觸發 OSR 編譯。

        關於回邊計數器的閾值,雖然 HotSpot 虛擬機也提供了一個類似於方法調用計數器閾值 -XX: CompileThreshold 的參數 -XX: BackEdgeThreashold 供用戶設置,但是當前的虛擬機實際上並未使用此參數,因此我們需要設置另外一個參數-XX: OnStackReplacePercentage 來簡介調整回邊計數器的閾值,其計算公式如下。

  • 虛擬機運行在 Client 模式下,回邊計數器閾值計算公式爲:

        方法調用計數器閾值(CompileThreshold)× OSR 比率(OnStackReplacePercentage)/ 100

        其中 OnStackReplacePercentage 默認值爲 933,如果都取默認值,那 Client 模式虛擬機的回邊計數器的閾值爲 13995。

  • 虛擬機運行在 Server 模式下,回邊計數器閾值的計算公式爲:

        方法調用計數器閾值(CompileThreshold)× (OSR 比率(OnStackReplacePercentage)- 解釋器監控比率(InterpreterProfilePercentage)) / 100

        其中 OnStackReplacePercentage 默認值爲 140,InterpreterProfilePercentage 默認值爲 33,如果都取默認值,那 Server 模式虛擬機回邊計數器的閾值爲 10700。

        當解釋器遇到一條回邊指令時,會先查找將要執行的代碼片段是否有已經編譯好的版本,如果有,它將會有限執行已編譯的代碼,否則就把回邊計數器的值加 1,然後判斷方法調用計數器與回邊計數器之和是否超過回邊計數器的閾值。當超過閾值的時候,將會提交一個 OSR 編譯請求,並且把回邊計數器的值降低一些,以便繼續在解釋器中執行循環,等待編譯器輸出編譯結果,整個執行過程如圖 11-3 所示。

        與方法計數器不同,回邊計數器沒有計數熱度衰減的過程,因此這個計數器統計的就是該方法循環執行的絕對次數。當計數器溢出的時候,它還會把方法計數器的值也調整到溢出狀態,這樣下次再進入該方法的時候就會執行標準編譯過程。

        最後需要提醒一點,圖 11-2 和 圖 11-3 都僅僅描述了 Client VM 的即時編譯方式,對於 Server VM 來說,執行情況會比上面的描述更復雜一些。從理論上了解過編譯對象和編譯觸發條件後,我們再從 HotSpot 虛擬機的源碼中觀察一下,在 MethodOop.hpp(一個 methodOop 對象代表了一個 Java 方法)中,定義了 Java 方法在虛擬機中的內存佈局,如下所示:

  1. // |------------------------------------------------------|  
  2. // | header                                               |  
  3. // | klass                                                |  
  4. // |------------------------------------------------------|  
  5. // | constMethodOop                 (oop)                 |  
  6. // |------------------------------------------------------|  
  7. // | methodData                     (oop)                 |  
  8. // | interp_invocation_count                              |  
  9. // |------------------------------------------------------|  
  10. // | access_flags                                         |  
  11. // | vtable_index                                         |  
  12. // |------------------------------------------------------|  
  13. // | result_index (C++ interpreter only)                  |  
  14. // |------------------------------------------------------|  
  15. // | method_size             | max_stack                  |  
  16. // | max_locals              | size_of_parameters         |  
  17. // |------------------------------------------------------|  
  18. // |intrinsic_id|   flags    |  throwout_count            |  
  19. // |------------------------------------------------------|  
  20. // | num_breakpoints         |  (unused)                  |  
  21. // |------------------------------------------------------|  
  22. // | <strong>invocation_counter</strong>                                |  
  23. // | <strong>backedge_counter</strong>                                  |  
  24. // |------------------------------------------------------|  
  25. // |           prev_time (tiered only, 64 bit wide)       |  
  26. // |                                                      |  
  27. // |------------------------------------------------------|  
  28. // |                  rate (tiered)                       |  
  29. // |------------------------------------------------------|  
  30. // | code                           (pointer)             |  
  31. // | i2i                            (pointer)             |  
  32. // | adapter                        (pointer)             |  
  33. // | <strong>from_compiled_entry</strong>         (pointer)             |  
  34. // | <strong>from_interpreted_entry</strong>     (pointer)             |  
  35. // |------------------------------------------------------|  
  36. // | native_function       (present only if native)       |  
  37. // | signature_handler     (present only if native)       |  
  38. // |------------------------------------------------------|  

        在這個內存佈局中,一行長度爲 32 bit,從中可以清楚地看到方法調用計數器和回邊計數器所在的位置和長度。還有 from_compiled_entry 和 from_interpreted_entry 這兩個方法的入口。

編譯過程

        在默認設置下,無論是方法調用產生的即時編譯請求,還是 OSR 編譯請求,虛擬機在代碼編譯器還未完成之前,都仍然將按照解釋方式繼續執行,而編譯動作則在後臺的編譯線程中進行。用戶可以通過參數 -XX: -BackgroundCompilation 來禁止後臺編譯,在禁止後臺編譯後,一旦達到 JIT 的編譯條件,執行線程向虛擬機提交編譯請求後將會一直等待,知道編譯過程完成後再開始執行編譯器輸出的本地代碼。

        那麼在後臺執行編譯的過程中,編譯器做了什麼事情呢?Server Compiler 和 Client Compiler 兩個編譯器的編譯過程是不一樣的。對於 Client Compiler 來說,它是一個簡單快速的一段式編譯器,主要的關注點在於局部性的優化,而放棄了許多耗時較長的全局優化手段。

        在第一個階段,一個平臺獨立的前端將字節碼構造成一種高級中間代碼表示(High-Level Intermediate Representation,HIR)。HIR 使用靜態單分配(Static Single AssignmentSSA)的形式來代表代碼值,這可以使得一些在 HIR 的構造過程之中和之後進行的優化動作更容易實現。在此之前編譯器會在字節碼上完成一部分基礎優化,如方法內聯常量傳播等優化將會在字節碼被構造成 HIR 之前完成。

        在第二個階段,一個平臺相關的後端從 HIR 中產生低級中間代碼表示(Low-Level Intermediate Representation,LIR),而在此之前會在 HIR 上完成另外一些優化,如空值檢查消除範圍檢查消除等,以便讓 HIR 達到更高效的代碼表示形式。

        最後階段是在平臺相關的後端使用線性掃描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,並在 LIR 上做窺孔(Peephole)優化,然後產生機器代碼。Client Compiler 的大致執行過程如圖 11-4 所示。

        而 Server Compiler 則是專門面向服務端的典型應用併爲服務端的性能配置特別調整過的編譯器,也是一個充分優化過的高級編譯器,幾乎能達到 GNU C++ 編譯器使用 -O2 參數時的優化強度,它會執行所有經典的優化動作,如無用代碼消除(Dead Code Elimination)、循環展開(Loop Unrolling)、循環表達式外提(Loop Expression Hoisting)、消除公共子表達式(Common Subexpression Elimination)、常量傳播(Constant Propagation)、基本塊重排序(Basic Block Reordering)等,還會實施一些與 Java 語言特性密切相關的優化技術,如範圍檢查消除(Range Check Elimination)、空值檢查消除(Null Check Elimination,不過並非所有的控制檢查消除都是依賴編譯器優化的,有一些是在代碼運行過程中自動優化了)等。另外,還可能根據解釋器或 Client Compiler 提供的性能監控信息,進行一些不穩定的激進優化如守護內聯(Guarded Inlining)、分支頻率預測(Branch Frequency Prediction)等。本章的下半部分將會挑選上述的一部分優化手段進行分析和講解。

        Server Compiler 的寄存器分配器是一個全局圖着色分配器,它可以充分利用某些處理器架構(如 RISC)上的大寄存器集合。以即時編譯的標準來看,Server Compiler 無疑是比較緩慢的,但它的編譯速度依然遠遠超過傳統的靜態優化編譯器,而且它相對於 Client Compiler 編譯輸出的代碼質量有所提高,可以減少本地代碼的執行時間,從而抵消了額外的編譯時間開銷,所以也有很多非服務端的應用選擇使用 Server 模式的虛擬機運行。

        在本節中,涉及了許多編譯原理和代碼優化中的概念名詞,沒有這方面基礎的讀者,閱讀起來會感覺到抽象和理論化。有這種感覺並不奇怪,JIT 編譯過程本來就是一個虛擬機中最體現技術水平也是醉複雜的部分,不可能一較短的篇幅就介紹得很詳細,另外,這個過程對 Java 開發來說是透明的,程序員平時無法感知它的存在,還好 HotSpot 虛擬機提供了兩個可視化的工具,讓我們可以 “看見” JIT 編譯器的優化過程,在稍後筆者將演示這個過程。

查看及分析即時編譯結果

        一般來說,虛擬機的即時編譯過程對用戶程序是完成透明的,虛擬機通過解釋執行代碼還是編譯執行代碼,對於用戶來說並沒有什麼影響(執行結果沒有影響,速度上會有很大差別)。在大多數情況下用戶也沒有必要知道。但是虛擬機也提供了一些參數用來輸出即時編譯和某些優化手段(如方法內聯)的執行狀況,本節將介紹如何從外部觀察虛擬機的即時編譯行爲。

        本節中提到的運行參數有一部分需要 Debug 或 FastDebug 版虛擬機的支持,Product 版的虛擬機無法使用這部分參數。如果讀者使用的是根據本書第 1 章的內容自己編譯的 JDK,注意將 SKIP_DEBUG_BUILD 或 SKIP_FASTDEBUG_BUILD 參數設置爲 false,也可以在 OpenJDK 網站上直接下載 FastDebug 班的 JDK(從 JDK 6u25 之後 Oracle 官網就不再提供 FastDebug 的 JDK 下載了)。注意,本節中所有的測試都基於代碼清單 11-2 所示的 Java 代碼。

代碼清單 11-2  測試代碼

  1. public static final int NUM = 15000;  
  2.   
  3. public static int doubleValue(int i) {  
  4.     // 這個空循環用於後面演示 JIT 代碼優化過程  
  5.     for (int j = 0; j < 100000; j++);  
  6.     return i * 2;  
  7. }  
  8.   
  9. public static long calcSum() {  
  10.     long sum = 0;  
  11.     for (int i = 1; i <= 100; i++) {  
  12.         sum += doubleValue(i);  
  13.     }  
  14.     return sum;  
  15. }  
  16.   
  17. public static void main(String[] args) {  
  18.     for (int i = 0; i < NUM; i++) {  
  19.         calcSum();  
  20.     }  
  21. }  

        首先運行這段代碼,並且確認這段代碼是否觸發了即時編譯,要知道某個方法是否被編譯過,可以使用參數 -XX: +PrintCompilation要求虛擬機在即時編譯時將被編譯成本地代碼的方法名稱打印出來,如代碼清單 11-3 所示(其中帶有 “%” 的輸出說明是由回邊計數器觸發的 OSR 編譯)。

代碼清單 11-3  被即時編譯的代碼

  1. VM option'+PrintCompilation'  
  2. 310 1 java.lang.String:charAt(33 bytes)  
  3. 329 2 org.fenixsoft.jit.Test:calcSum(26 bytes)  
  4. 329 3 org.fenixsoft.jit.Test:doubleValue(4 bytes)  
  5. 332 1%org.fenixsoft.jit.Test:main@520 bytes)  

        從代碼清單 11-3 輸出的確認信息中可以確認 main()、calcSum() 和 doubleValue() 方法已經被編譯,我們還可以加上參數 -XX: +PrintInlinint 要求虛擬機輸出方法內聯信息,如代碼清單 11-4 所示。

代碼清單  11-4  內聯信息

  1. VM option'+PrintCompilation'  
  2. VM option'+PrintInlining'  
  3. 273 1 java.lang.String:charAt(33 bytes)  
  4. 291 2 org.fenixsoft.jit.Test:calcSum(26 bytes)  
  5. @9 org.fenixsoft.jit.Test:doubleValue inline(hot)  
  6. 294 3 org.fenixsoft.jit.Test:doubleValue(4 bytes)  
  7. 295 1%org.fenixsoft.jit.Test:main@520 bytes)  
  8. @5 org.fenixsoft.jit.Test:calcSum inline(hot)  
  9. @9 org.fenixsoft.jit.Test:doubleValue inline(hot)  

        從代碼清單 11-4 的輸出中可以看到方法 doubleValue() 被內聯編譯到 calcSum() 中,而 calcSum() 又被內聯到方法 main() 中,所以虛擬機再次執行 main() 方法的時候(舉例而已,main() 方法並不會運行兩次),calcSum() 和 doubleValue() 方法都不會再被調用,它們的代碼邏輯都被直接內聯到 main() 方法中了。

編譯優化技術

        Java 程序員有一個共識,以編譯方式執行本地代碼比解釋方式更快,之所以有這樣的共識,除去虛擬機解釋執行字節碼時額外消耗時間的原因外,還有一個很重要的原因就是虛擬機設計團隊幾乎把對代碼的所有優化措施都集中在了即時編譯器之中(在 JDK 1.3 之後,javac 就去除了 -O 選項,不會生成任何字節碼級別的優化代碼了)。因此一般來說,即時編譯器產生的本地代碼會比 javac 產生的字節碼更加優秀。下面,筆者將介紹一些 HotSpot 虛擬機的即時編譯器在生成代碼時採用的代碼優化技術。

優化技術概覽

        在 Sun 官方的 Wiki 上,HotSpot 虛擬機設計團隊列出了一個相對比較全面的、在即時編譯器中採用的優化技術列表(見表 11-1),其中有不少經典編譯器的優化手段,也有許多針對 Java 語言(準確地說是針對運行在 Java 虛擬機上的所有語言)本身進行的優化技術,本節將對這些技術進行概括性的介紹,在後面幾節中,再挑選若干重要且典型的優化,與讀者一起看看優化前後的代碼產生了怎樣的變化。

        上述的優化技術看起來很多,而且從名字看都顯得有點 “高深莫測”,雖然實現這些優化也許確實有些難度,但大部分技術理解起來都並不困難。爲了消除讀者對這些優化技術的陌生感,筆者舉一個簡單的例子,即通過大家熟悉的 Java 代碼變化來展示其中幾種優化技術是如何發揮作用的(僅適用 Java 代碼來表示而已)。首先從原始代碼開始,如代碼清單 11-6 所示。

代碼清單 11-6  優化前的原始代碼

  1. static class B{  
  2.     int value;  
  3.     final int get() {  
  4.         return value;  
  5.     }  
  6. }  
  7. public void foo() {  
  8.     y = b.get();  
  9.     // ...do stuff...  
  10.     z = b.get();  
  11.     sum = y + z;  
  12. }  

        首先需要明確的是,這些代碼優化變換是建立在代碼的某種中間表示機器碼之上的,絕不是建立在Java 源碼之上的,爲了展示方便,筆者使用了 Java 語言的語法來表示這些優化技術所發揮的作用。

        代碼清單11-6 的代碼已經非常簡單了,但是仍然有許多優化的餘地。第一步進行方法內聯(Method Inlining),方法內聯的重要性要高於其他優化措施,它的主要目的有兩個,一是去除方法調用的成本(如建立棧幀等)。二是爲其他優化建立良好的基礎,方法內聯膨脹之後可以便於在更大範圍上採取後續的優化手段,從而獲取更好的優化效果。因此,各種編譯器一般都會把內聯優化放在優化序列的最靠前位置。內聯後的代碼如代碼清單 11-7 所示。

代碼清單 11-7  內聯後的代碼

  1. public void foo() {  
  2.     y = b.value;  
  3.     // ...do stuff...  
  4.     z = b.value;  
  5.     sum = y + z;  
  6. }  

        第二步進行冗餘訪問消除(Redundant Loads Elimination),假設代碼中間註釋掉的 “do stuff...” 所代表的操作不會改變 b.value 的值,那就可以把 “z = b.value” 替換爲 “z = y”,因爲上一句 “y = b.value” 已經保證了變量 y 與 b.value 是一致的,這樣就可以不再去訪問對象 b 的局部變量了。如果把 b.value 看做是一個表達式,那也可以把這項優化看成是公共子表達式消除(Common Subexpression Elimination),優化後的代碼如代碼清單 11-8 所示。

代碼清單 11-8  冗餘存儲消除的代碼

  1. public void foo() {  
  2.     y = b.value;  
  3.     // ...do stuff...  
  4.     z = y;  
  5.     sum = y + z;  
  6. }  

        第三步我們進行複寫傳播(Copy Propagation),因爲在這段程序的邏輯中並沒有必要使用一個額外的變量 “z”,它與變量 “y” 是完全相等的,因此可以使用 “y” 來代替 “z”。複寫傳播之後程序如代碼清單 11-9 所示。

代碼清單 11-9  複寫傳播的代碼

  1. public void foo() {  
  2.     y = b.value;  
  3.     // ...do stuff...  
  4.     y = y;  
  5.     sum = y + y;  
  6. }  

        第四步我們進行無用代碼消除(Dead Code Elimination)。無用代碼可能是永遠不會被執行的代碼,也可能是完全沒有意義的代碼,因此,它又形象地稱爲 “Dead Code”,在代碼清單 11-9 中,“y = y” 是沒有意義的,把它消除後的程序如代碼清單 11-10 所示。

代碼清單 11-10  進行無用代碼消除的代碼

  1. public void foo() {  
  2.     y = b.value;  
  3.     // ...do stuff...  
  4.     sum = y + y;  
  5. }  

        經過四次優化之後,代碼清單 11-10 與代碼清單 11-6 所達到的效果是一致的,但是前者比後者省略了許多語句(體現在字節碼和機器碼指令上的差距會更大),執行效率也會更高。編譯器的這些優化技術實現起來也許比較複雜,但是要理解它們的行爲對於一個普通的程序員來說是沒有困難的,接下來,我們將繼續查看如下的幾項最有代表性的優化技術是如何運作的,它們分別是:
  • 語言無關的經典優化技術之一:公共子表達式消除。
  • 語言相關的經典優化技術之一:數組範圍檢查消除。
  • 最重要的優化技術之一:方法內聯。
  • 最前沿的優化技術之一:逃逸分析。

公共子表達式消除

        公共子表達式消除是一個普遍應用於各種編譯器的經典優化技術,它的含義是:如果一個表達式 E 已經計算過了,並且從先前的計算到現在 E 中所有變量的值都沒有發生變化,那麼 E 的這次出現就成爲了公共子表達式。對於這種表達式,沒有必要花時間再對它進行計算,只需要直接用前面計算過的表達式結果代替 E 就可以了。如果這種優化僅限於程序的基本塊內,便稱爲局部公共子表達式消除(Local Common Subexpression Elimination),如果這種優化的範圍涵蓋了多個基本塊,那就稱爲全局公共子表達式消除(Global Common Subexpression Elimination)。舉個簡單的例子來說明它的優化過程,假設存在如下代碼:

  1. int d = (c * b) * 12 + a + (a + b * c);  

        如果這段代碼交給 Javac 編譯器則不會進行任何優化,那生成的代碼將如代碼清單 11-11 所示,是完全遵照 Java 源碼的寫法直譯而成的。

代碼清單 11-11  未做任何優化的字節碼

  1. iload_2     // b  
  2. imul        // 計算b * c  
  3. bipush 12   // 推入12  
  4. imul        // 計算(c * b)*12  
  5. iload_1     // a  
  6. iadd        // 計算(c * b)*12+a  
  7. iload_1     // a  
  8. iload_2     // b  
  9. iload_3     // c  
  10. imul        // 計算b * c  
  11. iadd        // 計算a+b * c  
  12. iadd        // 計算(c * b)*12+a+(a+b * c)  
  13. istore 4  

        當這段代碼進入到虛擬機即時編譯器後,它將進行如下優化:編譯器檢測到 “c*b” 與 “b*c” 是一樣的表達式,而且在計算期間 b 與 c 的值是不變的。因此,這條表達式就可能被視爲:
  1. int d = E * 12 + a + (a + E);  

        這時,編譯器還可能(取決於那種虛擬機的編譯器以及具體的上下文而定)進行另外一種優化:代數化簡(Algebraic Simplification),把表達式變爲:
  1. int d = E * 13 + a * 2;  

        表達式進行變換之後,再計算起來就可以節省一些時間了。如果讀者還對其他的經典編譯優化技術感興趣,可以參考《編譯原理》(俗稱 “龍書”,推薦使用 Java 的程序員閱讀 2006 年版的 “紫龍書”)中的相關章節。

數組邊界檢查消除

        數組邊界檢查消除(Array Bounds Checking Elimination)是即時編譯器中的一項語言相關的經典優化技術。我們知道 Java 語言是一門動態安全的語言,對數組的讀寫訪問也不像 C、C++ 那樣在本質上是裸指針操作。如果有一個數組 foo[],在 Java 語言中訪問數組元素 foo[i] 的時候系統將會自動進行上下界的範圍檢查,即檢查 i 必須滿足 i >= 0 && i < foo.length 這個條件,否則將會拋出一個運行時異常:java.lang.ArrayIndexOutOfBoundsException。這對軟件開發者來說是一件很好的事情,即使程序員沒有專門編寫防禦代碼,也可以避免大部分的溢出攻擊。但是對於虛擬機的執行子系統來說,每次數組元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量數組訪問的程序代碼,這無疑也是一種性能負擔。

        無論如何,爲了安全,數組邊界檢查肯定是必須做的,但數組邊界是不是必須在運行期間一次不漏地檢查則是可以 “商量” 的事情。例如下面這個簡單的情況:數組下標是一個常量,如 foo[3],只要在編譯期根據數組流分析來確定 foo.length 的值,並判斷下標 “3” 沒有越界,執行的時候就無須判斷了。更加常見的情況是數組訪問發生在循環之中,並且使用循環遍歷來進行數組訪問,如果編譯器只要通過數據流分析就可以判定循環變量的取值範圍永遠在區間[0, foo.length)之內,那在整個循環中就可以把數組的上下界檢查消除,這可以節省很多次的條件判斷操作。

        將這個數組邊界檢查的例子放在更高的角度來看,大量的安全檢查令編寫 Java 程序比編寫 C/C++ 程序容易很多,如數組越界會得到 ArrayIndexOutOfBoundsException 異常,空指針訪問會得到 NullPointException,除數爲零會得到 ArithmeticException 等,在 C/C++ 程序中出現類似的問題,一不小心就會出現 Segment Fault 信號或者 Windows 編程中常見的 “xxx 內存不能爲 Read/Wrie” 之類的提示,處理不好程序就和直接崩潰退出了。但這些安全檢查也導致了相同的程序,Java 要比 C/C++ 做更多的事情(各種檢查判斷),這些事情就成爲一種隱式開銷,除了如數組邊界檢查優化這種儘可能把運行期檢查提到編譯期完成的思路之外,另外還有一種避免思路——隱式異常處理,Java 中空指針檢查和算術運算中除數爲零的檢查都採用了這種思路。舉個例子,例如程序中訪問一個對象(假設對象叫 foo)的某個屬性(假設屬性叫 value),那以 Java 僞代碼來表示虛擬機訪問 foo.value 的過程如下。

  1. if (foo != null) {  
  2.     return foo.value;  
  3. else {  
  4.     throw new NullPointException();  
  5. }  

        在使用隱式異常優化之後,虛擬機會把上面僞代碼所表示的訪問過程變爲如下僞代碼。
  1. try {  
  2.      return foo.value;  
  3. catch (segment_fault) {  
  4.     uncommon_trap();  
  5. }  

        虛擬機會註冊一個 Segment Fault 信號的異常處理器(僞代碼中的 uncommon_trap()),這樣當 foo 不爲空的時候,對 value 的訪問是不會額外消耗一次對 foo 判空的開銷的。代價就是當 foo 真的爲空時,必須轉入到異常處理器中恢復並拋出 NullPointException 異常,這個過程必須從用戶態轉到內核態中處理,結束後再回到用戶態,速度遠比一次判空檢查慢。當 foo 極少爲空的時候,隱式異常優化是值得的,但假如 foo 經常爲空的話,這樣的優化反而會讓程序更慢,還好 HotSpot 虛擬機足夠 “聰明”,它會根據運行期收集到的 Profile 信息自動選擇最優方案。

        與語言相關的其他消除操作還有不少,如自動裝箱消除(Autobox Elimination)、安全點消除(Safepoint Elimination)、消除反射(Dereflection)等,筆者就不再一一介紹了。

方法內聯

        在前面的講解之中我們提到過方法內聯,它是編譯器最重要的優化手段之一,除了消除方法調用的成本之外,它更重要的意義是爲其他優化手段建立良好的基礎,如代碼清單 11-12 所示的簡單例子就揭示了內聯對其他優化手段的意義:事實上 testInline() 方法的內部全部都是無用的代碼,如果不做內聯,後續即使進行了無用代碼消除的優化,也無法發現任何 “Dead Code”,因爲如果分開來看,foo() 和 testInline() 兩個方法裏面的操作都可能是有意義的。

代碼清單 11-12  未做任何優化的字節碼

  1. public static void foo (Object obj) {  
  2.     if (obj != null) {  
  3.         System.out.println("do something");  
  4.     }  
  5. }  
  6. public static void testInline(String[]args) {  
  7.     Object obj = null;  
  8.     foo (obj);  
  9. }  

        方法內聯的優化行爲看起來很簡單,不過是把目標方法的代碼 “複製” 到發起調用的方法之中,避免發生真實的方法調用而已。但實際上 Java 虛擬機中的內聯過程遠遠沒有那麼簡單,因爲如果不是即時編譯器做了一些特別的努力,按照經典編譯原理的優化理論,大多數的 Java 方法都無法進行內聯。

        無法內聯的原因其實在《虛擬機字節碼執行引擎》中講解 Java 方法解析和分派調用的時候就已經介紹過。只有使用 invokespecial 指令調用的私有方法實例構造器父類方法以及使用invokestatic 指令進行調用的靜態方法纔是在編譯期進行解析的,除了上述 4 種方法之外,其他的 Java 方法調用都需要在運行時進行方法接收者的多態選擇,並且都可能存在多於一個版本的方法接收者(最多再除去被 final 修飾的方法這種特殊情況,儘管它使用 invokevirtual 指令調用,但也是非虛方法,Java 語言規範中明確說明了這點),簡而言之,Java 語言中默認的實例方法是虛方法

        對於一個虛方法,編譯期做內聯的時候根本就無法確定應該使用哪個方法版本,如果以代碼清單 11-7 中把 “b.get()” 內聯爲 “b.value” 爲例的話,就是不依賴上下文就無法確定 b 的實際類型是什麼。假如有 ParentB 和 SubB 兩個具有繼承關係的類,並且子類重寫了父類的 get() 方法,那麼,是要執行父類的 get() 方法還是子類的 get() 方法,需要在運行期才能確定,編譯期無法得出結論。

        由於 Java 語言提倡使用面向對象的編程方式進行編程,而 Java 對象的方法默認就是虛方法,因此 Java 間接鼓勵了程序員使用大量的虛方法來完成程序邏輯。根據上面的分析,如果內聯與虛方法之間產生 “矛盾”,那該怎麼辦呢?是不是爲了提高執行性能,就要到處使用 final 關鍵字去修飾方法呢?

        爲了解決虛方法的內聯問題,Java 虛擬機設計團隊想了很多辦法,首先是引入了一種名爲 “類型繼承關係分析”(Class Hierarchy Analysis,CHA)的技術,這是一種基於整個應用程序的類型分析技術,它用於確定在目前已加載的類中,某個接口是否有多於一種的實現,某個類是否存在子類、子類是否爲抽象類等信息

        編譯器在進行內聯時,如果是非虛方法,那麼直接進行內聯就可以了,這時候的內聯是有穩定前提保障的。如果遇到虛方法,則會向 CHA 查詢此方法在當前程序下是否有多個目標版本可供選擇,如果查詢結果只有一個版本,那也可以進行內聯,不過這種內聯就屬於激進優化,需要預留一個 “逃生門”(Guard 條件不成立時的 Slow Path),稱爲守護內聯(Guarded Inlining)。如果程序的後續執行過程中,虛擬機一直沒有加載到會令這個方法的接收者的繼承關係發生變化的類,那這個內聯優化的代碼就可以一直使用下去。但如果加載了導致繼承關係發生變化的新類,那就需要拋棄已經編譯的代碼,退回到解釋狀態執行,或者重新進行編譯。

        如果向 CHA 查詢出來的結果是多個版本的目標方法可供選擇,則編譯器還將會進行最後一次努力,使用內聯緩存(Inline Cache)來完成方法內聯,這是一個建立在目標方法正常入口之前的緩存,它的工作原理大致是:在未發生方法調用之前,內聯緩存狀態爲空,當第一次調用發生後,緩存記錄下方法接收者的版本信息,並且每次進行方法調用時都比較接收者版本,如果以後進來的每次調用的方法接收者版本都是一樣的,那這個內聯還可以一直用下去。如果發生了方法接收者不一致的情況,就說明程序真正使用了虛方法的多態特性,這時纔會取消內聯,查找虛方法表進行方法分派。

        所以說,在許多情況下虛擬機進行的內聯都是一種激進優化,激進優化的手段在高性能的商用虛擬機中很常見,除了內聯之外,對於出現概率很小(通過經驗數據或解釋器收集到的性能監控信息確定概率大小)的隱式異常、使用概率很小的分支等都可以被激進優化 “移除”,如果真的出現了小概率事件,這時纔會從 “逃生門” 回到解釋狀態重新執行

逃逸分析

        逃逸分析(Escape Analysis)是目前 Java 虛擬機中比較前沿的優化技術,它與類型繼承關係分析一樣,並不是直接優化代碼的手段,而是爲其他優化手段提供依據的分析技術

        逃逸分析的基本行爲就是分析對象動態作用域:當一個對象在方法中被定義後,它可能被外部方法所引用,例如作爲調用參數傳遞到其他方法中,稱爲方法逃逸。甚至還有可能被外部線程訪問到,譬如賦值給類變量或可以在其他線程中訪問的實例變量,稱爲線程逃逸。

        如果能證明一個對象不會逃逸到方法或線程之外,也就是別的方法或線程無法通過任何途徑訪問到這個對象,則可能爲這個變量進行一些高效的優化,如下所示。

  • 棧上分配(Stack Allocation):Java 虛擬機中,在 Java 堆上分配創建對象的內存空間幾乎是 Java 程序員都清楚的常識了,Java 堆中的對象對於各個線程都是共享和可見的,只要持有這個對象的引用,就可以訪問堆中存儲的對象數據。虛擬機的垃圾收集系統可以回收堆中不再使用的對象,但回收動作無論是篩選可回收對象,還是回收和整理內存都需要耗費時間。如果確定一個對象不會逃逸出方法之外,那讓這個對象在棧上分配內存將會是一個很不錯的注意,對象所佔用的內存空間就可以隨棧幀出棧而銷燬。在一般應用中,不會逃逸的局部對象所佔的比例很大,如果能使用棧上分配,那大量的對象就會隨着方法的結束而自動銷燬了,垃圾收集系統的壓力將會小很多。
  • 同步消除(Synchronized Elimination):線程同步本身是一個相對耗時的過程,如果逃逸分析能夠確定一個變量不會逃逸出線程,無法被其他線程訪問,那這個變量的讀寫肯定就不會有競爭,對這個變量實施的同步措施也就可以消除掉。
  • 標量替換(Scalar Replacement):標量(Scalar)是指一個數據已經無法再分解成更小的數據來表示了,Java 虛擬機中的原始數據類型(int、long 等數值類型以及 reference 類型等)都不能再進一步分解,它們就可以稱爲標量。相對的,如果一個數據可以繼續分解,那它就稱作聚合量(Aggregate),Java 中的對象就是最典型的聚合量如果把一個 Java 對象拆散,根據程序訪問的情況,將其使用到的成員變量恢復原始類型來訪問就叫做標量替換。如果逃逸分析證明一個對象不會被外部訪問,並且這個對象可以被拆散的話,那程序真正執行的時候將可能不創建這個對象,而改爲直接創建它的若干個被這個方法使用到的成員變量來代替。將對象拆分後,除了可以讓對象的成員變量在棧上(棧上存儲的數據,有很大的概率會被虛擬機分配至物理機器的告訴寄存器中存儲)分配和讀寫之外,還可以爲後續進一步的優化手段創建條件。

        關於逃逸分析的論文在 1999 年就已經發表,但直到 Sun JDK 1.6 才實現了逃逸分析,而且直到現在這項優化尚未足夠成熟,仍有很大的改進餘地。不成熟的原因主要是不能保證逃逸分析的性能收益必定高於它的消耗。如果要完全準確地判斷一個對象是否會逃逸,需要進行數據流敏感的一系列複雜分析,從而確定程序各個分支執行時對此對象的影響。這是一個相對高耗時的過程,如果分析完後發現沒有幾個不逃逸的對象,那這些運行期耗用的時間久白白浪費了,所以目前虛擬機只能採用不那麼準確,但時間壓力相對較小的算法來完成逃逸分析。還有一點是,基於逃逸分析的一些優化手段,如上面提到的 “棧上分配”,由於 HotSpot 虛擬機目前的實現方式導致棧上分配實現起來比較複雜,因此在 HotSpot 中暫時還沒有做這項優化。

        在測試結果中,實施逃逸分析後的程序在 MicroBenchmarks 中往往能運行出不錯的成績,但是在實際的應用程序,尤其是大型程序中反而發現實施逃逸分析可能出現效果不穩定的情況,或因分析過程耗時但卻無法有效判別出非逃逸對象而導致性能(即時編譯的收益)有所下降,所以在很長的一段時間裏,即時是 Server Compiler,也默認不開啓逃逸分析,甚至在某些版本(如 JDK 1.6 Update 18)中還曾經短暫地完全禁止了這項優化。

        如果有需要,並且確認對程序運行有益,用戶可以使用參數 -XX: +DoEscapeAnalysis 來手動開啓逃逸分析,開啓之後可以通過參數 -XX: +PrintEscapeAnalysis 來查看分析結果。有了逃逸分析支持之後,用戶可以使用參數 -XX: +EliminateAllocations 來開啓標量替換,使用+XX: +EliminateLocks 來開啓同步消除,使用參數-XX: +PrintEliminateAllocations 來查看標量的替換情況。

        儘管目前逃逸分析的技術仍不失十分成熟,但是它卻是即時編譯器優化技術的一個重要發展方向,在今後的虛擬機中,逃逸分析技術肯定會支撐起一系列實用有效的優化技術。

Java 與 C/C++ 的編譯器對比

        大多數程序員都認爲 C/C++ 會比 Java 語言塊,甚至覺得從 Java 語言誕生以來 “執行速度緩慢” 的帽子就應當扣在它的頭頂,這種觀點的出現是由於 Java 剛出現的時候即時編譯技術還不成熟,主要靠解釋器執行的 Java 語言性能確實比較低下。但目前即時編譯技術已經十分成熟,Java 語言有可能在速度上與 C/C++ 一爭高下嗎?要想知道這個問題的答案,讓我們從兩者的編譯器談起。

        Java 與 C/C++ 的編譯器對比實際上代表了最經典的即時編譯器與靜態編譯器的對比,很大程度上也決定了 Java 與 C/C++ 的性能對比的結果,因爲無論是 C/C++ 還是 Java 代碼,最終編譯之後被機器執行的都是本地機器碼,哪種語言的性能更高,除了它們自身的 API 庫實現得好壞以外,其餘的比較就成了一場 “拼編譯器” 和 “拼輸出代碼質量” 的遊戲。當然,這種比較也是剔除了開發效率的片面對比,語言間孰優孰劣、誰塊誰慢的問題都是很難有結果的爭論,下面我們就回到正題,看看這兩種語言的編譯器各有何種優勢。

        Java 虛擬機的即時編譯器與 C/C++ 的靜態優化編譯器相比,可能會由於下列這些原因而導致輸出的本地代碼有一些劣勢(下面列舉的也包括一些虛擬機執行子系統的性能劣勢):

        第一,因爲即時編譯器運行佔用的是用戶程序的運行時間,具有很大的時間壓力,它能提供的優化手段也嚴重受制於編譯成本。如果編譯速度不能達到要求,那用戶將在啓動程序或程序的某部分察覺到重大延遲,這點使得即時編譯器不敢隨便引入大規模的優化技術,而編譯的時間成本在靜態優化編譯器中並不是主要的關注點。

        第二,Java 語言是動態的類型安全語言,這就意味着需要由虛擬機來確保程序不會違反語言語義或訪問非結構化內存。從實現層面上看,這就意味着虛擬機必須頻繁地進行動態檢查,如實例方法訪問時檢測空指針、數組元素訪問時檢測上下文範圍、類型轉換時檢測繼承關係等。對於這類程序代碼沒有明確寫出的檢查行爲,儘管編譯器會努力進行優化,但是總體上仍然要消耗不少的運行時間。

        第三,Java 語言中雖然沒有 virtual 關鍵字,但是使用虛方法的頻率卻遠遠大於 C/C++ 語言,這意味着運行時對方法接收者進行多態選擇的頻率要遠遠大於 C/C++ 語言,也意味着即時編譯在進行一些優化(如前面提到的方法內聯)時的難度要遠大於 C/C++ 的靜態優化編譯器。

        第四,Java 語言是可以動態擴展的語言,運行時加載新的類可能改變程序類型的繼承關係,這使得很多全局的優化都難以進行,因爲編譯器無法看見程序的全貌,許多全局的優化措施都只能以激進優化的方式來完成,編譯器不得不時刻注意並隨着類型的變化而在運行時撤銷或重新進行一些優化。

        第五,Java 語言中對象的內存分配都是堆上進行的,只有方法中的局部變量才能在棧上分配。而 C/C++ 的對象則有多種內存分配方式,既可能在堆上分配,又可能在棧上分配,如果可以在棧上分配線程私有的對象,將減輕內存回收的壓力。另外,C/C++ 中主要由用戶程序代碼來回收分配的內存,這就不存在無用對象篩選的過程,因此效率上(僅指運行效率,排除了開發效率)也比垃圾收集機制要高。

        上面說了一大堆 Java 語言相對 C/C++ 的劣勢,不是說 Java 就真的不如 C/C++ 了,相信讀者也注意到了,Java 語言的這些性能上的劣勢都是爲了換取開發效率上的優勢而付出的代價,動態安全、動態擴展、垃圾回收這些 “拖後腿” 的特性都是爲 Java 語言的開發效率做出了很大貢獻。

        何況,還有許多優化是 Java 的即時編譯器能做而 C/C++ 的靜態優化編譯器不能做或者不好做的。例如,在 C/C++ 中,別名分析(Alias Analysis)的難度就要遠高於 Java。Java 的類型安全保證了在類似如下代碼中,只要 ClassA 和 ClassB 沒有繼承關係,那對象 objA 和 objB 就絕不可能是同一個對象,即不會是同一塊內存兩個不同別名。

  1. void foo(ClassA objA, ClassB objB) {  
  2.     objA.x = 123;  
  3.     objB.y = 456;  
  4.     // 只要 objB.y 不是 objA.x 的別名,下面就可以保證輸出爲 123  
  5.     print(objA.x);  
  6. }  

        確定了 objA 和 objB 並非對方的別名後,許多與數據依賴相關的優化纔可以進行(重排序、變量代換)。具體到這個例子中,就是無須擔心 objB.y 其實與 objA.x 指向同一塊內存,這樣就可以安全地確定打印語句中的 objA.x 爲 123。

        Java 編譯器另外一個紅利是由它的動態性所帶來的,由於 C/C++ 編譯器所有優化都在編譯期完成,以運行期性能監控爲基礎的優化措施它都無法進行,如調用頻率預測(Call Frequency Prediction)、分支頻率預測(Branch Frequency Prediction)、裁剪未被選中的分支(Untaken Branch Pruning)等,這些都會成爲 Java 語言都有的性能優勢。

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