本書部分摘自《深入理解 Java 虛擬機第三版》
概述
前面講過前端編譯是將 Java 源代碼編譯成 Class 字節碼,那么后端編譯就對應把 Class 文件轉換成與本地機器相關的二進制機器碼的過程。然后 JVM 把每一條要執行的字節碼交給解釋器,翻譯成對應的機器碼,由解釋器執行,Java 程序就運行起來了
即時編譯器
當虛擬機發現某個方法或代碼塊運行特別頻繁,就會把這些代碼認定為熱點代碼(HotSpot Code),為了提高熱點代碼的運行效率,在運行時,虛擬機將會把這些代碼編譯為本地機器碼,並以各種手段進行代碼優化,在運行時完成這個任務的后端編譯器被稱為即時編譯器
1. 編譯對象
熱點代碼主要有兩類:
- 被多次調用的方法
- 被多次執行的循環體
對於這兩種情況,編譯的目標對象都是整個方法體。第一種情況,由於是依靠方法調用觸發的編譯,以整個方法為編譯對象毫無疑問。而后一種情況,雖然編譯器仍以整個方法作為編譯對象,但執行入口(從方法第幾條字節碼執行開始執行)會稍有不同。
2. 觸發條件
如何判斷熱點代碼?是不是需要進行即時編譯?這個行為稱為熱點探測(Hot Spot Code Detection),進行熱點探測並不一定要知道方法具體被調用了多少次,目前主流的熱點探測判定方式有兩種:
-
基於采樣的熱點探測(Sample Based HotSpot Code Detection)
虛擬機會周期性地檢查各個線程的調用棧頂,如果發現某個(某些)方法經常出現在棧頂,那這個方法就是熱點方法。這種方式的好處是實現簡單高效,可以很容易獲取方法調用關系(將調用堆棧展開即可),缺點是很精確地確定一個方法的熱度,容易受線程阻塞或別的外界因素的影響
-
基於計數器的熱點探測(Counter Based HotSpot Code Detection)
虛擬機為每個方法(甚至代碼塊)建立計數器,統計方法的執行次數,如果執行次數超過一定閾值就認為是熱點方法。這種統計方式實現起來麻煩一些,不能直接獲取方法的調用關系,但結果相對來說更加精確
HotSpot 采用基於計數器待熱點探測方法,同時准備了兩類計數器:方法調用計數器(Invocation Counter)和回邊計數器(Back Edge Counter,回邊的意思是指在循環邊界往回跳轉)。當虛擬機運行參數確定的前提下,這兩個計數器都有一個明確的閾值,一旦溢出,就會觸發即時編譯。
當一個方法被調用時,虛擬機會先檢查該方法是否存在被即時編譯過后的版本,如果存在,則優先使用編譯后的本地代碼來執行。如果不執行已被即時編譯過后的版本,則將該方法的調用計數器值加一,然后判斷方法調用計數器與回邊計數器之和是否超過閾值,如果超過,則向即時編譯器提交一個該方法的代碼編譯請求
如果沒做過任何設置,執行引擎默認不會同步等待編譯請求完成,而是繼續進入解釋器執行字節碼,直到被提交的請求被即時編譯器編譯完成,當編譯工作完成后,這個方法的調用入口地址就會被系統自動改寫成新值,下一次調用該方法就會使用已編譯的版本
默認設置下,方法調用計數器統計的並不是方法被調用的絕對次數,而是一個相對的執行頻率,即一段時間之內方法被調用的次數。當超過一定的時間限度,如果方法的調用次數仍不足以讓它提交給即時編譯器編譯,那該方法的調用計數器就會減半,這個過程稱為方法調用計數器熱度的衰減(Counter Decay),這個動作是在虛擬機進行垃圾收集時順便進行的,也可以關閉熱度衰減,讓虛擬機統計方法調用的絕對次數,這樣時間長了,程序中絕大部分方法都會被編譯成本地代碼
再看一看回邊計數器,它的作用是統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向后跳轉的指令就稱為回邊(Back Edge)。當解釋器遇到一條回邊指令,會先查找將要執行的代碼片段是否有已經編譯好的版本,如果有的話,將優先執行已編譯的代碼,否則就把回邊計數器的值加一,然后判斷方法調用器與回邊計數器之和是否超過回邊計數器的閾值。當超過閾值,將提交一個棧上替換編譯請求,並且把回邊計數器的值稍微降低,以便繼續在解釋器中執行循環,等待編譯器輸出編譯結果
回邊計數器沒有計數熱度衰減的過程,因此這個計數器統計的就是方法循環執行的絕對次數。當計數器溢出時,它還會把方法計數器的值也調整為溢出狀態,這樣下次再進入這個方法的時候就會執行標准編譯過程了
提前編譯器
現在提前編譯器的研究兩條明顯的分支:在程序運行前把程序代碼編譯成機器碼的靜態翻譯工作,以及把原本即時編譯器在運行時要做的編譯工作提前做好並保存,下次運行到這些代碼時直接把它加載進來使用
第一種傳統的提前編譯應用形式,它是為了解決即時編譯的最大弱點:即時編譯要占用程序的運行時間和時間資源。而第二種方式,本質是給即時編譯器做緩存加速。
提前編譯器因為沒有執行時間和資源限制的壓力,可以毫無忌憚地使用重負載的優化手段,這是一個極大的優勢,但即時編譯器也有它的長處:
-
性能分析制導優化(Profile-Guided Optimization)
即時編譯器在運行過程中,會不斷地收集性能監控信息,譬如條件判斷通常走哪個分支、循環會進行幾次等等,這些數據一般在靜態分析時是無法得到的,或者說不能得到一個明確的解。但在動態運行時卻能看出它們具有非常明顯的偏好性,比如一個條件分支的某一路徑執行頻繁,就可以對熱點代碼進行優化和分配更多的資源
-
激進預測性優化(Aggressive Speculative Optimization)
靜態優化必須保證優化前后的程序對外部可見影響(不僅僅是執行結果)是等效的,而即時編譯可以不必如此保守,如果性能監控監控信息能夠支持它做出一些正確的可能性很大但無法保證絕對正確的預測判斷,就可以大膽地按照高概率的假設進行優化,萬一真的走到罕見分支上,大不了退回到低級編譯器甚至解釋器上去執行,並不會出現無法挽救的后果
-
鏈接時優化
Java 語言天生就是動態鏈接的,一個個 Class 文件在運行期被加載到虛擬機內存中,然后在即時編譯器里產生優化后的本地代碼
編譯器優化技術
編譯器的目標雖然是做由程序代碼翻譯為本地機器碼的工作,但其難點並不在於能否成功翻譯出機器碼,輸出代碼優化質量才是決定編譯器優秀與否的關鍵
1. 方法內聯
方法內聯就是把目標方法的代碼原封不動地復制到發起調用的方法之中,避免發生真實的方法調用。方法內聯聽上去很簡單,但實現並不簡單,因為有方法解析和分派機制。只有使用 invokespecial 指令調用的私有方法、實例構造器、父類方法、使用 invokestatic 指令調用的靜態方法和被 final 修飾的方法,這些方法會在編譯器解析。而其他 Java 方法必須在運行時進行方法接收者的多態選擇,它們都有可能有多於一個版本的方法接收者
為了解決這個難題,Java 虛擬機引入了一種名為類型繼承關系分析的技術,用於確定在目前已加載的類中,某個接口是否有多於一種的實現、某個類是否存在子類、某個子類是否覆蓋了父類的某個虛方法等信息。這樣,如果遇到非虛方法,直接內聯即可。如果查到只有一個版本,也直接內聯,這種內聯稱為守護內聯(Guarded Inlining)。不過由於 Java 程序是動態連接的,有可能會有新的類型加載進來,所以守護內聯屬於激進預測性優化,必須預留好退路。假如繼承關系發生變化,那么就必須拋棄已編譯的代碼,退回到解釋狀態進行執行,或重新編譯
如果方法存在多個版本的目標方法可供選擇,虛擬機將使用內聯緩存(Inline Cache)來縮減方法調用的開銷。內聯緩存是一個建立在目標方法正常入口之前的緩存,如果未發生方法調用,內聯緩存狀態為空,當第一次調用發生后,緩存記錄下方法接收者的版本信息,並且每次進行方法調用時都比較接收者的版本。如果每次都一致,就直接使用,否則查找虛方法表進行方法分派
2. 逃逸分析
逃逸分析的基本原理是:分析對象動態作用域,當一個對象在方法里面被定義后,它可能被外部方法引用,例如作為調用參數傳到其他方法中,這稱為方法逃逸;甚至有可能被外部線程訪問,這稱為線程逃逸
根據一個對象的逃逸程度,可以進行不同程度的優化:
-
棧上分配
對象是在棧上分配內存的,主要持有這個對象的引用,就可以訪問堆中存儲的對象數據。如果確定一個對象不會逃逸出線程之外,可以讓這個對象在棧上分配分配內存
-
標量替換
若一個數據已經無法再分解成更小的數據表示,如原始數據類型,那么這些數據就稱為標量。相對的,如果一個數據可以繼續分解,那就稱為聚合量,如對象。如果一個對象不會被方法外部訪問,那這個對象就可以拆成多個標量,替換原來引用對象的成員變量的地方
-
同步消除
如果一個變量不會逃逸出線程,那么這個變量的讀寫肯定不會有競爭,對這個變量實施的同步措施也就可以安全地消除掉
3. 公共子表達式消除
如果一個表達式 E 之前已經被計算過,而且從先前的計算到現在 E 中所有變量的值都沒有變化,那么 E 就稱為公共子表達式。之后就沒有必要再花時間重新計算了,直接用之前的計算結果代替 E 即可
假設有如下代碼
int d = (c * b) * 12 + a + (a + b * c);
編譯器檢測到 c * b
和 b * c
是一樣的表達式,而且 b 與 c 的值不變,因此這條表達式可能被視為
int d = E * 12 + a + (a + E);
4. 數組邊界檢查消除
我們知道 Java 中的數組不能越界訪問,否則會拋出一個運行時異常,這得益於系統會自動進行上下文的范圍檢查。但如果每次對數組元素的讀寫都要檢查一次,無疑是一種負擔。可無論如何,數組邊界安全檢查是肯定要做的,不過虛擬機會在編譯期根據數據分析流判斷數組下標有沒有越界的可能,避免過多的開銷