?【Java技術專區】「編譯器專題」重塑認識Java編譯器的執行過程(消除陣列邊界檢查+公共子表示式)!

李浩宇Alex發表於2021-08-07

前提概要

Java的class位元組碼並不是機器語言,要想讓機器能夠執行,還需要把位元組碼翻譯成機器指令。這個過程是Java虛擬機器做的,這個過程也叫編譯。是更深層次的編譯。

在編譯原理中,把原始碼翻譯成機器指令,一般要經過以下幾個重要步驟:

根據完成任務不同,可以將編譯器的組成部分劃分為前端(Front End)與後端(Back End)。

前端編譯主要指與源語言有關但與目標機無關的部分,包括詞法分析、語法分析、語義分析與中間程式碼生成。

後端編譯主要指與目標機有關的部分,包括程式碼優化和目的碼生成等。

我們可以把將.java檔案編譯成.class的編譯過程稱之為前端編譯。把將.class檔案翻譯成機器指令的編譯過程稱之為後端編譯。

Java中的前端編譯

  • 前端編譯主要指與源語言有關但與目標機無關的部分,包括詞法分析、語法分析、語義分析與中間程式碼生成。

  • 我們所熟知的javac的編譯就是前端編譯。除了這種以外,我們使用的很多IDE,如eclipse,idea等,都內建了前端編譯器。主要功能就是把.java程式碼轉換成.class程式碼。

詞法分析

  • 詞法分析階段是編譯過程的第一個階段。這個階段的任務是從左到右一個字元一個字元地讀入源程式,將字元序列轉換為標記(token)序列流的過程。這裡的標記是一個字串,是構成原始碼的最小單位。在這個過程中,詞法分析器還會對標記進行分類。

  • 詞法分析器通常不會關心標記之間的關係(屬於語法分析的範疇),舉例來說:詞法分析器能夠將括號識別為標記,但並不保證括號是否匹配。

語法分析

語法分析的任務是在詞法分析的基礎上將單詞序列組合成各類語法短語,如“程式”,“語句”,“表示式”等等,語法分析程式判斷源程式在結構上是否正確。源程式的結構由上下文無關文法描述。

語義分析

  • 語義分析是編譯過程的一個邏輯階段, 語義分析的任務是對結構上正確的源程式進行上下文有關性質的審查,進行型別審查。語義分析是審查源程式有無語義錯誤,為程式碼生成階段收集型別資訊。

  • 語義分析的一個重要部分就是型別檢查。比如很多語言要求陣列下標必須為整數,如果使用浮點數作為下標,編譯器就必須報錯。再比如,很多語言允許某些型別轉換,稱為自動型別轉換。

中間程式碼生成

在源程式的語法分析和語義分析完成之後,很多編譯器生成一個明確的低階的或類機器語言的中間表示。該中間表示有兩個重要的性質: 1.易於生成; 2.能夠輕鬆地翻譯為目標機器上的語言。

在Java中,javac執行的結果就是得到一個位元組碼,而這個位元組碼其實就是一種中間程式碼。

著名的解語法糖操作,也是在javac中完成的。

Java中的後端編譯

首先,我們大家都知道,通常通過 javac 將程式原始碼編譯,轉換成 java 位元組碼,JVM 通過解釋位元組碼將其翻譯成對應的機器指令,逐條讀入,逐條解釋翻譯。很顯然,經過解釋執行,其執行速度必然會比可執行的二進位制位元組碼程式慢很多。這就是傳統的JVM的直譯器(Interpreter)的功能。為了解決這種效率問題,引入了 JIT 技術。

JAVA程式還是通過直譯器進行解釋執行,當JVM發現某個方法或程式碼塊執行特別頻繁的時候,就會認為這是“熱點程式碼”(Hot Spot Code)。然後JIT會把部分“熱點程式碼”翻譯成本地機器相關的機器碼,並進行優化,然後再把翻譯後的機器碼快取起來,以備下次使用。

HotSpot虛擬機器中內建了兩個JIT編譯器:Client Complier和Server Complier,分別用在客戶端和服務端,目前主流的HotSpot虛擬機器中預設是採用直譯器與其中一個編譯器直接配合的方式工作。

當 JVM 執行程式碼時,它並不立即開始編譯程式碼。首先,如果這段程式碼本身在將來只會被執行一次,那麼從本質上看,編譯就是在浪費精力。因為將程式碼翻譯成 java 位元組碼相對於編譯這段程式碼並執行程式碼來說,要快很多。第二個原因是最優化,當 JVM 執行某一方法或遍歷迴圈的次數越多,就會更加了解程式碼結構,那麼 JVM 在編譯程式碼的時候就做出相應的優化。

熱點檢測

上面我們說過,要想觸發JIT,首先需要識別出熱點程式碼。目前主要的熱點程式碼識別方式是熱點探測(Hot Spot Detection),有以下兩種:

  1. 基於取樣的方式探測(Sample Based Hot Spot Detection) :週期性檢測各個執行緒的棧頂,發現某個方法經常出險在棧頂,就認為是熱點方法。好處就是簡單,缺點就是無法精確確認一個方法的熱度。容易受執行緒阻塞或別的原因干擾熱點探測。

  2. 基於計數器的熱點探測(Counter Based Hot Spot Detection)。採用這種方法的虛擬機器會為每個方法,甚至是程式碼塊建立計數器,統計方法的執行次數,某個方法超過閥值就認為是熱點方法,觸發JIT編譯。

在HotSpot虛擬機器中使用的是第二種——基於計數器的熱點探測方法,因此它為每個方法準備了兩個計數器:方法呼叫計數器和回邊計數器。

  • 方法計數器:顧名思義,就是記錄一個方法被呼叫次數的計數器。

  • 回邊計數器:是記錄方法中的for或者while的執行次數的計數器。

編譯優化

前面提到過,JIT除了具有快取的功能外,還會對程式碼做各種優化。說到這裡,不得不佩服HotSpot的開發者,他們在JIT中對於程式碼優化真的算是面面俱到了。

這裡簡答提及幾個我覺得比較重要的優化技術,並不準備直接展開,讀者感興趣的話,我後面再寫文章單獨介紹。

逃逸分析、 鎖消除、 鎖膨脹、 方法內聯、 空值檢查消除、 型別檢測消除、 公共子表示式消除

公共子表示式消除

公共子表示式消除是一項非常經典的、普遍應用於各種編譯器的優化技術,它的含義是: 如果一個表示式E之前已經被計算過了,並且從先前的計算到現在E中所有變數的值都沒有發生變化,那麼E的這次出現就稱為公共子表示式。

  • 對於這種表示式,沒有必要花時間再對它重新進行計算,只需要直接用前面計算過的表示式結果代替E。如果這種優化僅限於程式基本塊內,便可稱為區域性公共子表示式消除( Local Common SubexpressionElimination )

  • 如果這種優化的範圍涵蓋了多個基本塊,那就稱為全域性公共子表示式消除 ( Global Common Subexpression Elimination )。

如下程式碼:
int d = ( c * b ) * 12 + a + ( a + b * c )

如果這段程式碼交給 Javac 編譯器則不會進行優化,那麼生成的程式碼將如下所示:

iload_2        // b
imul           // 計算 b * c
bipush 12      // 推入 12
imul           // 計算 ( c * b ) * 12
iload_1        // a
iadd           // 計算 ( c * b ) * 12 + a
iload_1        // a
iload_2        // b
iload_3        // c
imul           // 計算 b * c
iadd           // 計算 a + b * c
iadd           // 計算 ( c * b ) * 12 + a + a + b * c
istore 4

是完全按照遵照 Java 原始碼的寫法直譯而成的。

當這段程式碼進人虛擬機器即時編譯器後,它將進行如下優化:編譯器檢測到 c * b 與 b * c 是一樣的表示式, 而且在計算期間 b 與 c 的值是不變的。

因此這條表示式就可能被視為:
int d = E * 12 + a + ( a + E );

這時候、編譯器還可能(取決於哪種虛擬機器的編譯器以及具體的上下文而定)進行另外一種優化——代數化簡 (Algebraic Simplification) , 在E本來就有乘法運算的前提下, 把表示式變為:

int d = E * 13 + a + a;

表示式進行變換之後,再計算起來就可以節省一些時間了。

陣列邊界檢查消除

陣列邊界檢查消除 ( Array Bounds Checking Elimination) 是即時編譯器中的一項語言相關的經典優化技術。我們知道Java語言是一門動態安全的語言,對陣列的讀寫訪問也不像 C、C++ 那樣實質上就是裸指標操作。

  • 如果有一個陣列 a[],在Java語言中訪問陣列元素 foo[i] 的時候系統將會自動進行上下界的範圍檢查,即 i 必須滿足 " i >= 0 && i < a.length " 的訪問條件,否則將丟擲一個執行時異常: java.lang.ArrayIndexOutOfBondsException

  • 這對軟體開發者來說是一件很友好的事情,即使程式設計師沒有專門編寫防禦程式碼,也能夠避免大多數的溢位攻擊。但是對於虛擬機器的執行子系統來說,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量陣列訪問的程式程式碼,這必定是一種效能負擔

無論如何,為了安全,陣列邊界檢查肯定是要做的,但陣列邊界檢查是不是必須在執行期間一次不漏地進行則是可以 “商量” 的事情。例如下面這個簡單的情況: 陣列下標是一個常量,如 a[3],只要在編譯期根據資料流分析來確定 foo.length 的值,並判斷下標 “3” 沒有越界,執行的時候就無須判斷了。更加常見的情況是,陣列訪問發生在迴圈之中,並且使用迴圈變數來進行陣列的訪問。

如果編譯器只要通過資料流分析就可以判定迴圈變數的取值範圍永遠在區間 [ 0,a.length ) 之內,那麼在迴圈中就可以把整個陣列的上下界檢查消除掉,這可以節省很多次的條件判斷操作。

  • 把這個陣列邊界檢查的例子放在更高的視角來看,大量的安全檢查使編寫 Java 程式比編寫 C 和 C++ 程式容易了很多,比如: 陣列越界會得到ArrayIndexOutfBoundsExcepion 異常;空指標訪問會得到 NullPointExceptioen 異常;除數為零會得到 ArithmeticExceptinon 異常…在和C++程式中出現類似的問題,一個不小心就會出現 Segment Fault 訊號或者 Windows 程式設計中常見的 “XXX記憶體不能為 Read/Write” 之類的提示,處理不好程式就直接崩潰退出了。

  • 但這些安全檢查也導致出現相同的程式,從而使 Java 比 C 和 C++ 要做更多的事情(各種檢查判斷),這些事情就會導致一些隱式開銷, 如果不處理好它們,就很可能成為一項 “ Java語言天生就比較慢” 的原罪。為了消除這些隱式開銷,除了如陣列邊界檢查優化這種儘可能把執行期檢查提前到編譯期完成的思路之外、還有一種避開的處理思路——隱式異常處理, Java中空指標檢查和算術運算中除數為零的檢查都採用了這種方案。

舉個例子,程式中訪問一個物件(假設物件叫 a )的某個屬性(假設屬性叫 value ),那以 Java 虛擬碼來表示虛擬機器訪問 a.value 的過程為:

if (foo != null) {
	return foo.value;
}else{
	throw new NullPointException();
}

在使用隱式異常優化之後,虛擬機器會把上面的虛擬碼所表示的訪問過程變為如下虛擬碼:

try{
	return foo.value;
} catch (segment_fault) {
	uncommon_ trap();
}
  • 虛擬機器會註冊一個 Segment Fault 訊號的異常處理器 ( 虛擬碼中的uncommon_trap(),務必注意這裡是指程式層面的異常處理器,並非真的 Java 的 try-catch 語句的異常處理器),這樣當 a 不為空的時候,對 value 的訪問是不會有任何額外對 a 判空的開銷的,而代價就是當 a 真的為空時,必須轉到異常處理器中恢復中斷並丟擲 NullPointException 異常。

  • 進人異常處理器的過程涉及程式從使用者態轉到核心態中處理的過程,結束後會再回到使用者態,速度遠比一次判空檢查要慢得多。

  • 當 foo 極少為空的時候,隱式異常優化是值得的,但假如 foo 經常為空,這樣的優化反而會讓程式更慢。幸好 HotSpot 虛擬機器足夠聰明,它會根據執行期收集到的效能監控資訊自動選擇最合適的方案。

相關文章