文章首發於微信公衆號:BaronTalk,歡迎關注!前端
對於性能和效率的追求一直是程序開發中永恆不變的宗旨,除了咱們本身在編碼過程當中要充分考慮代碼的性能和效率,虛擬機在編譯階段也會對代碼進行優化。本文就從虛擬機層面來看看虛擬機對咱們所編寫的代碼採用了哪些優化手段。java
Java 語言的「編譯期」實際上是一段「不肯定」的操做過程。由於它多是一個前端編譯器(如 Javac)把 *.java 文件編譯成 *.class 文件的過程;也多是程序運行期的即時編譯器(JIT 編譯器,Just In Time Compiler)把字節碼文件編譯成機器碼的過程;還多是靜態提早編譯器(AOT 編譯器,Ahead Of Time Compiler)直接把 *.java 文件編譯成本地機器碼的過程。git
Javac 這類編譯器對代碼的運行效率幾乎沒有任何優化措施,虛擬機設計團隊把對性能的優化都放到了後端的即時編譯器中,這樣可讓那些不是由 Javac 產生的 class 文件(如 Groovy、Kotlin 等語言產生的 class 文件)也能享受到編譯器優化帶來的好處。可是 Javac 作了不少針對 Java 語言編碼過程的優化措施來改善程序員的編碼風格、提高編碼效率。至關多新生的 Java 語法特性,都是靠編譯器的「語法糖」來實現的,而不是依賴虛擬機的底層改進來支持。程序員
Java 中即時編譯器在運行期的優化過程對於程序運行來講更重要,而前端編譯器在編譯期的優化過程對於程序編碼來講更加密切。github
Javac 編譯器的編譯過程大體可分爲 3 個步驟:後端
這 3 個步驟之間的關係以下圖所示:數組
解析步驟包含了經典程序編譯原理中的詞法分析和語法分析兩個過程;完成詞法分析和語法分析以後,下一步就是填充符號表的過程。符號表是由一組符號地址和符號信息構成的表格。在語義分析中,符號表所登記的內容將用於語義檢查和產生中間代碼。在目標代碼生成階段,當對符號名進行地址分配時,符號表是地址分配的依據。安全
註解(Annotation)是在 JDK 1.5 中新增的,有了編譯器註解處理的標準 API 後,咱們的代碼就能夠干涉編譯器的行爲,好比在編譯期生成 class 文件。微信
語法分析以後,編譯器得到了程序代碼的抽象語法樹表示,語法樹能表示一個結構正確的源程序的抽象,但沒法保證源程序是符合邏輯的。而語義分析的主要任務是對結構上正確的源程序進行上下文有關性質的審查,好比進行類型審查。多線程
字節碼生成是 Javac 編譯過程的最後一個階段,字節碼生成階段不只僅是把前面各個步驟所生成的信息(語法樹、符號表)轉化成字節碼寫到磁盤中,編譯器還進行了少許的代碼添加和轉換工做。如前面提到的 () 方法就是在這一階段添加到語法樹中的。
在字節碼生成階段,除了生成構造器之外,還有一些其它的代碼替換工做用於優化程序的實現邏輯,如把字符串的加操做替換爲 StringBiulder 或 StringBuffer。
完成了對語法樹的遍歷和調整以後,就會把填充了所需信息的符號表交給 com.sun.tools.javac.jvm.ClassWriter 類,由這個類的 writeClass() 方法輸出字節碼,最終生成字節碼文件,到此爲止整個編譯過程就結束了。
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 編譯性能的好壞、代碼優化程度的高低倒是衡量一款商用虛擬機優秀與否的最關鍵指標之一。
因爲 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 編譯請求,而且把回邊計數器的值下降一些,以便繼續在解釋器中執行循環,等待編譯器輸出編譯結果。
與方法計數器不一樣,回邊計數器沒有計算熱度衰減的過程,所以這個計數器統計的就是該方法循環執行的絕對次數。當計數器溢出時,它還會把方法計數器的值也調整到溢出狀態,這樣下次再進入該方法的時候就會執行標準編譯過程。
咱們都知道,以編譯方式執行本地代碼比解釋執行方式更快,一方面是由於節約了虛擬機解釋執行字節碼額外消耗的時間;另外一方面是由於虛擬機設計團隊幾乎把全部對代碼的優化措施都集中到了即時編譯器中。這一小節咱們來介紹下 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] 以內,那在整個循環中就能夠把數組的上下界檢查消除。
方法內聯前面已經經過代碼分析介紹過,這裏就再也不贅述了。
逃逸分析不是直接優化代碼的手段,而是爲其它優化手段提供依據的分析技術。逃逸分析的基本行爲就是分析對象的動態做用域:當一個對象在方法中被定義後,它可能被外部方法所引用,例如做爲調用參數傳遞到其它方法中,稱爲方法逃逸。甚至還有可能被外部線程訪問到,例如賦值給類變量或能夠在其餘線程中訪問的實例變量,稱爲線程逃逸。
若是能證實一個對象不會逃逸到方法或者線程以外,也就是別的方法和線程沒法經過任何途徑訪問到這個方法,則可能爲這個變量進行一些高效優化。好比:
棧上分配:若是肯定一個對象不會逃逸到方法以外,那麼就能夠在棧上分配內存,對象所佔的內存空間就能夠隨棧幀出棧而銷燬。一般,不會逃逸的局部對象所佔的比例很大,若是能棧上分配就會大大減輕 GC 的壓力。
同步消除:若是逃逸分析能肯定一個變量不會逃逸出線程,沒法被其它線程訪問,那這個變量的讀寫就不會有多線程競爭的問題,於是變量的同步措施也就能夠消除了。
標量替換:標量是指一個數據沒法再拆分紅更小的數據來表示了,Java 虛擬機中的原始數據類型都不能再進一步拆分,因此它們就是標量。相反,一個數據能夠繼續分解,那它就稱做聚合量,Java 中的對象就是聚合量。若是把一個 Java 對象拆散,根據訪問狀況將其使用到的成員變量恢復成原始類型來訪問,就叫標量替換。若是逃逸分析證實一個對象不會被外部訪問,而且這個對象能夠被拆散,那程序執行的時候就可能不建立這個對象,而改成直接建立它的若干個被這個方法使用到的成員變量來替代。對象被拆分後,除了可讓對象的成員變量在棧上分配和讀寫,還能夠爲後續進一步的優化手段創造條件。
本文用兩個小節分別介紹了 Java 程序從源代碼編譯成字節碼和從字節碼編譯成本地機器碼的過程,Javac 字節碼編譯器與虛擬機內的 JIT 編譯器的執行過程合併起來其實就等同於一個傳統編譯器所執行的編譯過程。下一篇文章咱們來聊聊虛擬機是如何高效處理併發的。
參考資料:
若是你喜歡個人文章,就關注下個人公衆號 BaronTalk 、 知乎專欄 或者在 GitHub 上添個 Star 吧!
- 微信公衆號:BaronTalk
- 知乎專欄:https://zhuanlan.zhihu.com/baron
- GitHub:https://github.com/BaronZ88