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


文章首發於微信公眾號: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 個步驟之間的關系如下圖所示:

 

解析與填充符號表

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

注解處理器

注解(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,然后判斷方法調用計數器與回邊計數器之和是否超過方法調用計數器的閾值。如果超過閾值,將會向即時編譯器提交一個該方法的代碼編譯請求。

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

 

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

進行熱度衰減的動作是在虛擬機進行 GC 時順便進行的,可以設置虛擬機參數來關閉熱度衰減,讓方法計數器統計方法調用的絕對次數,這樣,只要系統運行時間足夠長,絕大部分方法都會被編譯成本地代碼。此外還可以設置虛擬機參數調整半衰期的時間。

回邊計數器

回邊計數器的作用是統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向后跳轉的指令稱為「回邊」(Back Edge)。建立回邊計數器統計的目的是為了觸發 OSR 編譯。

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

 

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

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 吧!






免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM