JVM(十三):後端編譯優化
JVM(十三):後端編譯優化
在 JVM(一):原始檔的轉變 中我們介紹了 Java 中的前端優化,即將 Java 原始碼轉換為位元組碼檔案。在本文中,我們將介紹位元組碼檔案如何轉換為本地機器碼,並如何對程式碼進行優化,以提高效能。因為不同的虛擬機器,位元組碼優化引擎不同,因此本文采用 JIT 來作為例子,其也是 HotSpot 中的預設編譯器。
架構
我們都知道將程式碼轉換為機器碼有兩種方式,而在 HotSpot 中採用了卻兩者全部都涉及到了,其採用瞭解釋器和編譯器並存的架構。那麼其這樣的目的是什麼呢?
首先我們知道解釋執行,可以大大提高程式啟動時的效率,因為在這個時候需要執行什麼程式碼,才對對應的原始碼進行翻譯,將其變為機器碼,因此也提高了啟動時效率;
而編譯執行的優點則是可以獲得更高的執行效率,因為其將中間程式碼全部編譯成了與機器相關的原生代碼,並且在這一階段,有些編譯器還會對編譯後的程式碼進行初步的優化,這也使得效率更加的優秀。
因此 Hotspot 開始執行的時候採用解釋執行,獲得優良的啟動效率,而在程式碼執行過程中,對執行情況進行監控,運用以前所說的 熱點程式碼編譯技術 將熱點程式碼編譯成本地機器碼,並根據執行情況進行優化,以獲得兩者全部的優點。
可能會有讀者問道,我的程式碼部署在伺服器上,第一次慢一點就慢一點,我只採用編譯執行不行嗎?
其實不然,首先因為編譯器需要對程式碼進行優化,因此肯定是執行過程中根據執行情況進行優化更加的好,此外,在優化的過程中也有一種 激進優化 的方式,在這種情況下,就需要採用解釋執行的方式通過 逆優化 的方式來退回到解釋狀態來執行了。
因此,兩種方式並存的架構是合理且必要的,也因此目前主流的虛擬機器也大多采取這種架構。
即時編譯器
HotSpot 中的即時編譯器有兩種,分別稱為 Client Complier 和 Server Complier,或者簡稱為 C1 和 C2,目前虛擬機器一般採用直譯器和一個即時編譯器直接配合的方式來執行,這種模式稱之為 混合模式。
既然是兩者合作,那麼久需要考慮一個排程的問題,即何時使用編譯執行,何時採用解釋執行,多少的比例可以獲得最佳平衡,得到最高的效率。
在 HotSpot 中是通過 分層編譯 的策略來達到最優解的。其本質的思想如下所示:
- 第0層:程式解釋執行,直譯器不開啟效能監控,觸發第一層;
- 第1層:C1 編譯,將位元組碼編譯為原生代碼,進行簡單、可靠的優化,如果有必要,可以加入效能監控;
- 第2層:C2 編譯,也是將位元組碼編譯為原生代碼,但其會啟動一些耗時較長的優化,甚至會根據監控的資訊採取一些激進的優化措施。
這種分層編譯的方式可以達到一定情況的最優解:用 C1 獲取更快的編譯速度,用 C2 獲取更好的編譯質量,解釋執行的時候也無需增加效能監控的任務,反而拖累了啟動效率。
編譯物件
因為編譯的過程是一個耗時耗力的工作,因此對那麼頻繁執行的程式碼進行編譯能獲得更高的提升。因此如何判斷那麼程式碼是 熱點程式碼 呢?
在 JVM 中,熱點程式碼的判斷方式有兩種:
- 基於取樣,週期性的檢查棧頂,如果一段程式碼頻繁出現在棧幀頂部,那麼就判斷其是熱點程式碼。
- 優點:實現簡單,快;
- 缺點:探測很容易收到執行緒阻塞的影響。例如一個方法因為執行緒阻塞,一直在棧頂,但其實其執行次數並不多,那麼將其判定為熱點程式碼就是不合理的。
- 基於計數器:為每個方法甚至是程式碼塊建立計數器來統計執行次數,如果統計的次數達到了一定的條件則說明是熱點程式碼
- 優點:結果精確
- 缺點:實現就比較麻煩了,需要維護計數器
HotSpot 中採取的是第二種方案,因為頻繁執行的程式碼有如下兩種:
- 方法的頻繁執行
- 一段程式碼的頻繁執行
因此 HotSpot 中建立了兩種型別的計數器來進行判斷,其執行邏輯分別為如下所示:
回邊的判斷方法與方法基本一致,只是在提交編譯請求後,需要把回邊計數器的值減小一點,保證程式碼以解釋狀態繼續執行。
經典優化方案
JIT 中有太多編譯的優化技術了,在這裡我們就找幾個比較經典的介紹一個,剩下的讀者感興趣可以 Google 一下,或者給作者留言,可以再拓展一篇文章單獨介紹一下。
方法內聯
方法內聯應該是 Java 中最重要的幾項優化技術之一,其存在的最大意義就是為其他優化手段提供了基礎。其使得程式碼膨脹,因此也提供了更多的優化機會。
表面來看,方法內聯只是將程式碼複製一份到呼叫的地方,但實現起來真的那麼簡單嗎?
前面我們說過方法的多型呼叫,介紹了只有 非虛方法 可以在編譯期間知道呼叫的是哪個版本的方法,但是像虛方法這種,是可能會存在多個版本可以選擇的,那麼編譯器在進行方法內聯的時候,該複製哪裡的程式碼呢?
為了解決這個問題,JVM 團隊引入了 型別繼承關係分析 的技術。這種技術的執行邏輯如下所示:
公共子表示式消除
如果一個表示式,在兩次計算過程中,其內所有變數的值並沒有發生變化,那麼則將其稱為公共子表示式。
舉個栗子:
int a = (b*c)*4+(c*b+d)+d
上面這段程式碼在計算 b*c
的兩次中並沒有變化,因此可以將其簡寫為int a = E * 4 + (E + d) + d
,再進一步還可以進行 代數化簡 優化,將其優化為:
int a = E * 5 + 2 * d;
陣列範圍檢查消除
Java 語言中為了保持程式碼的健壯和安全性,在每次陣列訪問的時候需要判斷其是否在 0 ~ length-1 的範圍內,如若不然,將丟擲異常。這樣做有個顯而易見的好處是可以提高程式的健壯性,但這對於擁有大量陣列訪問的程式來說,就是一個災難了。
因此,如果可以確保陣列訪問不會越界的情況下,JVM 則可以做出相應的優化,例如可以使用隱式異常處理。栗子如下:
if(object != null){
return object.value;
}else{
throw new Exception();
}
在確定如果 object 在大多數情況下不會為空後,可以做出以下優化:
try{
return object.value;
}catch(segment_fault){
exception_execute;
....
}
這樣就可以減少大量的判斷開銷。
逃逸分析
逃逸分析可以說是目前最前沿的優化技術。其是指當分析物件作用域時,如果一個物件在被定義後,其不會外部方法和執行緒訪問到,那麼就可以說明其是不會逃逸的,即其生命週期只有在被定義的塊中,因此就可以對其進行優化。
- 棧上分配,Java 物件大家都知道是分配在堆上的,但通過前面的學習,我們知道棧上的物件在管理時,是十分地影響效能的。因此我們考慮,既然其不會逃逸的話,那麼直接將其分配到棧上不是更好嗎。這樣其可以隨著執行緒的消亡而消亡,減少垃圾收集的壓力;
- 同步消除,如果物件不會逃逸,就別談執行緒不安全的訪問了,也就不會被多個執行緒訪問,因此沒有必要對其進行同步,直接可以把同步消除掉;
- 標量替換,Java 中的物件分為 標量 和 聚合量 ,其中標量是不能再被拆分的變數,如 int、long 等。而聚合量中最典型的就是物件,現在如果能判斷物件不會逃逸,因此結合棧上分配,將其拆分為標量然後分配到棧上是一個很好的優化方式。
前面說了那麼多逃逸分析的優點,但目前逃逸分析技術還並不是十分的成熟,其能夠帶來的優化效果還不好說。
例如下面這種極端情況,JVM 在經過逃逸分析後,發現所有的物件都是可以逃逸出去的,那麼就帶來的效能消耗就十分的不值了,因為畢竟逃逸分析是一個相對高耗時的過程,耗費了大量的時間和運算資源,結果發現全部白費了。
不過雖然如此,但筆者相信逃逸分析一定是一個優化的技術發展路線。因為其經過優化後的程式碼大大提升了效能。
總結
在本文中,我們對後端編譯的方方面面進行了分析,包括其編譯器架構,分層編譯的思想,如何判斷一段程式碼值得被編譯為原生代碼,以及採取哪些方式來優化程式碼。
對這些內容的深入理解,有助於我們在工作中分出哪些程式碼是可以被編譯器優化的,哪些是需要自己處理的,提高自身編碼效率。
文章在公眾號「iceWang」第一手更新,有興趣的朋友可以關注公眾號,第一時間看到筆者分享的各項知識點,謝謝!筆芯!
本系列文章主要借鑑自《深入分析 JavaWeb 技術內幕》和《深入理解 Java 虛擬機器-JVM 高階特性與最佳實踐》。