1. 程式人生 > >深入理解 Java 虛擬機器(十一)程式編譯與程式碼優化

深入理解 Java 虛擬機器(十一)程式編譯與程式碼優化

編譯期優化

Java 語言的編譯期其實是一段不確定的過程,可以是前端編譯器 (Javac) 把 java 檔案編譯為 class 檔案的過程,也可能值虛擬機器的後端執行期編譯器 (JIT 編譯器,Just In Time Compiler) 把位元組碼轉變為機器碼的過程;還可能是指使用靜態提前編譯器 (AOT 編譯器:Ahead Of Time Compiler) 直接把 java 檔案編譯為本地機器程式碼的過程。

Javac 的編譯過程如下:

編譯過程

解析包括經典編譯原理中的詞法分析和語法分析兩個過程。

語義分析的主要任務是對結構上正確的源程式進行上下文有關性質的審查,如型別審查。

語義分析與位元組碼生成的步驟為:

  1. 標註檢查,如變數使用前是否已宣告,變數與賦值之間的資料型別是否能夠匹配等
  2. 資料及控制流分析,對程式上下文邏輯更進一步的驗證,比如區域性變數使用前是否有賦值、是否所有的受檢查異常都被正確處理等
  3. 解語法糖
  4. 位元組碼生成

執行期優化

在部分的商用虛擬機器中(HotSpot 等),Java 程式最初是通過直譯器進行解釋執行的,當虛擬機發現某個方法或者程式碼塊的執行特別頻繁時,就會把這些程式碼認定為熱點程式碼,為了提高熱點程式碼的執行效率,在執行時,虛擬機器將會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器稱為即時編譯器(JIT)。

HotSpot 虛擬機器內的即時編譯器

直譯器與編譯器

直譯器與編譯器兩者各有優勢:當程式需要迅速啟動和執行的時候,直譯器可以首先發揮作用,省去編譯的時間,立即執行。在程式執行後,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的程式碼編譯成原生代碼之後,可以獲取更高的執行效率。當程式執行環境中記憶體資源限制較大,可以使用解釋執行節約記憶體,反之可以使用編譯執行來提高效率。

HotSpot 虛擬機器內建了兩個即時編譯器,分別稱為 Client Compiler 和 Server Compiler,或者簡稱為 C1 編譯器和 C2 編譯器。

直譯器與編譯器搭配使用的方式在虛擬機器中成為混合模式。

由於即時編譯器編譯原生代碼需要佔用程式執行時間,同時直譯器可能還要題編譯器收集效能相關的資訊,這對解釋執行的速度也有影響,為了在程式響應速度和執行效率之間達到平衡,HotSpot 虛擬機器啟用了分層編譯的策略:

  1. 第 0 層,程式解釋執行,直譯器不開啟效能監控功能,可觸發第 1 層編譯
  2. 第 1 層,也稱為 C1 編譯,將位元組碼編譯為原生代碼,進行簡單、可靠的優化,如有必要將加入效能監控的邏輯
  3. 第 2 層,也稱為 C2 編譯,將位元組碼編譯為原生代碼,同時啟用一些編譯耗時較長的優化,甚至會根據效能監控資訊進行一些不可靠的激進優化

編譯物件與觸發條件

在執行過程中會被即時編譯器編譯的熱點程式碼有兩類:

  1. 被多次呼叫的方法
  2. 被多次呼叫的迴圈體

對第一種情況,編譯器會把整個方法作為編譯物件,這種編譯也是虛擬機器中標準的 JIT 編譯方式。而對後一種情況,編譯器依然會把整個方法作為編譯物件,因為編譯發生在放行執行過程中,因此這種編譯方式稱為棧上替換(On Stack Replacement,簡稱為 OSR 編譯,即方法棧幀還在棧上,方法就被替換了)。

判斷一段程式碼是不是熱點程式碼,是否需要觸發即時編譯,這樣的行為稱為熱點探測,目前主要的熱點探測判定方式有兩種:

  1. 基於取樣的熱點探測。虛擬機器會週期性地檢查各個執行緒的棧頂,如果某些方法經常出現在棧頂,那這個方法就是熱點方法。這種探測方式的好處是簡單、高效,可以很容易地獲取方法呼叫關係,缺點是很難精確地確認一個方法的熱度,容易受執行緒阻塞等外界因素的干擾。

  2. 基於計數器的熱點探測。虛擬機器會為每個方法(甚至程式碼塊)建立計數器,執行次數超過一定的閾值就認為是熱點方法。這種探測方式實現起來稍微麻煩一些,而且不能獲取到方法的呼叫關係,但它的統計結果更加精確和眼睛。

HotSpot 虛擬機器使用的是第二種方式,它為每個方法準備了兩類計數器:方法呼叫計數器(Invocation Counter)、回邊計數器(Back Edge Counter)。這兩個計數器都有一個確定的閾值,超過閾值就會觸發 JIT 編譯。

方法呼叫計數器用於統計方法被呼叫的次數,預設閾值在 Clinet 模式下是 1500 次,在 Server 模式下是 10000 次。執行流程如下:

方法呼叫計數器執行流程

如果不做任何設定,那麼方法呼叫計數器統計的不是方法被呼叫的絕對次數,而是一個相對的執行頻率,即一段時間內方法被呼叫的次數,如果超時,那麼計數器就會被減少一半,這個過程稱為熱度的衰減,這段時間稱為半衰週期。

回邊計數器用於統計一個方法中迴圈體程式碼執行的次數,在位元組碼中遇到控制流向後跳轉的指令稱為回邊(Back Edge),可以通過引數 -XX:OnStackReplacePercentage 來間接調整回邊計數器的閾值,計算公式見書籍 P。回邊指令的執行流程如下:

回邊計數器執行流程

提交 OSR 編譯請求後還需要調整回邊計數器值,以便繼續在直譯器中執行迴圈。

編譯優化技術

以編譯方式執行原生代碼比解釋方式快的原因是虛擬機器解釋位元組碼時需要額外消耗時間,且虛擬機器設計團隊幾乎把堆程式碼的所有優化措施都集中在了即時編譯器之中,因此,一般來說,即時編譯器產生的原生代碼比 Javac 產生的位元組碼優秀。

即時編譯器優化技術有很多種,詳見書籍 P,下面將演示一個程式碼優化的例子。

優化前的原始程式碼:

static class B{
    int value;
    final int get() {
        return value;
    }
}

public void foo() {
    y = b.get();
    z = b.get();
    sum = y + z;
}

首先進行方法內聯,方法內聯的重要性高於其他優化措施,它的主要目的有兩個,一個去除方法呼叫的成本(如建立棧幀等),二是為其它優化建立良好的基礎。內聯後的程式碼如下:

public void foo() {
    y = b.value;
    z = b.value;
    sum = y + z;
}

然後是冗餘訪問消除:

public void foo() {
    y = b.value;
    z = y;
    sum = y + z;
}

接著進行復寫傳播:

public void foo() {
    y = b.value;
    y = y;
    sum = y + y;
}

最後是無用程式碼消除:

public void foo() {
    y = b.value;
    sum = y + y;
}

接下來介紹幾項最有代表性的優化技術:

  1. 語言無關的經典優化技術之一:公共子表示式消除。公共子表示式消除是一個普遍應用於各種編譯器的經典優化技術,它的含義是:如果一個表示式 E 已經被計算過,且從先前的計算到現在 E 中所有變數的值都沒有發生變化,則 E 就成為了公共子表示式,無需重新計算,只要拿上次計算的結果即可。

  2. 語言相關的經典優化技術之一:陣列範圍檢查消除。 Java 語言中訪問陣列元素時將會自動進行上下邊界的範圍檢查,對於擁有大量陣列訪問的程式程式碼,無疑會對效能造成一定的負擔。但陣列邊界檢查是不是必須在執行期間一次不漏地檢查則是可以商量的事情,比如當陣列下標是一個常量時,只需在編譯期根據資料流分析比較該下標和陣列長度,即可確定是否越界,執行期就無需判斷了。

  3. 最重要的優化技術之一:方法內聯。如上,方法內聯是編譯器最重要的優化技術手段之一,看起來很簡單,但實際上,Java 語言中預設的例項方法都是虛方法,因此編譯期做內聯的時候根本無法確定應該使用哪個版本。 Java 虛擬機器設計團隊因此引入了名為“型別繼承關係技術(Class Hierarchy Analysis, CHA)”的技術,用於確定當前已載入的類中,某個介面是否有多於一種的實現……具體執行過程見書籍 P。

  4. 最前沿的優化技術之一:逃逸分析。逃逸分析用於分析物件的動態作用域:當一個物件在方法中被定義後,它可能被外部方法所引用,稱為方法逃逸;也可能被外部執行緒訪問到,稱為執行緒逃逸。如果能確定一個物件不會逃逸到外部方法或執行緒中,則可以進行一些高效的優化:

    a) 棧上分配。Java 虛擬機器中,幾乎所有物件都是分配在堆中的,而回收堆中的物件時,無論是篩選可回收物件的過程,還是回收物件、整理記憶體的過程,都需要耗費時間,因此確定一個物件不會逃逸出方法之外時,在棧上分配這個物件的記憶體是一個很好的主意,物件會隨著方法執行結束而自動銷燬。

    b) 同步消除。

    c) 標量替換。標量指無法再分解的的資料,比如 int、long 等,相反,如果可以分解,則是聚合量。Java 中的物件就是聚合量,把一個物件拆散,將其使用到的成員變數恢復為原始型別來訪問就叫做標量替換。

    逃逸分析是一個很好的技術,主要問題是不能保證逃逸分析的效能收益高於它的消耗,如果分析後發現沒有幾個不逃逸的物件,那麼分析所耗費的時間就被浪費了。

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

Java 虛擬機器的即時編譯器和 C/C++ 的靜態優化編譯器相比,可能會由於以下原因而導致輸出的原生代碼有一些劣勢:

  1. 即時編譯器佔用的是使用者程式執行的時間,具有很大的時間壓力,它能提供的優化手段也嚴重受制於編譯成本。如果編譯速度達不到要求,則使用者會在程式的執行期間察覺到某些延遲,這點使得編譯器不敢隨便引入大規模的優化技術。

  2. Java 語言是動態的型別安全語言,這就意味著需要由虛擬機器來確保不會違反語言語義或訪問非結構化記憶體。從實現層面上看,虛擬機器必須頻繁地進行動態檢查,比如例項方法訪問時檢查空指標、陣列元素訪問時檢查上下界範圍、型別轉換時檢查繼承關係等。

  3. Java 語言雖然沒有 virtual 關鍵字,但是使用虛方法的頻率卻遠大於 C/C++ 語言,這意味著執行時對方法接收者進行多型選擇的頻率要遠大於 C/C++ 語言,也意味著即時編譯器在進行一些優化(如方法內聯)時的難度要遠大於 C/C++ 語言。

  4. Java 是可以動態擴充套件的語言,執行時載入新的類可能改變程式型別的繼承關係,這使得很多全域性的優化都難以進行,因為編譯器無法看見程式的全貌,許多全域性的優化措施都只能以激進優化的方式來完成,編譯器不得不時刻注意並隨著型別的變化而在執行時撤銷或重新進行一些優化。

  5. Java 語言中物件的記憶體分配都是在堆上進行的,只有方法中的區域性變數才能在棧上分配,而 C/C++ 的物件則有多重記憶體分配方式,既可以在堆上分配,也能在棧上分配,如果可以在棧上分配執行緒私有的物件,將減輕記憶體回收的壓力。另外,C/C++ 主要由使用者程式程式碼來回收分配的記憶體,不存在篩選可回收物件的過程,因此效率上也比垃圾回收機制高。

總結

思維導圖

在這裡插入圖片描述