深入理解JVM(九)——早期(編譯期)優化
從Sun Javac的程式碼來看,編譯過程大致分為3個過程,分別是:
- 解析與填充符號表過程
- 插入式註解處理器的註解處理過程
- 分析與位元組碼生成過程
解析與填充符號表過程
詞法,語法分析
詞法分析是將原始碼的字元流轉變成標記(Token)集合,單個字元是程式編寫過程的最小元素,而標記則是編譯過程的最小元素,關鍵字,變數名,字面量,運算子都可以成為標記,如int a=b+2這段程式碼包含6個標記int,a,=,b,+,2,雖然關鍵字int由3個字元構成,但是他只是一個Token,不可拆分。在Javac的原始碼裡,詞法分析由com.sun.tools.javac.parser.Scanner類來實現。
語法分析是根據Token序列構造抽象語法樹的過程。抽象語法樹是一種用來描述程式程式碼語法結構的樹形表示方式,語法樹的每一個節點都代表著程式程式碼中的一個語法結構(Construct),例如包,型別,修飾符,運算子,介面,返回值甚至程式碼註釋等都可以是一個語法結構。在Javac的原始碼裡,語法分析過程由com.sun.tools.javac.parser.Parser類來實現,這個階段產生的抽象語法樹由com.sun.tools.javac.tree.JCTree類表示,經過這個步驟之後,編譯器基本不會再對原始碼進行操作了,後續操作都是建立在抽象語法樹上。
填充符號表
完成詞法分析和語法分析之後,就是填充符號表的過程。符號表(Symbol Table)是一組符號地址和符號資訊構成的表格。符號表中所登記的資訊在編譯的不同階段都要用到。
如在語義分析過程中,符號表中所登記的內容將用於語義檢查和產生中間程式碼。在目的碼生成階段,當對符號表進行地址分配時,符號表時地址分配的依據。
在Javac的原始碼裡,填充符號表的過程是由com.sun.tools.javac.comp.Enter類來實現,此過程的出口是一個待處理列表(To Do List),包含了每一個編譯單元的抽象語法樹的頂級節點以及package-info.java的頂級節點。
註釋處理器
JDK1.5之後,Java語言提供了對註解(Annotation)的支援,這些註解和普通程式碼一樣,在執行期間發揮作用。
JDK1.6的規範中,提供了一組插入式註解處理器的標準API在編譯期間對註解進行處理,可以看做一組編譯器的外掛,在這些外掛裡面,可以讀取,修改,新增抽象語法樹中的任意元素。如果這些外掛在處理註解期間對語法樹進行了修改,編譯器將回到解析及填充符號表的過程重新處理,直到所有插入式註解處理器都沒有再對語法樹進行修改為止,每次迴圈稱為一個Round。
在javac的原始碼中,插入式註解處理器的初始化過程是在initProcessAnnotations()方法中完成的,執行過程則是在processAnnotations()方法中完成的,這個方法判斷是否還有新的註解處理器需要執行,如果有通過com.sun.tools.javac.processing.JavacProcessingEnvironment類的doProcessing()方法生成一個新的JavaCompiler物件對編譯的後續步驟進行處理。
語義分析和位元組碼生成
語法分析後,編譯器獲得了程式程式碼的抽象語法樹表示。語法樹能表示一個結構正確的原始碼抽象,但無法保證原始碼時符合邏輯的。而語義分析則是對結構正確的原始碼進行上下文有關性質的審查,如進行型別審查等。
Javac的編譯過程,語義分析過程分為標註檢查以及資料及控制流分析兩個步驟,分別由attribute()和flow()方法完成。
標註檢查
標準檢查的步驟檢查的內容包括如變數使用前是否已經被申明,變數與賦值之間的資料型別是否能夠匹配等。在標註檢查過程中還有一個主要的動作叫常量摺疊。
如int a = 1+2;
那麼在語法樹上看到字面量1,2,已經操作符+,但是經過常量摺疊後,它們會摺疊成常量3。由於編譯期間進行了常量摺疊,所以程式碼裡面定義a=3並不會增加執行期哪怕一個CPU指令運算子。
在javac的原始碼中,標註檢查的實現類是com.sun.tools.javac.comp.Attr和com.sun.tools.javac.comp.Check
資料及控制流分析
資料及控制流分析對程式的上下文邏輯進行更進一步的驗證,如程式區域性變數在使用前是否有賦值,方法的每天路徑是否有返回值,是否所有的受查異常都被正確處理了等。
在javac的原始碼中,資料及控制流分析的入口是flow()方法,實現類是com.sun.tools.javac.comp.Flow類。
解語法糖
指在計算機語言中新增的某種語法,這種語法對語言功能並沒有影響,但是更方便程式設計師使用。通常使用語法糖增加程式的可讀性,從而減少程式程式碼出錯的機會。
Java中最常用的語法糖主要是泛型,變長引數,自動裝箱/拆箱等,虛擬機器執行時不支援這些語法,它們在編譯階段還原成簡單的基礎語法結構。,這個過程稱為解語法糖。
在javac的原始碼中,解語法糖的過程由desugar()方法觸發,實現類是com.sun.tools.javac.comp.TransTypes類和com.sun.tools.javac.comp.Lower類。
位元組碼生成
位元組碼是Java編譯過程的最後一個階段,在javac的原始碼中,由com.sun.tools.javac.jvm.Gen類來實現。位元組碼生成階段不僅把前面各個步驟的生成資訊(語法樹,符號表)轉化成位元組碼寫到磁碟中,編譯器還進行了少量的程式碼新增和轉換工作。
如例項構造器< init>()方法,類構造器< clinit>()方法就是在這個階段新增到語法樹中的。(這裡的例項構造器並非預設建構函式,如果程式程式碼中沒有提供建構函式,則編譯器會新增一個沒有引數的,訪問性與當前類一致的預設構造器,這個過程在填充符號表階段就已經完成了)。
這兩個構造器的產生過程實際上是一個程式碼收斂的過程,編譯器會把語句塊(對於例項構造器而言是{}塊,對於類構造器而言是static{}塊),變數初始化(例項變數和類變數),呼叫父類的例項構造器(僅僅是例項構造器,< clinit>()方法中無須呼叫父類的< clinit>()方法,虛擬機器會保證父類構造器的執行,但在< clinit>()方法中經常會生成呼叫java.lang.Object的< init>()方法的程式碼)等操作收斂到< init>()方法和< clinit>()方法之中,並且保證一定是按先執行父類的例項構造器,然後初始化變數,最後執行語句塊的順序。上述動作由由Gen.normalizeDefs()方法來實現。
除了生成構造器外還有一些程式碼替換的工作用於優化程式的實現邏輯,如把字串的加操作替換成StringBuffer或StringBuilder的append操作。
完成對語法樹的遍歷和調整之後,就會填充所有需要資訊的符號交給com.sun.tools.javac.jvm.ClassWriter類
泛型與型別擦除
本質是引數化型別的應用,將所操作的資料型別指定為一個引數,這種引數可以在類,介面和方法中建立,稱為泛型類,泛型介面,泛型方法。
Java中的泛型和C#不同,它只存在與程式原始碼中。在編譯後的位元組碼裡面就已經替換成了原來的原生型別,並且在相應的地方插入了強制轉換的程式碼,對Java而言,ArrayList< int>和ArrayList< String>就是同一類,泛型實際上是Java的一顆語法糖,Java中泛型實現稱為型別擦除,即偽泛型。