深入理解Java虛擬機器-程式編譯與程式碼最佳化,華為Java影片面試

歡喜程式設計發表於2021-09-22
        *   [編譯物件與觸發條件](about:blank#_178)


       *   [編譯過程](about:blank#_227)

       *   [檢視及分析即時編譯結果](about:blank#_231)

   *   [編譯最佳化技術](about:blank#_237)

   *   *   [最佳化技術概覽](about:blank#_241)

       *   [公共子表示式消除](about:blank#_310)

       *   [陣列邊界檢查消除](about:blank#_314)

       *   [方法內聯](about:blank#_320)

       *   [逃逸分析](about:blank#_324)

   *   [Java與C/C++編譯器對比](about:blank#JavaCC_336)

*   [總結](about:blank#_342)

從計算機程式出現的第一天起,對效率的追求就是程式天生的堅定信仰,這個過程猶如一場沒有終點,永不停歇的F1方程式競賽,程式設計師試車手,技術平臺則是在賽道上飛馳的賽車。

[](

)早期(編譯期)最佳化


[](

)概述

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 中即時編譯器在執行期的最佳化過程對於程式執行來說更重要,而前端編譯器在編譯期的最佳化過程對於程式編碼來說更加密切。

[](

)Javac編譯器

[](

)Javac的原始碼與除錯

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

  1. 解析與填充符號表;

  2. 插入式註解處理器的註解處理;

  3. 分析與位元組碼生成。

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

在這裡插入圖片描述

[](

)解析與填充符號表

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

[](

)註解處理器

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

[](

)語義分析與位元組碼生成

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

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

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

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

[](

)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虛擬機器》

[](

)晚期(執行期)最佳化


[](

)概述

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

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

[](

)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 編譯請求,並且把回邊計數器的值降低一些,以便繼續在直譯器中執行迴圈,等待編譯器輸出編譯結果。

在這裡插入圖片描述

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

[](

)編譯過程

《MySql面試專題》

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

《MySql效能最佳化的21個最佳實踐》

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

《MySQL高階知識筆記》

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

文中展示的資料包括: 《MySql思維導圖》《MySql核心筆記》《MySql調優筆記》《MySql面試專題》《MySql效能最佳化的21個最佳實踐》《MySq高階知識筆記》 如下圖

全網火爆MySql 開源筆記,圖文並茂易上手,阿里P8都說好

CodeChina開源專案:【一線大廠Java面試題解析+核心總結學習筆記+最新講解影片】

關注我,點贊本文給更多有需要的人


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69990490/viewspace-2792987/,如需轉載,請註明出處,否則將追究法律責任。

相關文章