深入瞭解JVM虛擬機8:Java的編譯期優化與運行期優化

java編譯期優化

微信公衆號【Java技術江湖】一位阿里 Java 工程師的技術小站。作者黃小斜,專注 Java 相關技術:SSM、SpringBoot、MySQL、分佈式、中間件、集羣、Linux、網絡、多線程,偶爾講點Docker、ELK,同時也分享技術乾貨和學習經驗,致力於Java全棧開發!(關注公衆號後回覆”Java“即可領取 Java基礎、進階、項目和架構師等免費學習資料,更有數據庫、分佈式、微服務等熱門技術學習視頻,內容豐富,兼顧原理和實踐,另外也將贈送作者原創的Java學習指南、Java程序員面試指南等乾貨資源)


                     

java語言的編譯期其實是一段不確定的操作過程,因爲它可以分爲三類編譯過程:
1.前端編譯:把.java文件轉變爲.class文件
2.後端編譯:把字節碼轉變爲機器碼
3.靜態提前編譯:直接把*.java文件編譯成本地機器代碼
從JDK1.3開始,虛擬機設計團隊就把對性能的優化集中到了後端的即時編譯中,這樣可以讓那些不是由Javac產生的Class文件(如JRuby、Groovy等語言的Class文件)也能享受到編譯期優化所帶來的好處
Java中即時編譯在運行期的優化過程對於程序運行來說更重要,而前端編譯期在編譯期的優化過程對於程序編碼來說關係更加密切    

早期(編譯期)優化

早期編譯過程主要分爲3個部分:
1.解析與填充符號表過程:詞法、語法分析;填充符號表  
2.插入式註解處理器的註解處理過程  
3.語義分析與字節碼生成過程:標註檢查、數據與控制流分析、解語法糖、字節碼生成
泛型與類型擦除

Java語言中的泛型只在程序源碼中存在,在編譯後的字節碼文件中,就已經替換成原來的原生類型了,並且在相應的地方插入了強制轉型代碼

泛型擦除前的例子    
public static void main( String[] args ){
    Map<String,String> map = new HashMap<String, String>();    map.put("hello","你好");
    System.out.println(map.get("hello"));
}

泛型擦除後的例子    
public static void main( String[] args ){
    Map map = new HashMap();    map.put("hello","你好");
    System.out.println((String)map.get("hello"));
}
自動裝箱、拆箱與遍歷循環

自動裝箱、拆箱在編譯之後會被轉化成對應的包裝和還原方法,如Integer.valueOf()與Integer.intValue(),而遍歷循環則把代碼還原成了迭代器的實現,變長參數會變成數組類型的參數。
然而包裝類的“==”運算在不遇到算術運算的情況下不會自動拆箱,以及它們的equals()方法不處理數據轉型的關係。

條件編譯

Java語言也可以進行條件編譯,方法就是使用條件爲常量的if語句,它在編譯階段就會被“運行”:

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");
}

只能是條件爲常量的if語句,這也是Java語言的語法糖,根據布爾常量值的真假,編譯器會把分支中不成立的代碼塊消除掉

晚期(運行期)優化

解釋器與編譯器

Java程序最初是通過解釋器進行解釋執行的,當程序需要迅速啓動和執行時,解釋器可以首先發揮作用,省去編譯時間,立即執行;當程序運行後,隨着時間的推移,編譯期逐漸發揮作用,把越來越多的代碼編譯成本地代碼,獲得更高的執行效率。解釋執行節約內存,編譯執行提升效率。 同時,解釋器可以作爲編譯器激進優化時的一個“逃生門”,讓編譯器根據概率選擇一些大多數時候都能提升運行速度的優化手段,當激進優化的假設不成立,則通過逆優化退回到解釋狀態繼續執行。

HotSpot虛擬機中內置了兩個即時編譯器,分別稱爲Client Compiler(C1編譯器)和Server Compiler(C2編譯器),默認採用解釋器與其中一個編譯器直接配合的方式工作,使用哪個編譯器取決於虛擬機運行的模式,也可以自己去指定。若強制虛擬機運行與“解釋模式”,編譯器完全不介入工作,若強制虛擬機運行於“編譯模式”,則優先採用編譯方式執行程序,解釋器仍然要在編譯無法進行的情況下介入執行過程。

分層編譯策略
分層編譯策略作爲默認編譯策略在JDK1.7的Server模式虛擬機中被開啓,其中包括:
第0層:程序解釋執行,解釋器不開啓性能監控功能,可觸發第1層編譯;
第1層:C1編譯,將字節碼編譯成本地代碼,進行簡單可靠的優化,如有必要將加入性能監控的邏輯;
第2層:C2編譯,也是將字節碼編譯成本地代碼,但是會啓動一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。
實施分層編譯後,C1和C2將會同時工作,C1獲取更高的編譯速度,C2獲取更好的編譯質量,在解釋執行的時候也無須再承擔性能監控信息的任務。
熱點代碼探測
在運行過程中會被即時編譯器編譯的“熱點代碼”有兩類:
1.被多次調用的方法:由方法調用觸發的編譯,屬於JIT編譯方式
2.被多次執行的循環體:也以整個方法作爲編譯對象,因爲編譯發生在方法執行過程中,因此成爲棧上替換(OSR編譯)

熱點探測判定方式有兩種:
1.基於採樣的熱點探測:虛擬機週期性的檢查各個線程的棧頂,如果某個方法經常出現在棧頂,則判定爲“熱點方法”。(簡單高效,可以獲取方法的調用關係,但容易受線程阻塞或別的外界因素影響擾亂熱點探測)
2.基於計數的熱點探測:虛擬機爲每個方法建立一個計數器,統計方法的執行次數,超過一定閾值就是“熱點方法”。(需要爲每個方法維護計數器,不能直接獲取方法的調用關係,但是統計結果精確嚴謹)

HotSpot虛擬機使用的是第二種,它爲每個方法準備了兩類計數器:方法調用計數器和回邊計數器,下圖表示方法調用計數器觸發即時編譯:

如果不做任何設置,執行引擎會繼續進入解釋器按照解釋方式執行字節碼,直到提交的請求被編譯器編譯完成,下次調用纔會使用已編譯的版本。另外,方法調用計數器的值也不是一個絕對次數,而是一段時間之內被調用的次數,超過這個時間,次數就減半,這稱爲計數器熱度的衰減。

下圖表示回邊計數器觸發即時編譯:

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

編譯優化技術

虛擬機設計團隊幾乎把對代碼的所有優化措施都集中在了即時編譯器之中,那麼在編譯器編譯的過程中,到底做了些什麼事情呢?下面將介紹幾種最有代表性的優化技術:
公共子表達式消除
如果一個表達式E已經計算過了,並且先前的計算到現在E中所有變量的值都沒有發生變化,那麼E的這次出現就成爲了公共表達式,可以直接用之前的結果替換。
例:int d = (c * b) * 12 + a + (a + b * c) => int d = E * 12 + a + (a + E)

數組邊界檢查消除
Java語言中訪問數組元素都要進行上下界的範圍檢查,每次讀寫都有一次條件判定操作,這無疑是一種負擔。編譯器只要通過數據流分析就可以判定循環變量的取值範圍永遠在數組長度以內,那麼整個循環中就可以把上下界檢查消除,這樣可以省很多次的條件判斷操作。

另一種方法叫做隱式異常處理,Java中空指針的判斷和算術運算中除數爲0的檢查都採用了這個思路:

if(foo != null){    return foo.value;
}else{    throw new NullPointException();
}

使用隱式異常優化以後:try{    return foo.value;
}catch(segment_fault){
    uncommon_trap();
}
當foo極少爲空時,隱式異常優化是值得的,但是foo經常爲空,這樣的優化反而會讓程序變慢,而HotSpot虛擬機會根據運行期收集到的Profile信息自動選擇最優方案。

方法內聯
方法內聯能去除方法調用的成本,同時也爲其他優化建立了良好的基礎,因此各種編譯器一般會把內聯優化放在優化序列的最靠前位置,然而由於Java對象的方法默認都是虛方法,因此方法調用都需要在運行時進行多態選擇,爲了解決虛方法的內聯問題,首先引入了“類型繼承關係分析(CHA)”的技術。

1.在內聯時,若是非虛方法,則可以直接內聯  
2.遇到虛方法,首先根據CHA判斷此方法是否有多個目標版本,若只有一個,可以直接內聯,但是需要預留一個“逃生門”,稱爲守護內聯,若在程序的後續執行過程中,加載了導致繼承關係發生變化的新類,就需要拋棄已經編譯的代碼,退回到解釋狀態執行,或者重新編譯。
3.若CHA判斷此方法有多個目標版本,則編譯器會使用“內聯緩存”,第一次調用緩存記錄下方法接收者的版本信息,並且每次調用都比較版本,若一致則可以一直使用,若不一致則取消內聯,查找虛方法表進行方法分派。

逃逸分析
逃逸分析的基本行爲就是分析對象動態作用域,當一個對象被外部方法所引用,稱爲方法逃逸;當被外部線程訪問,稱爲線程逃逸。若能證明一個對象不會被外部方法或進程引用,則可以爲這個變量進行一些優化:

1.棧上分配:如果確定一個對象不會逃逸,則可以讓它分配在棧上,對象所佔用的內存空間就可以隨棧幀出棧而銷燬。這樣可以減小垃圾收集系統的壓力。  
2.同步消除:線程同步相對耗時,如果確定一個變量不會逃逸出線程,那這個變量的讀寫不會有競爭,則對這個變量實施的同步措施也就可以消除掉。  
3.標量替換:如果逃逸分析證明一個對象不會被外部訪問,並且這個對象可以被拆散的話,那麼程序真正執行的時候可以不創建這個對象,改爲直接創建它的成員變量,這樣就可以在棧上分配。

可是目前還不能保證逃逸分析的性能收益必定高於它的消耗,所以這項技術還不是很成熟。

java與C/C++編譯器對比

Java虛擬機的即時編譯器與C/C++的靜態編譯器相比,可能會由於下面的原因導致輸出的本地代碼有一些劣勢:
1.即時編譯器運行佔用的是用戶程序的運行時間,具有很大的時間壓力,因此不敢隨便引入大規模的優化技術;
2.Java語言是動態的類型安全語言,虛擬器需要頻繁的進行動態檢查,如空指針,上下界範圍,繼承關係等;
3.Java中使用虛方法頻率遠高於C++,則需要進行多態選擇的頻率遠高於C++;
4.Java是可以動態擴展的語言,運行時加載新的類可能改變原有的繼承關係,許多全局的優化措施只能以激進優化的方式來完成;
5.Java語言的對象內存都在堆上分配,垃圾回收的壓力比C++大

然而,Java語言這些性能上的劣勢換取了開發效率上的優勢,並且由於C++編譯器所有優化都是在編譯期完成的,以運行期性能監控爲基礎的優化措施都無法進行,這也是Java編譯器獨有的優勢。

微信公衆號【Java技術江湖】一位阿里 Java 工程師的技術小站。(關注公衆號後回覆”Java“即可領取 Java基礎、進階、項目和架構師等免費學習資料,更有數據庫、分佈式、微服務等熱門技術學習視頻,內容豐富,兼顧原理和實踐,另外也將贈送作者原創的Java學習指南、Java程序員面試指南等乾貨資源)


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章