Java語言的"編譯期"其實是一段"不確定"的操作過程,因為它可能是指一個前端編譯器(其實叫"編譯器的前端"更准確一些)把*.java文件轉變成*.class文件的過程;也可能是指虛擬機的后端運行期編譯器(JIT編譯器,Just In Time Compiler )把字節碼轉變成機器碼的過程 ;還可能是指使用靜態提前編譯器(AOT編譯器,Ahead Of Time Compiler ) 直接把*.java 文件編譯成本地機器代碼的過程。下面列舉了這3類編譯過程中一些比較有代表性的編譯器。
- 前端編譯器:Sun的Javac、 Eclipse JDT中的增量式編譯器( ECJ ) 。
- JIT編譯器:HotSpotVM的C1、C2編譯器。
- AOT編譯器: GNU Compiler for the Java ( GCJ ) 、 Excelsior JET。
這3類過程中最符合大家對Java程序編譯認知的應該是第一類,在本章的后續文字里, 筆者提到的"編譯期"和"編譯器"都僅限於第一類編譯過程,把第二類編譯過程留到下一章中討論。限制了編譯范圍后,我們對於"優化"二字的定義就需要寬松一些,因為Javac這類編譯器對代碼的運行效率幾乎沒有任何優化措施(在JDK 1.3之 后 ,Javac的-O 優化參數就不再有意 義 )。虛擬機設計團隊把對性能的優化集中到了后端的即時編譯器中,這樣可以讓那些不是由Javac產生的Class文件 (如JRuby、Groovy等語言的Class文件 )也同樣能享受到編譯器優化所帶來的好處。但是Javac做了許多針對Java語言編碼過程的優化措施來改善程序員的編碼風格和提高編碼效率。相當多新生的Java語法特性,都是靠編譯器的"語法糖"來實現,而不是依賴虛擬機的底層改進來支持,可以說,Java中即時編譯器在運行期的優化過程對於程序運行來說更重要,而前端編譯器在編譯期的優化過程對於程序編碼來說關系更加密切。
從Sun Javac的代碼來看,編譯過程大致可以分為3個過程,分別是:
- 解析與填充符號表的過程。
- 插入式注解處理器的注解處理過程。
- 分析與字節碼生成過程。
這3個步驟之間的關系與交互順序如圖所示。

編譯過程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 ) ,例如包、類型、修飾符、運算符、接口、返回值甚至代碼注釋等都可以是一個語法結構。
2.填充符號表
完成了語法分析和此法分析后,下一步就是填充符號表的過程,符號表(Symbol Table)是由一組符號地址和符號信息構成的表格,讀者可以把它想象成哈希表中K-V值對的形式(實際上符號表不一定是哈希表實現,可以是有序符號表、樹狀符號表、棧結構符號表等)。符號表中所登記的信息在編譯的不同階段都要用到。在語義分析中,符號表所登記的內容將用於語義檢查(如檢查一個名字的使用和原先的說明是否一致)和產生中間代碼。在目標生成階段,當對符號名進行地址分配時,符號表是地址分配的依據。
在Javac源代碼中,填充符號表的過程由com.sun.tools.javac.comp.Enter類實現,此過程的出口是一個待處理列表( To Do List ) ,包含了每一個編譯單元的抽象語法樹的頂級節點, 以及package-info.java ( 如果存在的話)的頂級節點。
編譯過程2:注解處理器
在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對象對編譯的后續步驟進行處理。
編譯過程3:語義分析與字節碼生成
語法分析之后,編譯器獲得了程序代碼的抽象語法樹表示,語法樹能表示一個結構正確的源程序的抽象,但無法保證源程序是符合邏輯的。而語義分析的主要任務是對結構上正確的源程序進行上下文有關性質的審查,如進行類型審查。舉個例子,假設有如下的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" 。 由於編譯期間進行了常量折疊 ,所以在代碼里面定義"a=1+2"比起直接定義"a=3" , 並不會增加程序運行期哪怕僅僅一個 CPU指令的運算量。
2.數據及控制流分析
數據及控制流分析是對程序上下文邏輯更進一步的驗證,它可以檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。編譯時期的數據及控制流分析與類加載時的數據及控制流分析的目的基本上是一致的,但校驗范圍有所區別,有一些校驗項只有在編譯期或運行期才能進行。
3.解語法糖
語法糖( Syntactic Sugar ) , 也稱糖衣語法,是由英國計算機科學家彼得•約翰•蘭達 ( Peter J.Landin)發明的一個術語,指在計算機語言中添加的某種語法,這種語法對語言的功能並沒有影響,但是更方便程序員使用。通常來說,使用語法糖能夠增加程序的可讀性, 從而減少程序代碼出錯的機會。
Java在現代編程語言之中屬於"低糖語言" (相對於C#及許多其他JVM語言來說),尤其是JDK 1.5之前的版本,"低糖"語法也是Java語言被懷疑已經"落后"的一個表面理由。Java中最常用的語法糖主要是前面提到過的泛型(泛型並不一定都是語法糖實現,如C#的泛型就是直接由CLR支持的 )、變長參數、自動裝箱/拆箱等,虛擬機運行時不支持這些語法 ,它們在編譯階段還原回簡單的基礎語法結構,這個過程稱為解語法糖。
在Javac的源碼中,解語法糖的過程由desugar() 方法觸發,在 com.sun.tools.javac.comp.TransTypes類和com.sun.tools.javac.comp.Lower類中完成。
4.字節碼生成
字節碼生成是Javac編譯過程的最后一個階段,在Javac源碼里面由com.sun.tools.javac.jvm.Gen類來完成。字節碼生成階段不僅僅是把前面各個步驟所生成的信息 (語法樹、符號表)轉化成字節碼寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作。
例如,前面章節中多次提到的實例構造器<init>() 方法和類構造器<clinit> ()方法就是在這個階段添加到語法樹之中的( 注意 ,這里的實例構造器並不是指默認構造函數, 如果用戶代碼中沒有提供任何構造函數,那編譯器將會添加一個沒有參數的、訪問性( public、 protected或private ) 與當前類一致的默認構造函數,這個工作在填充符號表階段就已經完成 ),這兩個構造器的產生過程實際上是一個代碼收斂的過程,編譯器會把語句塊( 對於實例構造器而言是"{}"塊 ,對於類構造器而言是"static{}"塊 )、變量初始化(實例變量和類變量)、調用父類的實例構造器 ( 僅僅是實例構造器,<clinit>()方法中無須調用父類的<clinit>() 方法,虛擬機會自動保證父類構造器的執行,但在<clinit>() 方法中經常會生成調用java.lang.Object的<init>() 方法的代碼 ) 等操作收斂到<init>() 和<clinit>() 方法之中,並且保證一定是按先執行父類的實例構造器,然后初始化變量,最后執行語句塊的順序進行,上面所述的動作由Gen.normalizeDefs() 方法來實現。除了生成構造器以外,還有其他的一些代碼替換工作用於優化程序的實現邏輯,如把字符串的加操作替換為StringBuffer或StringBuilder ( 取決於目標代碼的版本是否大於或等於JDK 1.5 )的append() 操作等。
完成了對語法樹的遍歷和調整之后,就會把填充了所有所需信息的符號表交給 com.sun.tools.javac.jvm.ClassWriter類 ,由這個類的writeClass()方法輸出字節碼,生成最終的Class文件 ,到此為止整個編譯過程宣告結束。
