本文基于周志明的《深入理解java虛擬機 JVM高級特性與最佳實踐》所寫。特此推薦。
列舉了這3類編譯過程中一些比較有代表性的編譯器
- 前端編譯器:Sun的Javac、 Eclipse JDT中的增量式編譯器( ECJ ) 。
- JIT編譯器:HotSpotVM的C1、C2編譯器。
- AOT編譯器: GNU Compiler for the Java ( GCJ ) 、 Excelsior JET。
相當多新生的Java語法特性,都是靠編譯器的“語法糖”來實現,而不是依賴虛擬機的底層改進來支持,前端編譯器在編譯期的優化過程對于程序編碼來說關系更加密切。
Javac編譯器
Javac的源碼存放在JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac中, 除了JDK自身的API外 ,就只引用了JDK_SRC_HOME/langtools/src/share/classes/com/sun/*里面的代碼 ,調試環境建立起來簡單方便,因為基本上不需要處理依賴關系。
從Sun Javac的代碼來看,編譯過程大致可以分為3個過程,分別是:
- 解析與填充符號表的過程。
- 插入式注解處理器的注解處理過程。
- 分析與字節碼生成過程。
這3個步驟之間的關系與交互順序如下圖
Javac編譯動作的入口是com.sun.tools.javac.main.JavaCompiler類 ,上述3個過程的代碼邏輯集中在這個類的compile() 和compile2() 方法中,其中主體代碼如下圖所示,整個編譯最關鍵的處理就由圖中標注的8個方法來完成,下面我們具體看一下這8個方法實現了什么功能。
解析與填充符號表
解析步驟由圖10-5中的parseFiles()方法(圖10-5中的過程1.1 ) 完成,解析步驟包括了經典程序編譯原理中的詞法分析和語法分析兩個過程。
詞法、語法分析
詞法分析是將源代碼的字符流轉變為標記(Token)集合,單個字符是程序編寫過程的最小元素,而標記則是編譯過程的最小元素,關鍵字、變量名、字面量、運算符都可以成為標記,如“int a=b+2”這句代碼包含了6個標記,分別是int、a、=、b、+、2 ,雖然關鍵字int由 3個字符構成,但是它只是一個Token,不可再拆分。在Javac的源碼中,詞法分析過程由com.sun.tools.javac.parser.Scanner類來實現。
語法分析是根據Token序列構造抽象語法樹的過程,抽象語法樹( Abstract Syntax Tree,AST ) 是一種用來描述程序代碼語法結構的樹形表示方式,語法樹的每一個節點都代表著程序代碼中的一個語法結構( Construct ) ,例如包、類型、修飾符、運算符、接口、返回值甚至代碼注釋等都可以是一個語法結構。
在Javac的源碼中,語法分析過程由 com.sun.tools.javac.parser.Parser類實現,這個階段產出的抽象語法樹由com.sun.tools.javac.tree.JCTree類表示,經過這個步驟之后,編譯器就基本不會再對源碼文件進行操作了,后續的操作都建立在抽象語法樹之上。
填充符號表
完成了語法分析和此法分析后,下一步就是填充符號表的過程,也就是圖10-5中enterTrees()方法(圖10-5中的過程1.2)所做的事情。符號表(Symbol Table)是由一組符號地址和符號信息構成的表格,讀者可以把它想象成哈希表中K-V值對的形式(實際上符號表不一定是哈希表實現,可以是有序符號表、樹狀符號表、棧結構符號表等)。符號表中所登記的信息在編譯的不同階段都要用到。在語義分析中,符號表所登記的內容將用于語義檢查(如檢查一個名字的使用和原先的說明是否一致)和產生中間代碼。在目標生成階段,當對符號名進行地址分配時,符號表是地址分配的依據。
在Javac源代碼中,填充符號表的過程由com.sun.tools.javac.comp.Enter類實現,此過程的出口是一個待處理列表( To Do List ) ,包含了每一個編譯單元的抽象語法樹的頂級節點, 以及package-info.java ( 如果存在的話)的頂級節點。
注解處理器
在JDK 1.5之后,Java語言提供了對注解(Annotation ) 的支持,這些注解與普通的Java代碼一樣,是在運行期間發揮作用的。在JDK 1.6中實現了JSR-269規范 ,提供了一組插入式注解處理器的標準API在編譯期間對注解進行處理,我們可以把它看做是一組編譯器的插件 ,在這些插件里面,可以讀取、修改、添加抽象語法樹中的任意元素。如果這些插件在處理注解期間對語法樹進行了修改,編譯器將回到解析及填充符號表的過程重新處理,直到所有插入式注解處理器都沒有再對語法樹進行修改為止,每一次循環稱為一個Round,也就是圖10-4中的回環過程。
有了編譯器注解處理的標準API后 ,我們的代碼才有可能干涉編譯器的行為,由于語法樹中的任意元素,甚至包括代碼注釋都可以在插件之中訪問到,所以通過插入式注解處理器實現的插件在功能上有很大的發揮空間。只要有足夠的創意,程序員可以使用插入式注解處理器來實現許多原本只能在編碼中完成的事情。
在Javac源碼中,插入式注解處理器的初始化過程是在initPorcessAnnotations() 方法中完成的,而它的執行過程則是在processAnnotations() 方法中完成的,這個方法判斷是否還有新的注解處理器需要執行,如果有的話,通過com.sun.tools.javac.processing.JavacProcessingEnvironment類的doProcessing() 方法生成一個新的JavaCompiler對象對編譯的后續步驟進行處理。
語義分析與字節碼生成
語義分析的主要任務是對結構上正確的源程序(抽象語法樹)進行上下文有關性質的審查,如進行類型審查。
標注檢查
Javac的編譯過程中,語義分析過程分為標注檢查以及數據及控制流分析兩個步驟。分別由圖10-5中所示的attribute() 和flow() 方法(分別對應圖10-5中的過程3.1和過程3.2) 完成。
標注檢查步驟檢查的內容包括諸如變量使用前是否已被聲明、變量與賦值之間的數據類型是否能夠匹配等。在標注檢查步驟中,還有一個重要的動作稱為常量折疊,如果我們在代碼中寫了如下定義:
int a=1+2;
那么在語法樹上仍然能看到字面量“ 1”、“2”以及操作符“+”,但是在經過常量折疊之后 ,它們將會被折疊為字面量“3” ,這個插入式表達式( Mix Expression )的值已經在語法樹上標注出來了(ConstantExpressionValue : 3 ) 。 由于編譯期間進行了常量折疊 ,所以在代碼里面定義“a=1+2”比起直接定義“a=3” , 并不會增加程序運行期哪怕僅僅一個 CPU指令的運算量。
標注檢查步驟在Javac源碼中的實現類是com.sun.tools.javac.comp.Attr類和
com.sun.tools.javac.comp.Check類。
數據及控制流分析
數據及控制流分析是對程序上下文邏輯更進一步的驗證,它可以檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。
在Javac的源碼中,數據及控制流分析的入口是圖 10-5中的flow() 方法(對應圖10-5中的過程3.2) ,具體操作由com.sun.tools.javac.comp.Flow類來完成。
解語法糖
語法糖( Syntactic Sugar ) ,指在計算機語言中添加的某種語法,這種語法對語言的功能并沒有影響,但是更方便程序員使用。
Java中最常用的語法糖主要是前面提到過的泛型(泛型并不一定都是語法糖實現,如C#的泛型就是直接由CLR支持的 )、變長參數、自動裝箱/拆箱等,虛擬機運行時不支持這些語法 ,它們在編譯階段還原回簡單的基礎語法結構,這個過程稱為解語法糖。
在Javac的源碼中,解語法糖的過程由desugar() 方法觸發,在 com.sun.tools.javac.comp.TransTypes類和com.sun.tools.javac.comp.Lower類中完成。
字節碼生成
字節碼生成是Javac編譯過程的最后一個階段,在Javac源碼里面由com.sun.tools.javac.jvm.Gen類來完成。字節碼生成階段不僅僅是把前面各個步驟所生成的信息 (語法樹、符號表)轉化成字節碼寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作。
完成了對語法樹的遍歷和調整之后,就會把填充了所有所需信息的符號表交給 com.sun.tools.javac.jvm.ClassWriter類 ,由這個類的writeClass()方法輸出字節碼,生成最終的Class文件 ,到此為止整個編譯過程宣告結束。
Java語法糖的味道
泛型與類型擦除
泛型是JDK 1.5的一項新增特性,它的本質是參數化類型( Parametersized Type )的應用 ,也就是說所操作的數據類型被指定為一個參數。這種參數類型可以用在類、接口和方法的創建中,分別稱為泛型類、泛型接口和泛型方法。
java語言中的泛型只在程序源碼中存在,在編譯后的字節碼文件中,就已經替換為原來的原生類型( Raw Type,也稱為裸類型 )了,并且在相應的地方插入了強制轉型代碼,因此,對于運行期的Java語言來說,ArrayList<int>與ArrayList<String>就是同一個類,所以泛型技術實際上是Java語言的一顆語法糖,Java語言中的泛型實現方法稱為類型擦除 ,基于這種方法實現的泛型稱為偽泛型。
由于Java泛型的引入,各種場景(虛擬機解析、反射等)下的方法調用都可能對原有的基礎產生影響和新的需求,如在泛型類中如何獲取傳入的參數化類型等。因此 ,JCP組織對虛擬機規范做出了相應的修改,引入了諸如Signature、LocalVariableTypeTable等新的屬性用于解決伴隨泛型而來的參數類型的識別問題,Signature是其中最重要的一項屬性,它的作用就是存儲一個方法在字節碼層面的特征簽名,這個屬性中保存的參數類型并不是原生類型 ,而是包括了參數化類型的信息。修改后的虛擬機規范要求所有能識別49.0以上版本的 Class文件的虛擬機都要能正確地識別Signature參數。
另外 ,從Signature屬性的出現我們還可以得出結論,擦除法所謂的擦除,僅僅是對方法的Code屬性中的字節碼進行擦除,實際上元數據中還是保留了泛型信息,這也是我們能通過反射手段取得參數化類型的根本依據。
自動裝箱、拆箱與遍歷循環
泛型就不必說了,自動裝箱、拆箱在編譯之后被轉化成了對應的包裝和還原方法,如本例中的Integer.valueOf() 與Integer.intValue() 方法,而遍歷循環則把代碼還原成了迭代器的實現,這也是為何遍歷循環需要被遍歷的類實現Iterable接口的原因。最后再看看變長參數,它在調用的時候變成了一個數組類型的參數,在變長參數出現之前,程序員就是使用數組來完成類似功能的。
條件編譯
Java語言當然也可以進行條件編譯,方法就是使用條件為常量的if語句。只能使用條件為常量的if語句才能達到效果,如果使用常量與其他帶有條件判斷能力的語句搭配,則可能在控制流分析中提示錯誤,被拒絕編譯。
Java語言中條件編譯的實現,也是Java語言的一顆語法糖,根據布爾常量值的真假,編譯器將會把分支中不成立的代碼消除掉 ,這一工作將在編譯器解除語法糖階段
( com.sun.tools.javac.comp.Lower類中)完成。由于這種條件編譯的實現方式使用了if語句,所以它必須遵循最基本的Java語法 ,只能寫在方法體內部,因此它只能實現語句基本塊 ( Block)級別的條件編譯,而沒有辦法實現根據條件調整整個Java類的結構。
除了介紹的泛型、自動裝箱、自動拆箱、遍歷循環、變長參數和條件編譯之外 ,Java語言還有不少其他的語法糖,如內部類、枚舉類、斷言語句、對枚舉和字符串(在 JDK 1.7中支持)的switch支持、try語句中定義和關閉資源(在JDK 1.7中支持)等 。