《深刻理解Java虛擬機》讀書筆記十

第十一章  晚期(運行期)優化html

一、HotSpot虛擬機內的即時編譯前端

解釋器與編譯器:算法

  • 許多Java虛擬機的執行引擎在執行Java代碼的時候都有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼)兩種選擇

即時編譯器:後端

  • 當虛擬機發現某個方法或者代碼塊的運行特別頻繁時,就會把這些代碼認定爲熱點代碼。爲了提升熱點代碼的執行效率。在運行時虛擬機會把這些代碼編譯成本地平臺相關的機器碼,並進行各類層次的優化,完成這個任務的編譯器稱爲即時編譯器(Just In Time Compiler,下文中簡稱JIT編譯器)。

解釋器與編譯器二者各有優點:數組

  • 當程序須要迅速啓動和執行的時候,解釋器能夠首先發揮做用,省去編譯的時間,當即執行。在程序運行後,隨着時間的推移,編譯器逐漸發揮做用,把愈來愈多的代碼編譯成本地代碼以後,能夠獲取更高的執行效率。
  • 當程序運行環境中內存資源限制較大(如部分嵌入式系統中),可使用解釋執行節約內存,反之可使用編譯執行來提高效率。
  • 同時,解釋器還能夠做爲編譯器激進優化時的一個「逃生門」,讓編譯器根據機率選擇一些大多數時候都能提高運行速度的優化手段,當激進優化的假設不成立,如加載了新類後類型繼承結構出現變化、出現「罕見陷阱」(Uncommon Trap)時能夠經過逆優化(Deoptimization)退回到解釋狀態繼續執行。
  • 部分沒有解釋器的虛擬機中也會採用不進行激進優化的C1編譯器擔任「逃生門」的角色,(在虛擬機中習慣將Client Compiler稱爲C1,將Server Compiler稱爲C2)所以,在整個虛擬機執行架構中,解釋器與編譯器常常配合工做。

 HotSpot虛擬機中即時間編譯器:緩存

  •  HotSpot虛擬機中內置了兩個即時編譯器,分別稱爲Client Compiler和Server Compiler,或者簡稱爲C1編譯器和C2編譯器。
  • 目前主流的HotSpot虛擬機(Sun系列JDK 1.7及以前版本的虛擬機)中,默認採用解釋器與其中一個編譯器直接配合的方式工做,程序使用哪一個編譯器,取決於虛擬機運行的模式,HotSpot虛擬機會根據自身版本與宿主機器的硬件性能自動選擇運行模式,用戶也可使用「-client」或「-server」參數去強制指定虛擬機運行在Client模式或Server模式。
  • 不管採用的編譯器是Client Compiler仍是Server Compiler,解釋器與編譯器搭配使用的方式在虛擬機中稱爲「混合模式」(Mixed Mode),用戶可使用參數「-Xint」強制虛擬機運行於「解釋模式」(Interpreted Mode),這時編譯器徹底不介入工做,所有代碼都使用解釋方式執行。

 HotSpot虛擬機的分層編譯策略:架構

  • 爲了在程序啓動響應速度與運行效率之間達到最佳平衡,HotSpot虛擬機還會逐漸啓用分層編譯(Tiered Compilation)
  • 分層編譯根據編譯器編譯、優化的規模與耗時,劃分出不一樣的編譯層次
  • 第0層,程序解釋執行,解釋器不開啓性能監控功能(Profiling),可觸發第1層編譯。
  • 第1層,也稱爲C1編譯,將字節碼編譯爲本地代碼,進行簡單、可靠的優化,若有必要將加入性能監控的邏輯。
  • 第2層(或2層以上),也稱爲C2編譯,也是將字節碼編譯爲本地代碼,可是會啓用一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。

編譯對象:性能

  • 在運行過程當中會被即時編譯器編譯的「熱點代碼」有兩類,被屢次調用的方法和被屢次執行的循環體。
  • 被屢次調用的方法,整個方法做爲編譯對象。被屢次執行的循環體,儘管編譯動做是由循環體所觸發的,但編譯器依然會以整個方法(而不是單獨的循環體)做爲編譯對象。這種編譯方式由於編譯發生在方法執行過程之中,所以形象地稱之爲棧上替換(On Stack Replacement,簡稱爲OSR編譯,即方法棧幀還在棧上,方法就被替換了)。

觸發條件:優化

  • 判斷一段代碼是否是熱點代碼,是否是須要觸發即時編譯,這樣的行爲稱爲熱點探測(Hot Spot Detection)。前主要的熱點探測斷定方式有兩種,基於採樣的熱點探測和基於計數器的熱點探測
  • 採用基於採樣的熱點探測的虛擬機會週期性地檢查各個線程的棧頂,若是發現某個(或某些)方法常常出如今棧頂,那這個方法就是「熱點方法」。基於採樣的熱點探測的好處是實現簡單、高效,還能夠很容易地獲取方法調用關係(將調用堆棧展開便可),缺點是很難精確地確認一個方法的熱度,容易由於受到線程阻塞或別的外界因素的影響而擾亂熱點探測。
  • 採用基於計數器的熱點探測的虛擬機會爲每一個方法(甚至是代碼塊)創建計數器,統計方法的執行次數,若是執行次數超過必定的閾值就認爲它是「熱點方法」。這種統計方法實現起來麻煩一些,須要爲每一個方法創建並維護計數器,並且不能直接獲取到方法的調用關係,可是它的統計結果相對來講更加精確和嚴謹。
  • 基於計數器的熱點探測有兩種計數器,方法調用計數器(Invocation Counter)和回邊計數器(Back Edge Counter)。在肯定虛擬機運行參數的前提下,這兩個計數器都有一個肯定的閾值,當計數器超過閾值溢出了,就會觸發JIT編譯。

方法調用計數器;spa

  • 這個計數器就用於統計方法被調用的次數,它的默認閾值在Client模式下是1500次,在Server模式下是10000次,這個閾值能夠經過虛擬機參數-XX:CompileThreshold來人爲設定。
  • 當一個方法被調用時,會先檢查該方法是否存在被JIT編譯過的版本,若是存在,則優先使用編譯後的本地代碼來執行。若是不存在已被編譯過的版本,則將此方法的調用計數器值加1,而後判斷方法調用計數器與回邊計數器值之和是否超過方法調用計數器的閾值。若是已超過閾值,那麼將會向即時編譯器提交一個該方法的代碼編譯請求。若是不作任何設置,執行引擎並不會同步等待編譯請求完成,而是繼續進入解釋器按照解釋方式執行字節碼,直到提交的請求被編譯器編譯完成。當編譯工做完成以後,這個方法的調用入口地址就會被系統自動改寫成新的,下一次調用該方法時就會使用已編譯的版本。
  • 若是不作任何設置,方法調用計數器統計的並非方法被調用的絕對次數,而是一個相對的執行頻率,即一段時間以內方法被調用的次數。當超過必定的時間限度,若是方法的調用次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的調用計數器就會被減小一半,這個過程稱爲方法調用計數器熱度的衰減(Counter Decay),而這段時間就稱爲此方法統計的半衰週期(Counter Half Life Time)。進行熱度衰減的動做是在虛擬機進行垃圾收集時順便進行的,可使用虛擬機參數-XX:-UseCounterDecay來關閉熱度衰減,讓方法計數器統計方法調用的絕對次數,這樣,只要系統運行時間足夠長,絕大部分方法都會被編譯成本地代碼。另外,可使用-XX:CounterHalfLifeTime參數設置半衰週期的時間,單位是秒。

回邊計數器:

  • 它的做用是統計一個方法中循環體代碼執行的次數,準確地說,應當是回邊的次數而不是循環次數,由於並不是全部的循環都是回邊,如空循環實際上就能夠視爲本身跳轉到本身的過程,所以並不算做控制流向後跳轉,也不會被回邊計數器統計。(在字節碼中遇到控制流向後跳轉的指令稱爲「回邊」(Back Edge)。顯然,創建回邊計數器統計的目的就是爲了觸發OSR編譯。
  • 關於回邊計數器的閾值,能夠經過設置參數-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編譯請求,而且把回邊計數器的值下降一些,以便繼續在解釋器中執行循環,等待編譯器輸出編譯結果。                                                          

 

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

編譯過程:

  • 不管是方法調用產生的即時編譯請求,仍是OSR編譯請求,虛擬機在代碼編譯器還未完成以前,都仍然將按照解釋方式繼續執行,而編譯動做則在後臺的編譯線程中進行。
  •  Client Compiler過程分爲三個階段。在第一個階段,一個平臺獨立的前端將字節碼構形成一種高級中間代碼表示(High-Level 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)優化,而後產生機器代碼。
  • Server Compiler過程,Server Compiler則是專門面向服務端的典型應用併爲服務端的性能配置特別調整過的編譯器,也是一個充分優化過的高級編譯器,幾乎能達到GNU C++編譯器使用-O2參數時的優化強度,它會執行全部經典的優化動做。Server Compiler無疑是比較緩慢的,但它的編譯速度依然遠遠超過傳統的靜態優化編譯器,並且它相對於Client Compiler編譯輸出的代碼質量有所提升,能夠減小本地代碼的執行時間,從而抵消了額外的編譯時間開銷,因此也有不少非服務端的應用選擇使用Server模式的虛擬機運行。

查看及分析即時編譯結果:

  • 使用參數-XX:+PrintCompilation要求虛擬機在即時編譯時將被編譯成本地代碼的方法名稱打印出來,(其中帶有「%」的輸出說明是由回邊計數器觸發的OSR編譯)。
  • 參數-XX:+PrintInlining要求虛擬機輸出方法內聯信息。
  • 使用-XX+PrintOptoAssembly(用於Server VM)或-
  • XX:+PrintLIR(用於Client VM)來輸出比較接近最終結果的中間代碼表示。

二、編譯優化技術

概述:

  • 以編譯方式執行本地代碼比解釋方式更快的緣由是,虛擬機解釋執行字節碼額外消耗時間,虛擬機設計團隊幾乎把對代碼的全部優化措施都集中在了即時編譯器之中。
  • 代碼優化技術:公共子表達式消除 、數組範圍檢查消除 、方法內聯、逃逸分析。

公共子表達式消除:

  • 若是一個表達式E已經計算過了,而且從先前的計算到如今E中全部變量的值都沒有發生變化,那E的此次出現就成爲了公共子表達式。 
  • 例:int d = (c * b) * 12 + a + (a + b * c) => int d = E * 12 + a + (a + E) =>int d = E * 13 + a*2。

數組邊界檢查消除:

  • 在Java語言中訪問數組元素foo[i]的時候系統將會自動進行上下界的返回檢查,對於有大量數組訪問的程序代碼,無疑是一種性能負擔。在編譯期根據數據流分析來判斷沒有越界,執行的時候就無需判斷了。另外一種方法叫作隱式異常處理以下:
    if(foo!=null){ return foo.value; }else{ throw new NullPointException(); }

    在使用隱式異常優化以後,虛擬機會把上面僞代碼所表示的訪問過程變爲以下僞代碼。

    try{ return foo.value; }catch(segment_fault){ uncommon_trap(); }

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

方法內聯:

  • 方法內聯能夠去除方法調用的成本(如創建棧幀等),還能夠爲其餘優化創建良好的基礎
  • Java語言中默認的實例方法是虛方法。對於一個虛方法,編譯期作內聯的時候根本沒法肯定應該使用哪一個方法版本,Java虛擬機引入了一種名爲「類型繼承關係分析」(Class Hierarchy Analysis,CHA)的技術,若是遇到虛方法,則會向CHA查詢此方法在當前程序下是否有多個目標版本可供選擇,若是查詢結果只有一個版本,那也能夠進行內聯,不過這種內聯就屬於激進優化,須要預留一個「逃生門」(Guard條件不成立時的Slow Path),稱爲守護內聯(Guarded Inlining)。
  • 若是程序的後續執行過程當中,虛擬機一直沒有加載到會令這個方法的接收者的繼承關係發生變化的類,那這個內聯優化的代碼就能夠一直使用下去。
  • 但若是加載了致使繼承關係發生變化的新類,那就須要拋棄已經編譯的代碼,退回到解釋狀態執行,或者從新進行編譯。
  • 若是向CHA查詢出來的結果是有多個版本的目標方法可供選擇,則編譯器還將會進行最後一次努力,使用內聯緩存(Inline Cache)來完成方法內聯,這是一個創建在目標方法正常入口以前的緩存.
  • 內聯緩存工做原理大體是:在未發生方法調用以前,內聯緩存狀態爲空,當第一次調用發生後,緩存記錄下方法接收者的版本信息,而且每次進行方法調用時都比較接收者版本,若是之後進來的每次調用的方法接收者版本都是同樣的,那這個內聯還能夠一直用下去。若是發生了方法接收者不一致的狀況,就說明程序真正使用了虛方法的多態特性,這時纔會取消內聯,查找虛方法表進行方法分派。

逃逸分析:

  • 逃逸分析(Escape Analysis)是爲其餘優化手段提供依據的分析技術。逃逸分析的基本行爲就是分析對象動態做用域:當一個對象在方法中被定義後,它可能被外部方法所引用.
  • 例如做爲調用參數傳遞到其餘方法中,稱爲方法逃逸。甚至還有可能被外部線程訪問到,譬如賦值給類變量或能夠在其餘線程中訪問的實例變量,稱爲線程逃逸。若是能證實一個對象不會逃逸到方法或線程以外,也就是別的方法或線程沒法經過任何途徑訪問到這個對象,則可能爲這個變量進行一些高效的優化,如棧上分配(Stack Allocation),同步消除(Synchronization Elimination),標量替換(Scalar Replacement)。
  • 棧上分配,若是肯定一個對象不會逃逸出方法以外,那讓這個對象在棧上分配內存,對象就會隨着方法的結束而自動銷燬了,垃圾收集系統的壓力將會小不少。
  • 同步消除,若是逃逸分析可以肯定一個變量不會逃逸出線程,沒法被其餘線程訪問,那這個變量的讀寫確定就不會有競爭,對這個變量實施的同步措施也就能夠消除掉。
  • 標量替換,標量(Scalar)是指一個數據已經沒法再分解成更小的數據來表示了,Java虛擬機中的原始數據類型(int、long等數值類型以及reference類型等)都不能再進一步分解,它們就能夠稱爲標量。相對的,若是一個數據能夠繼續分解,那它就稱做聚合量(Aggregate),Java中的對象就是最典型的聚合量。若是把一個Java對象拆散,根據程序訪問的狀況,將其使用到的成員變量恢復原始類型來訪問就叫作標量替換。若是逃逸分析證實一個對象不會被外部訪問,而且這個對象能夠被拆散的話,那程序真正執行的時候將可能不建立這個對象,而改成直接建立它的若干個被這個方法使用到的成員變量來代替。將對象拆分後,除了可讓對象的成員變量在棧上(棧上存儲的數據,有很大的機率會被虛擬機分配至物理機器的高速寄存器中存儲)分配和讀寫以外,還能夠爲後續進一步的優化手段建立條件。

轉載請於明顯處標明出處:

http://www.javashuo.com/article/p-ssoivhzp-y.html

相關文章
相關標籤/搜索