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

張磊BARON發表於2019-06-29

文章首發於微信公眾號:BaronTalk,歡迎關注!

對於效能和效率的追求一直是程式開發中永恆不變的宗旨,除了我們自己在編碼過程中要充分考慮程式碼的效能和效率,虛擬機器在編譯階段也會對程式碼進行優化。本文就從虛擬機器層面來看看虛擬機器對我們所編寫的程式碼採用了哪些優化手段。

一. 早期優化(編譯期優化)

Java 語言的「編譯期」其實是一段「不確定」的操作過程。因為它可能是一個前端編譯器(如 Javac)把 *.java 檔案編譯成 *.class 檔案的過程;也可能是程式執行期的即時編譯器(JIT 編譯器,Just In Time Compiler)把位元組碼檔案編譯成機器碼的過程;還可能是靜態提前編譯器(AOT 編譯器,Ahead Of Time Compiler)直接把 *.java 檔案編譯成本地機器碼的過程。

Javac 這類編譯器對程式碼的執行效率幾乎沒有任何優化措施,虛擬機器設計團隊把對效能的優化都放到了後端的即時編譯器中,這樣可以讓那些不是由 Javac 產生的 class 檔案(如 Groovy、Kotlin 等語言產生的 class 檔案)也能享受到編譯器優化帶來的好處。但是 Javac 做了很多針對 Java 語言編碼過程的優化措施來改善程式設計師的編碼風格、提升編碼效率。相當多新生的 Java 語法特性,都是靠編譯器的「語法糖」來實現的,而不是依賴虛擬機器的底層改進來支援。

Java 中即時編譯器在執行期的優化過程對於程式執行來說更重要,而前端編譯器在編譯期的優化過程對於程式編碼來說更加密切。

1.1 Javac 編譯器

Javac 編譯器的編譯過程大致可分為 3 個步驟:

  1. 解析與填充符號表;
  2. 插入式註解處理器的註解處理;
  3. 分析與位元組碼生成。

這 3 個步驟之間的關係如下圖所示:

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

解析與填充符號表

解析步驟包含了經典程式編譯原理中的詞法分析和語法分析兩個過程;完成詞法分析和語法分析之後,下一步就是填充符號表的過程。符號表是由一組符號地址和符號資訊構成的表格。在語義分析中,符號表所登記的內容將用於語義檢查和產生中間程式碼。在目的碼生成階段,當對符號名進行地址分配時,符號表是地址分配的依據。

註解處理器

註解(Annotation)是在 JDK 1.5 中新增的,有了編譯器註解處理的標準 API 後,我們的程式碼就可以干涉編譯器的行為,比如在編譯期生成 class 檔案。

語義分析與位元組碼生成

語法分析之後,編譯器獲得了程式程式碼的抽象語法樹表示,語法樹能表示一個結構正確的源程式的抽象,但無法保證源程式是符合邏輯的。而語義分析的主要任務是對結構上正確的源程式進行上下文有關性質的審查,比如進行型別審查。

位元組碼生成是 Javac 編譯過程的最後一個階段,位元組碼生成階段不僅僅是把前面各個步驟所生成的資訊(語法樹、符號表)轉化成位元組碼寫到磁碟中,編譯器還進行了少量的程式碼新增和轉換工作。如前面提到的 () 方法就是在這一階段新增到語法樹中的。

在位元組碼生成階段,除了生成構造器以外,還有一些其它的程式碼替換工作用於優化程式的實現邏輯,如把字串的加操作替換為 StringBiulder 或 StringBuffer。

完成了對語法樹的遍歷和調整之後,就會把填充了所需資訊的符號表交給 com.sun.tools.javac.jvm.ClassWriter 類,由這個類的 writeClass() 方法輸出位元組碼,最終生成位元組碼檔案,到此為止整個編譯過程就結束了。

1.2 Java 語法糖

Java 中提供了有很多語法糖來方便程式開發,雖然語法糖不會提供實質性的功能改進,但是它能提升開發效率、語法的嚴謹性、減少編碼出錯的機會。下面我們來了解下語法糖背後我們看不見的東西。

泛型與型別擦除

泛型顧名思義就是型別泛化,本質是引數化型別的應用,也就是說操作的資料型別被指定為一個引數。這種引數可以用在類、介面和方法的建立中,分別稱為泛型類、泛型介面和泛型方法。

在 Java 語言還沒有泛型的時候,只能通過 Object 是所有型別的父類和強制型別轉換兩個特點的配合來實現型別泛化。例如 HashMap 的 get() 方法返回的就是一個 Object 物件,那麼只有程式設計師和執行期的虛擬機器才知道這個 Object 到底是個什麼型別的物件。在編譯期間,編譯器無法檢查這個 Object 的強制型別轉換是否成功,如果僅僅依賴程式設計師去保障這項操作的正確性,許多 ClassCastException 的風險就會轉嫁到程式執行期。

Java 語言中泛型只在程式原始碼中存在,在編譯後的位元組碼檔案中,就已經替換為原來的原生型別,並且在相應的地方插入了強制型別轉換的程式碼。因此對於執行期的 Java 語言來說, ArrayList 與 ArrayList 是同一個型別,所以泛型實際上是 Java 語言的一個語法糖,這種泛型的實現方法稱為型別擦除。

自動裝箱、拆箱與遍歷迴圈

自動裝箱、拆箱與遍歷迴圈是 Java 語言中用得最多的語法糖。這塊比較簡單,我們直接看程式碼:

public class SyntaxSugars {

    public static void main(String[] args){

        List<Integer> list = Arrays.asList(1,2,3,4,5);

        int sum = 0;
        for(int i : list){
            sum += i;
        }
        System.out.println("sum = " + sum);
    }
}
複製程式碼

自動裝箱、拆箱與遍歷迴圈編譯之後:

public class SyntaxSugars {

    public static void main(String[] args) {

        List list = Arrays.asList(new Integer[]{
                Integer.valueOf(1),
                Integer.valueOf(2),
                Integer.valueOf(3),
                Integer.valueOf(4),
                Integer.valueOf(5)
        });

        int sum = 0;
        for (Iterator iterable = list.iterator(); iterable.hasNext(); ) {
            int i = ((Integer) iterable.next()).intValue();
            sum += i;
        }
        System.out.println("sum = " + sum);
    }
}
複製程式碼

第一段程式碼包含了泛型、自動裝箱、自動拆箱、遍歷迴圈和變長引數 5 種語法糖,第二段程式碼則展示了它們在編譯後的變化。

條件編譯

Java 語言中條件編譯的實現也是一顆語法糖,根據布林常量值的真假,編譯器會把分支中不成立的程式碼塊消除。

public static void main(String[] args) {
    if (true) {
        System.out.println("block 1");
    } else {
        System.out.println("block 2");
    }
}
複製程式碼

上述程式碼經過編譯後 class 檔案的反編譯結果:

public static void main(String[] args) {
    System.out.println("block 1");
}
複製程式碼

二. 晚期優化(執行期優化)

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

即時編譯器不是虛擬機器必須的部分,Java 虛擬機器規範並沒有規定虛擬機器內部必須要有即時編譯器存在,更沒有限定或指導即時編譯器應該如何實現。但是 JIT 編譯效能的好壞、程式碼優化程度的高低卻是衡量一款商用虛擬機器優秀與否的最關鍵指標之一。

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

由於 Java 虛擬機器規範中沒有限定即時編譯器如何實現,所以本節的內容完全取決於虛擬機器的具體實現。我們這裡拿 HotSpot 來說明,不過後面的內容涉及具體實現細節的內容很少,主流虛擬機器中 JIT 的實現又有頗多相似之處,因此對理解其它虛擬機器的實現也有很高的參考價值。

直譯器與編譯器

儘管並不是所有的 Java 虛擬機器都採用直譯器與編譯器並存的架構,但許多主流的商用虛擬機器,如 HotSpot、J9 等,都同時包含直譯器與編譯器。

直譯器與編譯器兩者各有優勢:

  • 當程式需要迅速啟動和執行的時候,直譯器可以首先發揮作用,省去編譯的時間,立即執行。在程式執行後,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的程式碼編譯成本地機器碼之後,可以獲得更高的執行效率。

  • 當程式執行環境中記憶體資源限制較大(如部分嵌入式系統),可以使用直譯器執行來節約記憶體,反之可以使用編譯執行來提升效率。

同時,直譯器還可以作為編譯器激進優化時的一個「逃生門」,當編譯器根據概率選擇一些大多數時候都能提升執行速度的優化手段,當激進優化的假設不成立,如載入了新的類後型別繼承結構出現變化、出現「罕見陷阱」時可以通過逆優化退回到解釋狀態繼續執行。

編譯物件與觸發條件

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

  • 被多次呼叫的方法;
  • 被多次執行的迴圈體。

這兩種被多次重複執行的程式碼,稱之為「熱點程式碼」。

  • 對於被多次呼叫的方法,方法體內的程式碼自然會被執行多次,理所當然的就是熱點程式碼。

  • 而對於多次執行的迴圈體則是為了解決一個方法只被呼叫一次或者少量幾次,但是方法體內部存在迴圈次數較多的迴圈體問題,這樣迴圈體的程式碼也被重複執行多次,因此這些程式碼也是熱點程式碼。

對於第一種情況,由於是方法呼叫觸發的編譯,因此編譯器理所當然地會以整個方法作為編譯物件,這種編譯也是虛擬機器中標準的 JIT 編譯方式。而對於後一種情況,儘管編譯動作是由迴圈體所觸發的,但是編譯器依然會以整個方法(而不是單獨的迴圈體)作為編譯物件。這種編譯方式因為發生在方法執行過程中,因此形象地稱之為棧上替換(On Stack Replacement,簡稱 OSR 編譯,即方法棧幀還在棧上,方法就被替換了)。

我們反覆提到多次,可是多少次算多次呢?虛擬機器如何統計一個方法或一段程式碼被執行過多少次呢?回答了這兩個問題,也就回答了即時編譯器的觸發條件。

判斷一段程式碼是不是熱點程式碼,是不是需要觸發即時編譯,這樣的行為稱為「熱點探測」。其實進行熱點探測並不一定需要知道方法具體被呼叫了多少次,目前主要的熱點探測判定方式有兩種。

  • 基於取樣的熱點探測:採用這種方法的虛擬機器會週期性地檢查各個執行緒棧頂,如果發現某個(或某些)方法經常出現在棧頂,那這個方法就是「熱點方法」。基於取樣的熱點探測的好處是實現簡單、高效,還可以很容易地獲取方法呼叫關係(將呼叫棧展開即可),缺點是很難精確地確認一個方法的熱度,容易因為受到執行緒阻塞或別的外界因數的影響而擾亂熱點探測。

  • 基於計數器的熱點探測:採用這種方法的虛擬機器會為每個方法(甚至程式碼塊)建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值就認為它是「熱點方法」。這種統計方法實現起來麻煩一些,需要為每個方法建立並維護計數器,而且不能直接獲取到方法的呼叫關係,但是統計結果相對來說更加精確和嚴謹。

HotSpot 虛擬機器採用的是第二種:基於計數器的熱點探測。因此它為每個方法準備了兩類計數器:方法呼叫計數器(Invocation Counter)和回邊計數器(Back Edge Counter)。

在確定虛擬機器執行引數的情況下,這兩個計數器都有一個確定的閾值,當計數器超過閾值就會觸發 JIT 編譯。

方法呼叫計數器

顧名思義,這個計數器用於統計方法被呼叫的次數。當一個方法被呼叫時,會首先檢查該方法是否存在被 JIT 編譯過的版本,如果存在,則優先使用編譯後的原生程式碼來執行。如果不存在,則將此方法的呼叫計數器加 1,然後判斷方法呼叫計數器與回邊計數器之和是否超過方法呼叫計數器的閾值。如果超過閾值,將會向即時編譯器提交一個該方法的程式碼編譯請求。

如果不做任何設定,執行引擎不會同步等待編譯請求完成,而是繼續進入直譯器按照解釋方式執行位元組碼,直到提交的請求被編譯器編譯完成。當編譯完成後,這個方法的呼叫入口地址就會被系統自動改寫成新的,下一次呼叫該方法時就會使用已編譯的版本。

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

如果不做任何設定,方法呼叫計數器統計的並不是方法被呼叫的絕對次數,而是一個相對的執行頻率,即一段時間內方法呼叫的次數。當超過一定的時間限度,如果方法的呼叫次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的呼叫計數器值就會被減少一半,這個過程稱為方法呼叫計數器熱度的衰減,而這段時間就稱為此方法統計的半衰期。

進行熱度衰減的動作是在虛擬機器進行 GC 時順便進行的,可以設定虛擬機器引數來關閉熱度衰減,讓方法計數器統計方法呼叫的絕對次數,這樣,只要系統執行時間足夠長,絕大部分方法都會被編譯成原生程式碼。此外還可以設定虛擬機器引數調整半衰期的時間。

回邊計數器

回邊計數器的作用是統計一個方法中迴圈體程式碼執行的次數,在位元組碼中遇到控制流向後跳轉的指令稱為「回邊」(Back Edge)。建立回邊計數器統計的目的是為了觸發 OSR 編譯。

當直譯器遇到一條回邊指令時,會先查詢將要執行的程式碼片段是否已經有編譯好的版本,如果有,它將優先執行已編譯的程式碼,否則就把回邊計數器值加 1,然後判斷方法呼叫計數器和回邊計數器值之和是否超過計數器的閾值。當超過閾值時,將會提交一個 OSR 編譯請求,並且把回邊計數器的值降低一些,以便繼續在直譯器中執行迴圈,等待編譯器輸出編譯結果。

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

與方法計數器不同,回邊計數器沒有計算熱度衰減的過程,因此這個計數器統計的就是該方法迴圈執行的絕對次數。當計數器溢位時,它還會把方法計數器的值也調整到溢位狀態,這樣下次再進入該方法的時候就會執行標準編譯過程。

2.2 編譯優化技術

我們都知道,以編譯方式執行原生程式碼比解釋執行方式更快,一方面是因為節約了虛擬機器解釋執行位元組碼額外消耗的時間;另一方面是因為虛擬機器設計團隊幾乎把所有對程式碼的優化措施都集中到了即時編譯器中。這一小節我們來介紹下 HotSpot 虛擬機器的即時編譯器在編譯程式碼時採用的優化技術。

優化技術概覽

程式碼優化技術有很多,實現這些優化也很有難度,但是大部分還是比較好理解的。為了便於介紹,我們先從一段簡單的程式碼開始,看看虛擬機器會做哪些程式碼優化。

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

public void foo() {
    y = b.get();
    z = b.get();
    sum = y + z;
}
複製程式碼

首先需要明確的是,這些程式碼優化是建立在程式碼的某種中間表示或者機器碼上的,絕不是建立在 Java 原始碼上。這裡之所使用 Java 程式碼來介紹是為了方便演示。

上面這段程式碼看起來簡單,但是有許多可以優化的地方。

第一步是進行方法內聯(Method Inlining),方法內聯的重要性要高於其它優化措施。方法內聯的目的主要有兩個,一是去除方法呼叫的成本(比如建立棧幀),二是為其它優化建立良好的基礎,方法內聯膨脹之後可以便於更大範圍上採取後續的優化手段,從而獲得更好的優化效果。因此,各種編譯器一般都會把內聯優化放在優化序列的最前面。內聯優化後的程式碼如下:

public void foo() {
    y = b.value;
    z = b.value;
    sum = y + z;
}
複製程式碼

第二步進行冗餘消除,程式碼中「z = b.value;」可以被替換成「z = y」。這樣就不用再去訪問物件 b 的區域性變數。如果把 b.value 看做是一個表示式,那也可以把這項優化工作看成是公共子表示式消除。優化後的程式碼如下:

public void foo() {
    y = b.value;
    z = y;
    sum = y + z;
}
複製程式碼

第三步進行復寫傳播,因為這段程式碼裡沒有必要使用一個額外的變數 z,它與變數 y 是完全等價的,因此可以使用 y 來代替 z。複寫傳播後的程式碼如下:

public void foo() {
    y = b.value;
    y = y;
    sum = y + y;
}
複製程式碼

第四步進行無用程式碼消除。無用程式碼可能是永遠不會執行的程式碼,也可能是完全沒有意義的程式碼。因此,又被形象的成為「Dead Code」。上述程式碼中 y = y 是沒有意義的,因此進行無用程式碼消除後的程式碼是這樣的:

public void foo() {
    y = b.value;
    sum = y + y;
}
複製程式碼

經過這四次優化後,最新優化後的程式碼和優化前的程式碼所達到的效果是一致的,但是優化後的程式碼執行效率會更高。編譯器的這些優化技術實現起來是很複雜的,但是想要理解它們還是很容易的。接下來我們再講講如下幾項最有代表性的優化技術是如何運作的,它們分別是:

  • 公共子表示式消除;
  • 陣列邊界檢查消除;
  • 方法內聯;
  • 逃逸分析。

公共子表示式消除

如果一個表示式 E 已經計算過了,並且從先前的計算到現在 E 中所有變數的值都沒有發生變化,那麼 E 的這次出現就成了公共子表示式。對於這種表示式,沒有必要花時間再對它進行計算,只需要直接使用前面計算過的表示式結果代替 E 就好了。如果這種優化僅限於程式的基本塊內,便稱為區域性公共子表示式消除,如果這種優化的範圍覆蓋了多個基本塊,那就稱為全域性公共子表示式消除。

陣列邊界檢查消除

如果有一個陣列 array[],在 Java 中訪問陣列元素 array[i] 的時候,系統會自動進行上下界的範圍檢查,即檢查 i 必須滿足 i >= 0 && i < array.length,否則會丟擲一個執行時異常:java.lang.ArrayIndexOutOfBoundsException,這就是陣列邊界檢查。

對於虛擬機器執行子系統來說,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量陣列訪問的程式程式碼,這是一種不小的效能開銷。為了安全,陣列邊界檢查是必須做的,但是陣列邊界檢查並不一定每次都要進行。比如在迴圈的時候訪問陣列,如果編譯器只要通過資料流分析就知道迴圈變數是不是在區間 [0, array.length] 之內,那在整個迴圈中就可以把陣列的上下界檢查消除。

方法內聯

方法內聯前面已經通過程式碼分析介紹過,這裡就不再贅述了。

逃逸分析

逃逸分析不是直接優化程式碼的手段,而是為其它優化手段提供依據的分析技術。逃逸分析的基本行為就是分析物件的動態作用域:當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞到其它方法中,稱為方法逃逸。甚至還有可能被外部執行緒訪問到,例如賦值給類變數或可以在其他執行緒中訪問的例項變數,稱為執行緒逃逸。

如果能證明一個物件不會逃逸到方法或者執行緒之外,也就是別的方法和執行緒無法通過任何途徑訪問到這個方法,則可能為這個變數進行一些高效優化。比如:

  1. 棧上分配:如果確定一個物件不會逃逸到方法之外,那麼就可以在棧上分配記憶體,物件所佔的記憶體空間就可以隨棧幀出棧而銷燬。通常,不會逃逸的區域性物件所佔的比例很大,如果能棧上分配就會大大減輕 GC 的壓力。

  2. 同步消除:如果逃逸分析能確定一個變數不會逃逸出執行緒,無法被其它執行緒訪問,那這個變數的讀寫就不會有多執行緒競爭的問題,因而變數的同步措施也就可以消除了。

  3. 標量替換:標量是指一個資料無法再拆分成更小的資料來表示了,Java 虛擬機器中的原始資料型別都不能再進一步拆分,所以它們就是標量。相反,一個資料可以繼續分解,那它就稱作聚合量,Java 中的物件就是聚合量。如果把一個 Java 物件拆散,根據訪問情況將其使用到的成員變數恢復成原始型別來訪問,就叫標量替換。如果逃逸分析證明一個物件不會被外部訪問,並且這個物件可以被拆散,那程式執行的時候就可能不建立這個物件,而改為直接建立它的若干個被這個方法使用到的成員變數來替代。物件被拆分後,除了可以讓物件的成員變數在棧上分配和讀寫,還可以為後續進一步的優化手段創造條件。

三. 總結

本文用兩個小節分別介紹了 Java 程式從原始碼編譯成位元組碼和從位元組碼編譯成本地機器碼的過程,Javac 位元組碼編譯器與虛擬機器內的 JIT 編譯器的執行過程合併起來其實就等同於一個傳統編譯器所執行的編譯過程。下一篇文章我們來聊聊虛擬機器是如何高效處理併發的。

參考資料:

  • 《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐(第 2 版)》

如果你喜歡我的文章,就關注下我的公眾號 BaronTalk知乎專欄 或者在 GitHub 上添個 Star 吧!

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

相關文章