從Sun Javac的代碼來看,編譯過程大致可以分為3個過程:
- 解析與填充符號表過程。
- 插入式注解處理器的注解處理過程。
- 分析與字節碼生成過程。
Javac編譯動作的入口是com.sun.tools.javac.main.JavaCompiler類,上述3個過程的代碼邏輯集中在這個類的compile()和compile2()方法中,整個編譯最關鍵的處理就由圖中標注的8個方法來完成,下面我們具體看一下這8個方法實現了什么功能。
解析與填充符號表
解析步驟由上圖中的parseFiles()方法(過程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類表示,經過這個步驟之后,編譯器就基本不會再對源碼文件進行操作了,后續的操作都建立在抽象語法樹之上。
填充符號表
完成了語法分析和詞法分析之后,下一步就是填充符號表的過程,也就是enterTrees()方法(過程1.2)所做的事情。符號表(Symbol Table)是由一組符號地址和符號信息構成的表格,可以把它想象成哈希表中K-V值對的形式(實際上符號表不一定是哈希表實現,可以是有序符號表、樹狀符號表、棧結構符號表等)。符號表中所登記的信息在編譯的不同階段都要用到。在語義分析中,符號表所登記的內容將用於語義檢查(如檢查一個名字的使用和原先的說明是否一致)和產生中間代碼。在目標代碼生成階段,當對符號名進行地址分配時,符號表是地址分配的依據。
在Javac源代碼中,填充符號表的過程由com.sun.tools.javac.comp.Enter類實現,此過程的出口是一個待處理列表(To Do List),包含了每一個編譯單元的抽象語法樹的頂級節點,以及package-info.java(如果存在的話)的頂級節點。
注解處理器
在Javac源碼中,插入式注解處理器的初始化過程是在initPorcessAnnotations()方法中完成的,而它的執行過程則是在processAnnotations()方法中完成的,這個方法判斷是否還有新的注解處理器需要執行,如果有的話,通過com.sun.tools.javac.processing.JavacProcessingEnvironment類的doProcessing()方法生成一個新的JavaCompiler對象對編譯的后續步驟進行處理。
在JDK 1.5之后,Java語言提供了對注解(Annotation)的支持,這些注解與普通的Java代碼一樣,是在運行期間發揮作用的。在JDK 1.6中實現了JSR-269規范JSR-269:Pluggable Annotations Processing API(插入式注解處理API)。提供了一組插入式注解處理器的標准API在編譯期間對注解進行處理,我們可以把它看做是一組編譯器的插件,在這些插件里面,可以讀取、修改、添加抽象語法樹中的任意元素。如果這些插件在處理注解期間對語法樹進行了修改,編譯器將回到解析及填充符號表的過程重新處理,直到所有插入式注解處理器都沒有再對語法樹進行修改為止,每一次循環稱為一個Round,也就是第一張圖中的回環過程。 有了編譯器注解處理的標准API后,我們的代碼才有可能干涉編譯器的行為,由於語法樹中的任意元素,甚至包括代碼注釋都可以在插件之中訪問到,所以通過插入式注解處理器實現的插件在功能上有很大的發揮空間。只要有足夠的創意,程序員可以使用插入式注解處理器來實現許多原本只能在編碼中完成的事情。
我們知道編譯器在把Java程序源碼編譯為字節碼的時候,會對Java程序源碼做各方面的檢查校驗。這些校驗主要以程序“寫得對不對”為出發點,雖然也有各種WARNING的信息,但總體來講還是較少去校驗程序“寫得好不好”。有鑒於此,業界出現了許多針對程序“寫得好不好”的輔助校驗工具,如CheckStyle、FindBug、Klocwork等。這些代碼校驗工具有一些是基於Java的源碼進行校驗,還有一些是通過掃描字節碼來完成。我們將會使用注解處理器API來編寫一款擁有自己編碼風格的校驗工具:NameCheckProcessor。
代碼實現
要通過注解處理器API實現一個編譯器插件,首先需要了解這組API的一些基本知識。我們實現注解處理器的代碼需要繼承抽象類javax.annotation.processing.AbstractProcessor,這個抽象類中只有一個必須覆蓋的abstract方法:“process()”,它是Javac編譯器在執行注解處理器代碼時要調用的過程,我們可以從這個方法的第一個參數“annotations”中獲取到此注解處理器所要處理的注解集合,從第二個參數“roundEnv”中訪問到當前這個Round中的語法樹節點,每個語法樹節點在這里表示為一個Element。在JDK 1.6新增的javax.lang.model包中定義了16類Element,包括了Java代碼中最常用的元素,如:“包(PACKAGE)、枚舉(ENUM)、類(CLASS)、注解(ANNOTATION_TYPE)、接口(INTERFACE)、枚舉值(ENUM_CONSTANT)、字段(FIELD)、參數(PARAMETER)、本地變量(LOCAL_VARIABLE)、異常(EXCEPTION_PARAMETER)、方法(METHOD)、構造函數(CONSTRUCTOR)、靜態語句塊(STATIC_INIT,即static{}塊)、實例語句塊(INSTANCE_INIT,即{}塊)、參數化類型(TYPE_PARAMETER,既泛型尖括號內的類型)和未定義的其他語法樹節點(OTHER)”。除了process()方法的傳入參數之外,還有一個很常用的實例變量“processingEnv”,它是AbstractProcessor中的一個protected變量,在注解處理器初始化的時候(init()方法執行的時候)創建,繼承了AbstractProcessor的注解處理器代碼可以直接訪問到它。它代表了注解處理器框架提供的一個上下文環境,要創建新的代碼、向編譯器輸出信息、獲取其他工具類等都需要用到這個實例變量。注解處理器除了process()方法及其參數之外,還有兩個可以配合使用的Annotations:@SupportedAnnotationTypes和@SupportedSourceVersion,前者代表了這個注解處理器對哪些注解感興趣,可以使用星號“*”作為通配符代表對所有的注解都感興趣,后者指出這個注解處理器可以處理哪些版本的Java代碼。
每一個注解處理器在運行的時候都是單例的,如果不需要改變或生成語法樹的內容,process()方法就可以返回一個值為false的布爾值,通知編譯器這個Round中的代碼未發生變化,無須構造新的JavaCompiler實例,在這次實戰的注解處理器中只對程序命名進行檢查,不需要改變語法樹的內容,因此process()方法的返回值都是false。
import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.Element; import javax.lang.model.element.TypeElement; import java.util.Set; //可以用"*"表示支持所有Annotations @SupportedAnnotationTypes("*") //只支持JDK 1.6的Java代碼 @SupportedSourceVersion(SourceVersion.RELEASE_6) public class NameCheckProcessor extends AbstractProcessor { private NameChecker nameChecker; /** * 初始化名稱檢查插件 */ @Override public void init(ProcessingEnvironment processingEnv){ super.init(processingEnv); nameChecker = new NameChecker(processingEnv); } /** * 對輸入的語法樹的各個節點進行名稱檢查 */ @Override public boolean process(Set<? extends TypeElement> annotations,RoundEnvironment roundEnv){ if (!roundEnv.processingOver()) { for (Element element:roundEnv.getRootElements()) nameChecker.checkNames(element); } return false; } } import javax.annotation.processing.Messager; import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.*; import javax.lang.model.util.ElementScanner6; import javax.tools.Diagnostic; import java.util.EnumSet; public class NameChecker { private final Messager messager; NameCheckScanner nameCheckScanner = new NameCheckScanner(); NameChecker(ProcessingEnvironment processsingEnv) { this.messager = processsingEnv.getMessager(); } /** * 對Java程序命名進行檢查,根據《Java語言規范(第3版)》第6.8節的要求,Java程序命名應當符合下列格式: * <p/> * <ul> * <li>類或接口:符合駝式命名法,首字母大寫。 * <li>方法:符合駝式命名法,首字母小寫。 * <li>字段: * <ul> * <li>類、實例變量:符合駝式命名法,首字母小寫。 * <li>常量:要求全部大寫。 * </ul> * </ul> */ public void checkNames(Element element) { nameCheckScanner.scan(element); } /** * 名稱檢查器實現類,繼承了JDK 1.6中新提供的ElementScanner6<br> * 將會以Visitor模式訪問抽象語法樹中的元素 */ private class NameCheckScanner extends ElementScanner6<Void, Void> { /** * 此方法用於檢查Java類 */ @Override public Void visitType(TypeElement e, Void p) { scan(e.getTypeParameters(), p); checkCamelCase(e, true); super.visitType(e, p); return null; } /** * 檢查方法命名是否合法 */ @Override public Void visitExecutable(ExecutableElement e, Void p) { if (e.getKind() == ElementKind.METHOD) { Name name = e.getSimpleName(); if (name.contentEquals(e.getEnclosingElement().getSimpleName())) messager.printMessage(Diagnostic.Kind.WARNING, "一個普通方法'" + name + "'不應當與類名重復,避免與構造函數產生混淆", e); checkCamelCase(e, false); } super.visitExecutable(e, p); return null; } /** * 檢查變量命名是否合法 */ @Override public Void visitVariable(VariableElement e, Void p) { //如果這個Variable是枚舉或常量,則按大寫命名檢查,否則按照駝式命名法規則檢查 if (e.getKind() == ElementKind.ENUM_CONSTANT || e.getConstantValue() != null || heuristicallyConstant(e)) checkAllCaps(e); else checkCamelCase(e, false); return null; } /** * 判斷一個變量是否是常量 */ private boolean heuristicallyConstant(VariableElement e) { if (e.getEnclosingElement().getKind() == ElementKind.INTERFACE) return true; else if (e.getKind() == ElementKind.FIELD && e.getModifiers().containsAll(EnumSet.of(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL))) return true; else { return false; } } /** * 檢查傳入的Element是否符合駝式命名法,如果不符合,則輸出警告信息 */ private void checkCamelCase(Element e, boolean initialCaps) { String name = e.getSimpleName().toString(); boolean previousUpper = false; boolean conventional = true; int firstCodePoint = name.codePointAt(0); if (Character.isUpperCase(firstCodePoint)) { previousUpper = true; if (!initialCaps) { messager.printMessage(Diagnostic.Kind.WARNING, "名稱'" + name + "'應當以小寫字母開頭", e); return; } } else if (Character.isLowerCase(firstCodePoint)) { if (initialCaps) { messager.printMessage(Diagnostic.Kind.WARNING, "名稱'" + name + "'應當以大寫字母開頭", e); return; } } else conventional = false; if (conventional) { int cp = firstCodePoint; for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) { cp = name.codePointAt(i); if (Character.isUpperCase(cp)) { if (previousUpper) { conventional = false; break; } previousUpper = true; } else previousUpper = false; } } if (!conventional) messager.printMessage(Diagnostic.Kind.WARNING, "名稱'" + name + "'應當符合駝式命名法(Camel Case Names)", e); } /** * 大寫命名檢查,要求第一個字母必須是大寫的英文字母,其余部分可以是下划線或大寫字母 */ private void checkAllCaps(Element e) { String name = e.getSimpleName().toString(); boolean conventional = true; int firstCodePoint = name.codePointAt(0); if (!Character.isUpperCase(firstCodePoint)) conventional = false; else { boolean previousUnderscore = false; int cp = firstCodePoint; for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) { cp = name.codePointAt(i); if (cp == (int) '_') { if (previousUnderscore) { conventional = false; break; } previousUnderscore = true; } else { previousUnderscore = false; if (!Character.isUpperCase(cp) && !Character.isDigit(cp)) { conventional = false; break; } } } } if (!conventional) messager.printMessage(Diagnostic.Kind.WARNING, "常量'" + name + "'應當全部以大寫字母或下划線命名,並且以字母開頭", e); } } }
我們可以通過Javac命令的“-processor”參數來執行編譯時需要附帶的注解處理器,如果有多個注解處理器的話,用逗號分隔。還可以使用-XprintRounds和-XprintProcessorInfo參數來查看注解處理器運作的詳細信息,javac -processor ***.NameCheckProcessor ***/test.java
test.java:3:警告:名稱"test"應當符合駝式命名法(Camel Case Names) public class test{ ^ test.java:5:警告:名稱"colors"應當以大寫字母開頭 enum colors{ ^ test.java:6:警告:常量"red"應當全部以大寫字母或下划線命名,並且以字母開頭 red,blue,green; ^ test.java:6:警告:常量"blue"應當全部以大寫字母或下划線命名,並且以字母開頭 red,blue,green; ^ test.java:6:警告:常量"green"應當全部以大寫字母或下划線命名,並且以字母開頭 red,blue,green; ^ test.java:9:警告:常量"_FORTY_TWO"應當全部以大寫字母或下划線命名,並且以字母開頭 static final int_FORTY_TWO=42; ^ test.java:11:警告:名稱"NOT_A_CONSTANT"應當以小寫字母開頭 public static int NOT_A_CONSTANT=_FORTY_TWO; ^ test.java:13:警告:名稱"Test"應當以小寫字母開頭 protected void Test(){ ^ test.java:17:警告:名稱"NOTcamelCASEmethodNAME"應當以小寫字母開頭 public void NOTcamelCASEmethodNAME(){ ^
NameCheckProcessor的例子只演示了JSR-269嵌入式注解處理器API中的一部分功能,基於這組API支持的項目還有用於校驗Hibernate標簽使用正確性的Hibernate Validator Annotation Processorm自動為字段生成getter和setter方法的Project Lombok.
語義分析
語法分析之后,編譯器獲得了程序代碼的抽象語法樹表示,語法樹能表示一個結構正確的源程序的抽象,但無法保證源程序是符合邏輯的。而語義分析的主要任務是對結構上正確的源程序進行上下文有關性質的審查,如進行類型審查。Javac的編譯過程中,語義分析過程分為標注檢查以及數據及控制流分析兩個步驟,分別由上圖中所示的attribute()和flow()方法(分別對應過程3.1和過程3.2)完成。
標注檢查
標注檢查步驟檢查的內容包括諸如變量使用前是否已被聲明、變量與賦值之間的數據類型是否能夠匹配等。在標注檢查步驟中,還有一個重要的動作稱為常量折疊,如果我們在代碼中寫了如下定義:int a=1+2;那么在語法樹上仍然能看到字面量“1”、“2”以及操作符“+”,但是在經過常量折疊之后,它們將會被折疊為字面量“3”,由於編譯期間進行了常量折疊,所以在代碼里面定義“a=1+2”比起直接定義“a=3”,並不會增加程序運行期哪怕僅僅一個CPU指令的運算量。
數據及控制流分析
在Javac的源碼中,數據及控制流分析的入口是圖中的flow()方法(對應過程3.2),具體操作由com.sun.tools.javac.comp.Flow類來完成。
數據及控制流分析是對程序上下文邏輯更進一步的驗證,它可以檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。編譯時期的數據及控制流分析與類加載時的數據及控制流分析的目的基本上是一致的,但校驗范圍有所區別,有一些校驗項只有在編譯期或運行期才能進行。
//方法一帶有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文件是沒有任何一點區別的,局部變量與字段(實例變量、類變量)是有區別的,它在常量池中沒有CONSTANT_Fieldref_info的符號引用,自然就沒有訪問標志(Access_Flags)的信息,甚至可能連名稱都不會保留下來(取決於編譯時的選項),自然在Class文件中不可能知道一個局部變量是不是聲明為final了。因此,將局部變量聲明為final,對運行期是沒有影響的,變量的不變性僅僅由編譯器在編譯期間保障。
解語法糖
在Javac的源碼中,解語法糖的過程由desugar()方法觸發,在com.sun.tools.javac.comp.TransTypes類和com.sun.tools.javac.comp.Lower類中完成。
語法糖(Syntactic Sugar),也稱糖衣語法,指在計算機語言中添加的某種語法,這種語法對語言的功能並沒有影響,但是更方便程序員使用。通常來說,使用語法糖能夠增加程序的可讀性,從而減少程序代碼出錯的機會。
Java中最常用的語法糖主要是的泛型擦除(泛型並不一定都是語法糖實現,如C#的泛型就是直接由CLR支持的)、變長參數、自動裝箱/拆箱,條件編譯等,虛擬機運行時不支持這些語法,它們在編譯階段還原回簡單的基礎語法結構,這個過程稱為解語法糖。
字節碼生成
字節碼生成是Javac編譯過程的最后一個階段,在Javac源碼里面由com.sun.tools.javac.jvm.Gen類來完成。字節碼生成階段不僅僅是把前面各個步驟所生成的信息(語法樹、符號表)轉化成字節碼寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作。
例如,實例構造器<init>()方法和類構造器<clinit>()方法就是在這個階段添加到語法樹之中的(注意,這里的實例構造器並不是指默認構造函數,如果用戶代碼中沒有提供任何構造函數,那編譯器將會添加一個沒有參數的、訪問性(public、protected或private)與當前類一致的默認構造函數,這個工作在填充符號表階段就已經完成),這兩個構造器的產生過程實際上是一個代碼收斂的過程,編譯器會把語句塊(對於實例構造器而言是“{}”塊,對於類構造器而言是“static{}”塊)、變量初始化(實例變量和類變量)、調用父類的實例構造器等操作收斂到<init>()和<clinit>()方法之中,並且保證一定是按先執行父類的實例構造器,然后初始化變量,最后執行語句塊的順序進行,上面所述的動作由Gen.normalizeDefs()方法來實現。除了生成構造器以外,還有其他的一些代碼替換工作用於優化程序的實現邏輯,如把字符串的加操作替換為StringBuffer或StringBuilder的append()操作等。
完成了對語法樹的遍歷和調整之后,就會把填充了所有所需信息的符號表交給com.sun.tools.javac.jvm.ClassWriter類,由這個類的writeClass()方法輸出字節碼,生成最終的Class文件,到此為止整個編譯過程宣告結束。