深入理解JVM讀書筆記四: (早期)編譯器優化

衣舞晨風發表於2016-10-19

10.1概述

Java 語言的 “編譯期” 其實是一段 “不確定” 的操作過程,因為它可能是指一個前端編譯器(其實叫 “編譯器的前端” 更準確一些)把 .java 檔案轉變成 .class 檔案的過程;也可能是指虛擬機器的後端執行期編譯器(JIT 編譯器,Just In Time Compiler)把位元組碼轉變成機器碼的過程;還可能是指使用靜態提前編譯器(AOT 編譯器,Ahead Of Time Compiler)直接把 *.java 檔案編譯成本地機器程式碼的過程。下面列舉了這 3 類編譯過程中一些比較有代表性的編譯器。

  • 前端編譯器:Sun 的 Java、Eclipse JDT 中的增量式編譯器(ECJ)。
  • JIT 編譯器:HotSpot VM 的 C1、C2 編譯器。
  • AOT 編譯器:GNU Compiler for the Java (GCJ)、Excelsior JET。

Javac 做了許多針對 Java 語言編碼過程的優化措施來改善程式設計師的編碼風格和提高編碼效率。相當多新生的 Java 語法特性,都是靠編譯器的 “語法糖” 來實現,而不是依賴虛擬機器的底層改進來支援,可以說,Java 中即時編譯器在執行期的優化過程對於程式執行來說更重要,而前端編譯器在編譯期的優化過程對於程式編碼來說關係更加密切。

10.2 javac編譯器

10.2.1Javac 的原始碼與除錯

虛擬機器規範嚴格定義了 Class 檔案的格式,但是《Java 虛擬機器規範(第 2 版)》中,雖然有專門的一章 “Compiling for the Java Virtual Machine”,但都是以舉例的形式描述,並沒有對如何把 Java 原始碼檔案轉變為 Class 檔案的編譯過程進行十分嚴格的定義,這導致 Class 檔案編譯在某種程度上是與具體 JDK 實現相關的,在一些極端情況,可能出現一段程式碼 Javac 編譯器可以編譯,但是 ECJ 編譯器就不可以編譯的問題。從 Sun Javac 的程式碼來看,編譯過程大致可以分為 3 個過程,分別是:

  • 解析與填充符號表過程
  • 插入式註解處理器的註解處理過程
  • 分析與位元組碼生成過程

這裡寫圖片描述

Javac 編譯動作的入口是 com.sun.tools.javac.main.JavaCompiler 類,上述 3 個過程的程式碼邏輯集中在這個類的 compile() 和 compile2() 方法中,其中主體程式碼如圖 10-5 所示,整個編譯最關鍵的處理就由圖中標註的 8 個方法來完成,下面我們具體看一下這 8 個方法實現了什麼功能。

這裡寫圖片描述

10.2.2解析與填充符號表

解析步驟由圖 10-5 中的 parseFiles() 方法(圖 10-5 中的過程 1.1)完成,解析步驟包括了經典程式編譯原理中的詞法分析語法分析兩個過程。

1、詞法、語法分析
詞法分析是將原始碼的字元流轉變為標記(Token)集合,單個字元是程式編寫過程的最小元素,而標記則是編譯過程的最小元素,關鍵字、變數名、字面量、運算子都可以成為標記,如 “int a=b+2” 這句程式碼包含了 6 個標記,分別是 int、a、=、b、+、2,雖然關鍵字 int 由 3 個字元構成,但是它只是一個 Token,不可再拆分。在 Javac 的原始碼中,詞法分析過程由com.sun.tools.javac.parser.Scanner 類來實現。

2、填充符號表

完成了語法分析和詞法分析之後,下一步就是填充符號表的過程,也就是圖 10-5 中 enterTrees() 方法(圖 10-5 中的過程 1.2)所做的事情。符號表(Symbol Table)是由一組符號地址和符號資訊構成的表格,讀者可以把它想象成雜湊表中 K-V 值對的形式(實際上符號表不一定是雜湊表實現,可以是有序符號表、樹狀符號表、棧結構符號表等)。符號表中所登記的資訊在編譯的不同階段都要用到。在語義分析中,符號表所登記的內容將用於語義檢查(如檢查一個名字的使用和原先的說明是否一致)和產生中間程式碼。在目的碼生成階段,當對符號名進行地址分配時,符號表是地址分配的依據。

10.2.3 註解處理器

在 JDK 1.5 之後,Java 語言提供了對註解(Annotation)的支援,這些註解與普通 Java 程式碼一樣,是在執行期間發揮作用的。在 JDK 1.6 中實現了 JSR-269 規範(JSR-269:Pluggable Annotations Processing API(插入式註解處理 API)),提供了一組插入式註解處理器的標準 API 在編譯期間對註解進行處理,我們可以把它看做是一組編譯器的外掛,在這些外掛裡面,可以讀取、修改、新增抽象語法樹中的任意元素。如果這些外掛在處理註解期間對語法樹進行了修改,編譯器將回到解析及填充符號表的過程重新處理,知道所有插入式註解處理器都沒有再對語法樹進行修改為止,每一次迴圈稱為一個 Round,也就是圖 10-4 中的迴環過程。

有了編譯器註解處理的標準 API 後,我們的程式碼才有可能干涉編譯器的行為,由於語法樹中的任意元素,甚至包括程式碼註釋都可以在外掛之中訪問到,所以通過插入式註解處理器實現的外掛在功能上有很大的發揮空間。只要有足夠的創意,程式設計師可以使用插入式註解處理器來實現許多原本只能在編碼中完成的事情。

在 Javac 原始碼中,插入式註解處理器的初始化過程是在 initProcessAnnotations() 方法中完成的,而它的執行過程則是在 processAnnotations() 方法中完成的,這個方法判斷是否還有新的註解處理器需要執行,如果有的話,通過 com.sun.tools.javac.processing.JavacProcessingEnvironment 類的 doProcessing() 方法生成一個新的 JavaCompiler 物件對編譯的後續步驟進行處理。

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

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

int a = 1;  
boolean b = false;  
char c = 2;  

後續可能出現的賦值運算:

int d = a + c;  
int d = b + c;  
char d = a + c;  

後續程式碼中如果出現瞭如上 3 種賦值運算的話,那它們都能構成結構正確的語法樹,但是隻有第 1 種的寫法在語義上是沒有問題的,能夠通過編譯,其餘兩種在 Java 語言中是不合邏輯的,無法編譯(是否合乎語義邏輯必須限定在語言與具體的上下文環境之中才有意義。如在 C 語言中,a、b、c 的上下文定義不變,第 2、3 種寫法都是可以正確編譯)。

1、標註檢查
Javac 的編譯過程中,語義分析過程分為標註檢查以及資料及控制流分析兩個步驟。

標註檢查步驟檢查的內容包括諸如變數使用前是否已被宣告、變數與賦值之間的資料型別是否能夠匹配等。在標註檢查步驟中,還有一個重要的動作稱為常量摺疊,如果我們在程式碼中寫了如下定義。

int a = 1 + 2; 

那麼在語法樹上仍然能看到字面量 “1”、“2” 以及操作符 “+”,但是在經過常量摺疊之後,它們將會被摺疊為字面量 “3”,如圖 10-7 所示,這個插入式表示式(Infix Expression)的值已經在語法樹上標註出來了(ConstantExpressionValue:3)。由於編譯期間進行了常量摺疊,所以在程式碼裡面定義 “a=1+2” 比起直接定義 “a=3”,並不會增加程式執行期哪怕僅僅一個 CPU 指令的運算量。

2、資料及控制流分析

資料及控制流分析是對程式上下文邏輯更進一步的驗證,它可以檢測出諸如程式區域性變數是在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。編譯時期的資料及控制流分析與類載入時資料及控制流分析的目的基本上是一致的,但校驗範圍有所區別,有一些校驗只有在編譯期或執行期才能進行。下面舉一個關於 final 修飾符的資料及控制流分析的例子,見程式碼清單 10-1。

// 方法一帶有 final 修飾  
public void foo(final int arg) {  
    final int var = 0;  
    // do something  
}  

// 方法而沒有 final 修飾  
public void foo(int arg) {  
    int var = 0;  
    // do something  
}  

在這兩個 foo() 方法中,第一種方法的引數和區域性變數定義使用了 final 修飾符,而第二種方法則沒有,在程式碼編寫時程式肯定會受到 final 修飾符的影響,不能再改吧 arg 和 var 變數的值,但是這兩段程式碼編譯出來的 Class 檔案是沒有任何一點區別的,通過第 6 章的講解我們已經知道,區域性變數與欄位(例項變數、類變數)是有區別的,它在常量池中沒有 CONSTANT_Fieldref_info 的符號引用,自然就沒有訪問標誌(Access_Flags)的資訊,甚至可能連名稱都不會保留下來(取決於編譯時的選項),自然在 Class 檔案中不可能知道一個區域性變數是不是宣告為 final 了。因此,將區域性變數宣告為 final,對執行期是沒有影響的,變數的不變性僅僅由編譯器在編譯期間保障。

3、 解語法糖
語法糖(Syntactic Sugar),也稱糖衣語法,是由英國電腦科學家彼得·約翰·蘭達(Perter J.Landin)發明的一個術語,指在計算機語言中新增的某種語法,這種語法對語言的功能並沒有影響,但是更方便程式設計師使用。通常來說,使用語法糖能夠增加程式的可讀性,從而減少程式程式碼出錯的機會。

4、位元組碼生成

位元組碼生成是 Javac 編譯過程的最後一個階段,在 Javac 原始碼裡面由com.sun.tools.javac.jvm.Gen 類來完成。位元組碼生成階段不僅僅是把前面各個步驟所生成的資訊(語法樹、符號表)轉化成位元組碼寫到磁碟中,編譯器還進行了少量的程式碼新增和轉換工作。

如果使用者程式碼中沒有提供任何建構函式,那編譯器將會新增一個沒有引數的、訪問性(public、protected 或 private)與當前類一直的預設建構函式,這個工作在填充符號表階段就已經完成)。除了生成構造器以外,還有其他的一些程式碼替換工作用於優化程式的實現邏輯,如把字串的加操作替換為 StringBuffer 或 StringBuilder(取決於目的碼的版本是否大於或等於 JDK 1.5)的 append() 操作等。

10.3語法糖的味道

10.3.1泛型與型別擦除

泛型技術在 C# 和 Java之中的使用方式看似相同,但實現上卻有著根本性的分歧,C# 裡面泛型無論是在程式原始碼中、編譯後的 IL 中(Intermediate Language,中間語言,這時候泛型是一個佔位符),或是執行期的 CLR 中,都是切實存在的,List 與 List 就是兩個不同的型別,它們在系統執行期生成,有自己的虛方法表和型別資料,這種實現稱為型別膨脹,基於這種方法實現的泛型稱為真實泛型。

Java 語言中的泛型則不一樣,它只在程式原始碼中存在,在編譯後的位元組碼檔案中,就已經替換為原來的原生型別(Raw Type,也稱為裸型別)了,並且在相應的地方插入了強制型別程式碼,因此,對於執行期的 Java 語言來說,ArrayList 與 ArrayList 就是同一個類,所以泛型技術實際上是 Java 語言的一顆語法糖,Java 語言中的泛型實現方法稱為型別擦除,基於這種方法實現的泛型稱為偽泛型。

程式碼清單 10-2 是一段簡單的 Java 泛型的例子,我們可以看一下它編譯後的結果是怎樣的。

程式碼清單 10-2 泛型擦除前的例子

public static void main(String[] args) {  
    Map<String, String> map = new HashMap<String, String>();  
    map.put("hello", "你好");  
    map.put("how are you?", "吃了沒?");  
    System.out.println(map.get("hello"));  
    System.out.println(map.get("how are you?"));  
}  

把這段 Java 程式碼編譯成 Class 檔案,然後再用位元組碼反編譯工具進行反編譯後,將會發現泛型都不見了(用jd-gui 檢視發現宣告的時候泛型還在,其他地方就變成了強制型別轉換),程式又變回了 Java 泛型出現之前的寫法,泛型型別都變回了原生型別,如程式碼清單 10-3 所示。

程式碼清單 10-3 泛型擦除後的例子
這裡寫圖片描述

當泛型遇見過載
1、當泛型遇見過載 1

public class GenericTypes {  

    public static void method(List<String> list) {  
        System.out.println("invoke method(List<String> list)");  
    }  

    public static void method(List<Integer> list) {  
        System.out.println("invoke method(List<Integer> list)");  
    }  
}  

請想一想,上面這段程式碼是否正確,能否編譯執行?也許你已經有了答案,這段程式碼是不能被編譯的,因為引數 List 和 List 編譯之後都被擦除了,變成了一樣的原生型別 List,擦除動作導致這兩種方法的特徵簽名變得一模一樣。初步看來,無法過載的原因已經找到了,但真的就是如此嗎?只能說,泛型擦除成相同的原生型別只是無法過載的其中一部分原因,請再接著看一看程式碼清單 10-5 中的內容。

public class GenericTypes {  

    public static String method(List<String> list) {  
        System.out.println("invoke method(List<String> list)");  
        return "";  
    }  

    public static int method(List<Integer> list) {  
        System.out.println("invoke method(List<Integer> list)");  
        return 1;  
    }  

    public static void main(String[] args) {  
        method(new ArrayList<String>());  
        method(new ArrayList<Integer>());  
    }  

}  

執行結果:

invoke method(List<String> list)  
invoke method(List<Integer> list)  

程式碼清單 10-5 與程式碼清單 10-4 的差別是兩個 method 方法新增了不同的返回值,由於這兩個返回值的加入,方法過載居然成功了,即這段程式碼可以被編譯和執行(注:測試的時候請使用 Sun JDK 1.6(1.7 和 1.8 也無法進行編譯) 進行編譯,其他編譯器,如 Eclipse JDT 的 ECJ 編譯器,仍然可能會拒絕這段程式碼)了。這是對 Java 語言中返回值不參與過載選擇的基本認知的挑戰嗎?

程式碼清單 10-5 中的過載當然不是根據返回值來確定的,之所以這次能編譯和執行成功,是因為兩個 method() 方法加入了不同的返回值後才能共存在一個 Class 檔案之中。前面介紹 Class 檔案方法表(method_info)的資料結構時曾經提到過,方法過載要求方法具備不同的特徵簽名,返回值並不包含在方法的特徵簽名之中,所以返回值不參與過載選擇,但是在 Class 檔案格式之中,只要描述符不是完全一致的兩個方法就可以共存。也就是說,兩個方法如果有相同的名稱和特徵簽名,但返回值不同,那它們也是可以合法地共存於一個 Class 檔案中的。

10.3.2自動裝箱、拆箱與遍歷迴圈 略

10.3.3條件編譯 略

個人感覺C#在語言層面上比Java優雅太多太多。

深入理解Java虛擬機器——JVM高階特性與最佳實踐(第2版)PDF版下載:
http://download.csdn.net/detail/xunzaosiyecao/9648998

作者:jiankunking 出處:http://blog.csdn.net/jiankunking

相關文章