《深入理解Java虛擬機》學習筆記(八)(晚期(運行期)優化(JIT編譯器))

晚期(運行期)優化

  • 熱點代碼(Hot Spot Code):運行得特別頻繁的某個方法或代碼塊
    • 被多次調用的方法。
    • 被多次執行的循環體。
  • 即時編譯器(Just In Time Compiler,簡稱JIT編譯器):為了提高熱點代碼的效率,在運行時,把這些代碼編譯成與本地平臺相關的機器碼,并進行各種層次的優化的編譯器

HotSpot虛擬機內的即時編譯器

解釋器與編譯器

當程序需要迅速啟動和執行的時候,解釋器可以首先發揮作用,省去編譯的時間,立即執行。在程序運行后,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的代碼編譯成本地代碼之后,可以獲取更高的執行效率。

圖1 解釋器和編譯器的交互

Client Compiler(C1編譯器)
Server Compiler(C2編譯器(也叫Opto編譯器))

HotSpot虛擬機會逐漸啟用分層編譯(Tiered Compilation)的策略
第0層,程序解釋執行,解釋器不開啟性能監控功能(Profiling),可觸發第1層編譯。
第1層,也稱為C1編譯,將字節碼編譯為本地代碼,進行簡單、 可靠的優化,如有必要將加入性能監控的邏輯。
第2層(或2層以上),也稱為C2編譯,也是將字節碼編譯為本地代碼,但是會啟用一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。

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

編譯對象與觸發條件

  • 熱點代碼

    • 被多次調用的方法。
      • 編譯器理所當然地會以整個方法作為編譯對象,這種編譯也是虛擬機中標準的JIT編譯方式。
    • 被多次執行的循環體。
      • 編譯器依然會以整個方法(而不是單獨的循環體)作為編譯對象。 這種編譯方式因為編譯發生在方法執行過程之中,因此形象地稱之為棧上替換(On Stack Replacement,簡稱為OSR編譯,即方法棧幀還在棧上,方法就被替換了)
  • 熱點探測

    • 判斷一段代碼是不是熱點代碼,是不是需要觸發即時編譯的行為
    • 基于采樣的熱點探測(Sample Based Hot Spot Detection)
      • 虛擬機會周期性地檢查各個線程的棧頂,如果發現某個(或某些)方法經常出現在棧頂,那這個方法就是“熱點方法”。
    • 基于計數器的熱點探測(Counter Based Hot Spot Detection)
      • 虛擬機會為每個方法(甚至是代碼塊)建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值就認為它是“熱點方法”。

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

  • 方法調用計數器
    • 用于統計方法被調用的次數,它的默認閾值在Client模式下是1500次,在Server模式下是10 000次
    • 當一個方法被調用時,會先檢查該方法是否存在被JIT編譯過的版本,如果存在,則優先使用編譯后的本地代碼來執行。 如果不存在已被編譯過的版本,則將此方法的調用計數器值加1,然后判斷方法調用計數器與回邊計數器值之和是否超過方法調用計數器的閾值。 如果已超過閾值,那么將會向即時編譯器提交一個該方法的代碼編譯請求。


      圖2 方法調用計數器觸發即時編譯
    • 方法調用計數器熱度的衰減
      • 當超過一定的時間限度,如果方法的調用次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的調用計數器就會被減少一半,這段時間就稱為此方法統計的半衰周期(Counter Half Life Time)
  • 回邊計數器
    • 統計一個方法中循環體代碼執行的次數
    • 回邊:在字節碼中遇到控制流向后跳轉的指令
    • 建立回邊計數器統計的目的就是為了觸發OSR編譯
    • 虛擬機運行在Client模式下,回邊計數器閾值計算公式為:
      • 方法調用計數器閾值(CompileThreshold)×OSR比率(OnStackReplacePercentage)/100。其中OnStackReplacePercentage默認值為933,如果都取默認值,那Client模式虛擬機的回
        邊計數器的閾值為13995。
    • 虛擬機運行在Server模式下,回邊計數器閾值的計算公式為:
      • 方法調用計數器閾值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解釋器監控比率(InterpreterProfilePercentage)/100。其中OnStackReplacePercentage默認值為140,InterpreterProfilePercentage默認值為33,如果都取默認值,那Server模式虛擬機回邊計數器的閾值為10700。
    • 當解釋器遇到一條回邊指令時,會先查找將要執行的代碼片段是否有已經編譯好的版本,如果有,它將會優先執行已編譯的代碼,否則就把回邊計數器的值加1,然后判斷方法調用計數器與回邊計數器值之和是否超過回邊計數器的閾值。 當超過閾值的時候,將會提交一個OSR編譯請求,并且把回邊計數器的值降低一些,以便繼續在解釋器中執行循環,等待
      編譯器輸出編譯結果


      圖3 回邊計數器觸發即時編譯
    • 與方法計數器不同,回邊計數器沒有計數熱度衰減的過程,因此這個計數器統計的就是該方法循環執行的絕對次數。 當計數器溢出的時候,它還會把方法計數器的值也調整到溢出狀態,這樣下次再進入該方法的時候就會執行標準編譯過程。
  • 在確定虛擬機運行參數的前提下,這兩個計數器都有一個確定的閾值,當計數器超過閾值溢出了,就會觸發JIT編譯。

編譯過程

無論是方法調用產生的即時編譯請求,還是OSR編譯請求,虛擬機在代碼編譯器還未完成之前,都仍然將按照解釋方式繼續執行,而編譯動作則在后臺的編譯線程中進行。
對于Client Compiler來說,它是一個簡單快速的三段式編譯器,主要的關注點在于局部性的優化,而放棄了許多耗時較長的全局優化手段。

  • 在第一個階段,一個平臺獨立的前端將字節碼構造成一種高級中間代碼表示(HighLevel Intermediate Representaion,HIR)。 HIR使用靜態單分配(Static Single Assignment,SSA)的形式來代表代碼值,這可以使得一些在HIR的構造過程之中和之后進行的優化動作更容易實現。 在此之前編譯器會在字節碼上完成一部分基礎優化,如方法內聯、 常量傳播等優化將會在字節碼被構造成HIR之前完成。
  • 在第二個階段,一個平臺相關的后端從HIR中產生低級中間代碼表示(Low-Level Intermediate Representation,LIR),而在此之前會在HIR上完成另外一些優化,如空值檢查消除、 范圍檢查消除等,以便讓HIR達到更高效的代碼表示形式。
  • 最后階段是在平臺相關的后端使用線性掃描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窺孔(Peephole)優化,然后產生機器代碼。


    圖4 Client Compiler架構

Server Compiler則是專門面向服務端的典型應用并為服務端的性能配置特別調整過的編譯器,也是一個充分優化過的高級編譯器,幾乎能達到GNU C++編譯器使用-O2參數時的優化強度。另外,還可能根據解釋器或Client Compiler提供的性能監控信息,進行一些不穩定的激進優化,如守護內聯(Guarded Inlining)、 分支頻率預測(Branch Frequency Prediction)等。

  • Server Compiler的寄存器分配器是一個全局圖著色分配器,它可以充分利用某些處理器架構(如RISC)上的大寄存器集合。

編譯優化技術

虛擬機設計團隊幾乎把對代碼的所有優化措施都集中在了即時編譯器之中

公共子表達式消除

如果一個表達式E已經計算過了,并且從先前的計算到現在E中所有變量的值都沒有發生變化,那么E的這次出現就成為了公共子表達式。 對于這種表達式,沒有必要花時間再對它進行計算,只需要直接用前面計算過的表達式結果代替E就可以了。

數組邊界檢查消除

隱式異常處理

方法內聯

消除方法調用的成本之外,并其他優化手段建立良好的基礎
“類型繼承關系分析”(Class Hierarchy Analysis,CHA):
是一種基于整個應用程序的類型分析技術,它用于確定在目前已加載的類中,某個接口是否有多于一種的實現,某個類是否存在子類、 子類是否為抽象類等信息。

  • 編譯器在進行內聯時,如果是非虛方法,直接進行內聯
  • 如果遇到虛方法,則會向CHA查詢此方法在當前程序下是否有多個目標
    版本可供選擇。
    • 如果查詢結果只有一個版本,那也可以進行內聯,不過這種內聯就屬于激進優化,需要預留一個“逃生門”(Guard條件不成立時的Slow Path),稱為守護內聯(Guarded Inlining)。 如果程序的后續執行過程中,虛擬機一直沒有加載到會令這個方法的接收者的繼承關系發生變化的類,那這個內聯優化的代碼就可以一直使用下去。 但如果加載了導致繼承關系發生變化的新類,那就需要拋棄已經編譯的代碼,退回到解釋狀態執行,或者重新進行編譯。
    • 如果向CHA查詢出來的結果是有多個版本的目標方法可供選擇,則編譯器還將會進行最后一次努力,使用內聯緩存(Inline Cache)來完成方法內聯,這是一個建立在目標方法正常入口之前的緩存,它的工作原理大致是:在未發生方法調用之前,內聯緩存狀態為空,當第一次調用發生后,緩存記錄下方法接收者的版本信息,并且每次進行方法調用時都比較接收
      者版本,如果以后進來的每次調用的方法接收者版本都是一樣的,那這個內聯還可以一直用下去。 如果發生了方法接收者不一致的情況,就說明程序真正使用了虛方法的多態特性,這時才會取消內聯,查找虛方法表進行方法分派。

逃逸分析

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

  • 棧上分配(Stack Allocation):如果確定一個對象不會逃逸出方法之外,那讓這個對象在棧上分配內存將會是一個很不錯的主意。由于HotSpot虛擬機目前的實現方式導致棧上分配實現起來比較復雜,因此在HotSpot中暫時還沒有做這項優化。
  • 同步消除(Synchronization Elimination):線程同步本身是一個相對耗時的過程,如果逃逸分析能夠確定一個變量不會逃逸出線程,無法被其他線程訪問,那這個變量的讀寫肯定就不會有競爭,對這個變量實施的同步措施也就可以消除掉。
  • 標量替換(Scalar Replacement):標量(Scalar)是指一個數據已經無法再分解成更小的數據來表示了,Java虛擬機中的原始數據類型(int、 long等數值類型以及reference類型等)都不能再進一步分解,它們就可以稱為標量。 相對的,如果一個數據可以繼續分解,那它就稱作聚合量(Aggregate),Java中的對象就是最典型的聚合量。 如果把一個Java對象拆散,根據程序訪問的情況,將其使用到的成員變量恢復原始類型來訪問就叫做標量替換。 如果逃逸分析證明一個對象不會被外部訪問,并且這個對象可以被拆散的話,那程序真正執行的時候將可能不創建這個對象,而改為直接創建它的若干個被這個方法使用到的成員變量來代替。

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

劣勢:

  • 第一,因為即時編譯器運行占用的是用戶程序的運行時間,具有很大的時間壓力,它能提供的優化手段也嚴重受制于編譯成本。
  • 第二,Java語言是動態的類型安全語言,這就意味著需要由虛擬機來確保程序不會違反語言語義或訪問非結構化內存。
  • 第三,Java語言中雖然沒有virtual關鍵字,但是使用虛方法的頻率卻遠遠大于C/C++語言,這意味著運行時對方法接收者進行多態選擇的頻率要遠遠大于C/C++語言,也意味著即時編譯器在進行一些優化(如前面提到的方法內聯)時的難度要遠大于C/C++的靜態優化編譯器。
  • 第四,Java語言是可以動態擴展的語言,運行時加載新的類可能改變程序類型的繼承關系,這使得很多全局的優化都難以進行。
  • 第五,Java語言中對象的內存分配都是堆上進行的,只有方法中的局部變量才能在棧上分配。

優勢 :

  • 在C/C++中,別名分析(Alias Analysis)的難度就要遠高于Java。
  • Java編譯器另外一個紅利是由它的動態性所帶來的,由于C/C++編譯器所有優化都在編譯期完成,以運行期性能監控為基礎的優化措施它都無法進行,如調用頻率預測(Call Frequency Prediction)、 分支頻率預測(Branch Frequency Prediction)、 裁剪未被選擇的分支(Untaken Branch Pruning)等,這些都會成為Java語言獨有的性能優勢。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容