後端編譯與優化

低吟不作語發表於2021-02-07

本書部分摘自《深入理解 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 * bb * c 是一樣的表示式,而且 b 與 c 的值不變,因此這條表示式可能被視為

int d = E * 12 + a + (a + E);

4. 陣列邊界檢查消除

我們知道 Java 中的陣列不能越界訪問,否則會丟擲一個執行時異常,這得益於系統會自動進行上下文的範圍檢查。但如果每次對陣列元素的讀寫都要檢查一次,無疑是一種負擔。可無論如何,陣列邊界安全檢查是肯定要做的,不過虛擬機器會在編譯期根據資料分析流判斷陣列下標有沒有越界的可能,避免過多的開銷


相關文章