Java語言的「編譯期」實際上是一段「不肯定」的操做過程,由於它多是指一個前端編譯器(其實叫「編譯器的前端」更準確一些)把*.java文件轉變成*.class文件的過程;也多是指虛擬機的後端運行期編譯器(JIT編譯器,Just In Time Compiler )把字節碼轉變成機器碼的過程 ;還多是指使用靜態提早編譯器(AOT編譯器,Ahead Of Time Compiler ) 直接把*.java 文件編譯成本地機器代碼的過程。下面列舉了這3類編譯過程當中一些比較有表明性的編譯器。前端
這3類過程當中最符合你們對Java程序編譯認知的應該是第一類,在本章的後續文字裏, 筆者提到的「編譯期」和「編譯器」都僅限於第一類編譯過程,把第二類編譯過程留到下一章中討論。限制了編譯範圍後,咱們對於「優化」二字的定義就須要寬鬆一些,由於Javac這類編譯器對代碼的運行效率幾乎沒有任何優化措施(在JDK 1.3之 後 ,Javac的-O 優化參數就再也不有意 義 )。虛擬機設計團隊把對性能的優化集中到了後端的即時編譯器中,這樣可讓那些不是由Javac產生的Class文件 (如JRuby、Groovy等語言的Class文件 )也一樣能享受到編譯器優化所帶來的好處。可是Javac作了許多針對Java語言編碼過程的優化措施來改善程序員的編碼風格和提升編碼效率。至關多新生的Java語法特性,都是靠編譯器的「語法糖」來實現,而不是依賴虛擬機的底層改進來支持,能夠說,Java中即時編譯器在運行期的優化過程對於程序運行來講更重要,而前端編譯器在編譯期的優化過程對於程序編碼來講關係更加密切。java
分析源碼是瞭解一項技術的實現內幕最有效的手段,Javac編譯器不像HotSpot虛擬機那樣使用C++語 言 (包含少許C語言 )實現 ,它自己就是一個由Java語言編寫的程序,這爲純Java的程序員瞭解它的編譯過程帶來了很大的便利。git
Javac的源碼存放在JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac中, 除了JDK自身的API外 ,就只引用了JDK_SRC_HOME/langtools/src/share/classes/com/sun/*裏面的代碼 ,調試環境創建起來簡單方便,由於基本上不須要處理依賴關係。程序員
以Eclipse IDE環境爲例,先創建一個名爲「Compiler_javac」的Java工程,而後把 JDK_SRC_HOME/langtools/src/share/classes/com/sun/*目錄下的源文件所有複製到工程的源碼目錄中 ,如圖10-1所示。編程
導入代碼期間,源碼文件「AnnotationProxyMaker.java」可能會提示「Access Restriction」, 被Eclipse拒絕編譯,如圖10-2 所示。後端
這是因爲Eelipse的JRE System Library中默認包含了一系列的代碼訪問規則(Access Rules ) , 若是代碼中引用了這些訪問規則所禁止引用的類,就會提示這個錯誤。能夠經過添加一條容許訪問JAR包中全部類的訪問規則來解決這個問題,如圖10-3所示。數組
導入了Javac的源碼後,就能夠運行com.sun.tools.javac.Main的main ()方法來執行編譯了 ,與命令行中使用Javac的命令沒有什麼區別,編譯的文件與參數在Eclipse的「Debug Configurations」面板中的「Arguments」頁籤中指定。數據結構
虛擬機規範嚴格定義了Class文件的格式,可是《 Java虛擬機規範(第2版)》中,雖然有專門的一章「Compiling for the Java Virtual Machine」 , 但都是以舉例的形式描述,並無對如何把Java源碼文件轉變爲Class文件的編譯過程進行十分嚴格的定義,這致使Class文件編譯 在某種程度上是與具體JDK實現相關的,在一些極端狀況,可能出現一段代碼.Javac編譯器能夠編譯,可是ECJ編譯器就不能夠編譯的問題。從Sun Javac的代碼來看,編譯過程大體能夠分爲3個過程,分別是:app
這3個步驟之間的關係與交互順序如圖10-4所示。框架
Javac編譯動做的入口是com.sun.tools.javac.main.JavaCompiler類 ,上述3個過程的代碼邏輯集中在這個類的compile() 和compile2() 方法中,其中主體代碼如圖10-5所示,整個編譯最關鍵的處理就由圖中標註的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 ) ,例如包、類型、修飾符、運算符、接口、返回值甚至代碼註釋等均可以是一個語法結構。
圖10-6是根據Eclipse AST View插件分析出來的某段代碼的抽象語法樹視圖,讀者能夠經過這張圖對抽象語法樹有一個直觀的認識。在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對象對編譯的後續步驟進行處理。
語法分析以後,編譯器得到了程序代碼的抽象語法樹表示,語法樹能表示一個結構正確的源程序的抽象,但沒法保證源程序是符合邏輯的。而語義分析的主要任務是對結構上正確的源程序進行上下文有關性質的審查,如進行類型審查。舉個例子,假設有以下的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種寫法都是能夠正確編譯)。
Javac的編譯過程當中,語義分析過程分爲標註檢查以及數據及控制流分析兩個步驟,分別由圖10-5中所示的attribute() 和flow() 方法(分別對應圖10-5中的過程3.1和過程3.2) 完成。
標註檢查步驟檢查的內容包括諸如變量使用前是否已被聲明、變量與賦值之間的數據類型是否可以匹配等。在標註檢查步驟中,還有一個重要的動做稱爲常量摺疊,若是咱們在代碼中寫了以下定義:
int a=1+2;
那麼在語法樹上仍然能看到字面量「 1」、「2」以及操做符「+」,可是在通過常量摺疊以後 ,它們將會被摺疊爲字面量「3」 ,如圖10-7所示,這個插入式表達式( Mix Expression )的值已經在語法樹上標註出來了(ConstantExpressionValue : 3 ) 。 因爲編譯期間進行了常量摺疊 ,因此在代碼裏面定義「a=1+2」比起直接定義「a=3」 , 並不會增長程序運行期哪怕僅僅一個 CPU指令的運算量。
標註檢查步驟在Javac源碼中的實現類是com.sun.tools.javac.comp.Attr類和
com.sun.tools.javac.comp.Check類。
數據及控制流分析是對程序上下文邏輯更進一步的驗證,它能夠檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否全部的受查異常都被正確處理了等問題。編譯時期的數據及控制流分析與類加載時的數據及控制流分析的目的基本上是一致的,但校驗範圍有所區別,有一些校驗項只有在編譯期或運行期才能進行。下面舉一個關於final修飾符的數據及控制流分析的例子,見代碼清單10-1。
代碼清單10-1 final語義校驗
// 方法一帶有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,對運行期是沒有影響的,變量的不變性僅僅由編譯器在編譯期間保障。在Javac的源碼中,數據及控制流分析的入口是圖 10-5中的flow() 方法(對應圖10-5中的過程3.2) ,具體操做由com.sun.tools.javac.comp.Flow類來完成。
語法糖( Syntactic Sugar ) , 也稱糖衣語法,是由英國計算機科學家彼得•約翰•蘭達 ( Peter J.Landin)發明的一個術語,指在計算機語言中添加的某種語法,這種語法對語言的功能並無影響,可是更方便程序員使用。一般來講,使用語法糖可以增長程序的可讀性, 從而減小程序代碼出錯的機會。
Java在現代編程語言之中屬於「低糖語言」 (相對於C#及許多其餘JVM語言來講),尤爲是JDK 1.5以前的版本,「低糖」語法也是Java語言被懷疑已經「落後」的一個表面理由。Java中最經常使用的語法糖主要是前面提到過的泛型(泛型並不必定都是語法糖實現,如C#的泛型就是直接由CLR支持的 )、變長參數、自動裝箱/拆箱等,虛擬機運行時不支持這些語法 ,它們在編譯階段還原回簡單的基礎語法結構,這個過程稱爲解語法糖。Java的這些語法糖被解除後 是什麼樣子,將在10.3節中詳細講述。
在Javac的源碼中,解語法糖的過程由desugar() 方法觸發,在 com.sun.tools.javac.comp.TransTypes類和com.sun.tools.javac.comp.Lower類中完成。
字節碼生成是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文件 ,到此爲止整個編譯過程宣告結束。
幾乎各類語言或多或少都提供過一些語法糖來方便程序員的代碼開發,這些語法糖雖然不會提供實質性的功能改進,可是它們或能提升效率,或能提高語法的嚴謹性,或能減小編碼出錯的機會。不過也有一種觀點認爲語法糖並不必定都是有益的,大量添加和使用「含糖」的語法,容易讓程序員產生依賴,沒法看清語法糖的糖衣背後,程序代碼的真實面目。
總而言之,語法糖能夠看作是編譯器實現的一些「小把戲」 ,這些「小把戲」可能會使得效率「大提高」 ,但咱們也應該去了解這些「小把戲」背後的真實世界,那樣才能利用好它們,而不是被它們所迷惑。
泛型是JDK 1.5的一項新增特性,它的本質是參數化類型( Parametersized Type )的應用 ,也就是說所操做的數據類型被指定爲一個參數。這種參數類型能夠用在類、接口和方法的建立中,分別稱爲泛型類、泛型接口和泛型方法。
泛型思想早在C++語言的模板( Template ) 中就開始生根發芽,在Java語言處於尚未出現泛型的版本時,只能經過Object是全部類型的父類和類型強制轉換兩個特色的配合來實現類型泛化。例如,在哈希表的取中, JDK 1.5以前使用HashMap的g et() 方法,返回值就是一個Object對象,因爲Java語言裏面全部的類型都繼承於java.lang.Object,因此Object轉型成任何對都是有可能的。可是也由於有無限的可能性,就只有程序員和運行期的虛擬機才知道這個Object究竟是個什麼類型的對象。在編譯期間,編譯器沒法檢查這個Object的強制轉型是否成功,若是僅僅依賴程序員去保障這項操做的正確性,許多ClassCastException的風險就會轉嫁到程序運行期之中。
泛型技術在C#和Java之中的使用方式看似相同,但實現上卻有着根本性的分歧,C#裏面泛型不管在程序源碼中、編譯後的IL中( Intermediate Language,中間語言,這時候泛型是一個佔位符),或是運行期的CLR中 ,都是切實存在的,List<int>與List<String>就是兩個不一樣的類型,它們在系統運行期生成,有本身的虛方法表和類型數據,這種實現稱爲類型膨脹 ,基於這種方法實現的泛型稱爲真實泛型。
Java語言中的泛型則不同,它只在程序源碼中存在,在編譯後的字節碼文件中,就已 經替換爲原來的原生類型( Raw Type,也稱爲裸類型 )了,而且在相應的地方插入了強制轉型代碼,所以,對於運行期的Java語言來講,ArrayList<int>與ArrayList<String>就是同一個類,因此泛型技術其實是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文件 ,而後再用字節碼反編譯工具進行反編譯後,將會發現泛型都不見了,程序又變回了Java泛型出現以前的寫法,泛型類型都變回了原生類型,如代碼清10-3所示。
代碼清單10-3泛型擦除後的例子
public static void main(String[] args) { Map map = new HashMap(); map.put("hello", "你好"); map.put("how are you?", "吃了沒?"); System.out.println((String) map.get("hello")); System.out.println((String) map.get("how are you?")); }
當初JDK設計團隊爲何選擇類型擦除的方式來實現Java語言的泛型支持呢?是由於實現簡單、兼容性考慮仍是別的緣由?咱們已不得而知,但確實有很多人對Java語言提供的僞泛型很有微詞,當時甚至連《Thingking in Java》一書的做者Bruce Eckel也發表了一篇文章《這不是泛型!》來批評JDK1.5中的泛型實現。
在當時衆多的批評之中,有一些是比較片面的,還有一些從性能上說泛型會因爲強制轉型操做和運行期缺乏針對類型的優化等從而致使比C#的泛型慢一些,則是徹底偏離了方向,姑且不論Java泛型是否是真的會比C#泛型慢,選擇從性能的角度上評價用於提高語義準確性的泛型思想就不太恰當了。但筆者也並不是在爲Java的泛型辯護,它在某些場景下確實存在不足,筆者認爲經過擦除法來實現泛型喪失了一些泛型思想應有的優雅,例如代碼清單10-4的例子。
代碼清單10-4 當泛型碰見重載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<Integer>和List<Striiig>編譯以後都被擦除了,變成了同樣的原生類型List<E> ,擦除動做致使這兩種方法的特徵簽名變得如出一轍。初步看來,沒法重載的緣由已經找到了,但真的就是如此嗎?只能說 ,泛型擦除成相同的原生類型只是沒法重載的其中一部分緣由,請再接着看一看代碼清單10-5中的內容。
代碼清單10-5 當泛型碰見重載2
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方法添加了不一樣的返回值,因爲這兩個返回值的加入,方法重載竟然成功了,即這段代碼能夠被編譯和執行了。這是對Java語言中返回值不參與重載選擇的基本認知的挑戰嗎?
代碼清單10-5中的重載固然不是根據返回值來肯定的,之因此此次能編譯和執行成功, 是由於兩個method() 方法加入了不一樣的返回值後才能共存在一個Class文件之中。第6章介紹Class文件方法表( methodjnfo ) 的數據結構時曾經提到過,方法重載要求方法具有不一樣的特徵簽名,返回值並不包含在方法的特徵簽名之中,因此返回值不參與重載選擇,可是在Class文件格式之中,只要描述符不是徹底一致的兩個方法就能夠共存。也就是說,兩個方法若是有相同的名稱和特徵簽名,但返回值不一樣,那它們也是能夠合法地共存於一個Class文件中的。
因爲Java泛型的引入,各類場景(虛擬機解析、反射等)下的方法調用均可能對原有的基礎產生影響和新的需求,如在泛型類中如何獲取傳入的參數化類型等。所以 ,JCP組織對虛擬機規範作出了相應的修改,引入了諸如Signature、LocalVariableTypeTable等新的屬性用於解決伴隨泛型而來的參數類型的識別問題,Signature是其中最重要的一項屬性,它的做用就是存儲一個方法在字節碼層面的特徵簽名,這個屬性中保存的參數類型並非原生類型 ,而是包括了參數化類型的信息。修改後的虛擬機規範要求全部能識別49.0以上版本的 Class文件的虛擬機都要能正確地識別Signature參數。
從上面的例子能夠看到擦除法對實際編碼帶來的影響,因爲List<String> 和List<Integer> 擦除後是同一個類型,咱們只能添加兩個並不須要實際使用到的返回值才能完成重載,這是一種毫無優雅和美感可言的解決方案,而且存在必定語意上的混亂,譬如上面腳註中提到的,必須用SunJDK 1.6的Javac才能編譯成功,其餘版本或者ECJ編譯器均可能拒絕編譯。
另外 ,從Signature屬性的出現咱們還能夠得出結論,擦除法所謂的擦除,僅僅是對方法的Code屬性中的字節碼進行擦除,實際上元數據中仍是保留了泛型信息,這也是咱們能經過反射手段取得參數化類型的根本依據。
注:在 《Java虛擬機規範(第2版 ) 》 ( JDK 1.5修改後的版本)的「§4.4.4 Signatures」章節及 《Java語言規範(第3版)》的「§8.4.2 Method Signature」章節中分別定義了字節碼層面的方法特徵簽名,以及Java代碼層面的方法特徵簽名,特徵簽名最重要的任務就是做爲方法獨一無二且不可重複的ID ,在Java代碼中的方法特徵簽名只包括了方法名稱、參數順序及參數類型 ,而在字節碼中的特徵簽名還包括方法返回值及受查異常表,本書中若是指的是字節碼層面的方法簽名,筆者會加入限定語進行說明,也請讀者根據上下文語境注意區分。
從純技術的角度來說,自動裝箱、自動拆箱與遍歷循環(Foreach循環 )這些語法糖,不管是實現上仍是思想上都不能和上文介紹的泛型相比,二者的難度和深度都有很大差距。專門拿出一節來說解它們只有一個理由:毫無疑問,它們是Java語言裏使用得最多的語法糖。 咱們經過代碼清單10-6和代碼清單10-7中所示的代碼來看看這些語法糖在編譯後會發生什麼樣的變化。
代碼清單10-6 自動裝箱、拆箱與遍歷循環
public static void main(String[] args) { List<Integer> list = Arrays.asList(1, 2, 3, 4); // 若是在JDK 1.7中,還有另一顆語法糖 , // 能讓上面這句代碼進一步簡寫成List<Integer> list = [1, 2, 3, 4]; int sum = 0; for (int i : list) { sum += i; } System.out.println(sum); }
代碼清單10-7 自動裝箱、拆箱與遍歷循環編譯以後
public static void main(String[] args) { List list = Arrays.asList( new Integer[] { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) }); int sum = 0; for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) { int i = ((Integer)localIterator.next()).intValue(); sum += i; } System.out.println(sum); }
代碼清單10-6中一共包含了泛型、自動裝箱、自動拆箱、遍歷循環與變長參數5種語法糖 ,代碼清單10-7則展現了它們在編譯後的變化。泛型就沒必要說了,自動裝箱、拆箱在編譯以後被轉化成了對應的包裝和還原方法,如本例中的Integer.valueOf() 與Integer.intValue() 方法,而遍歷循環則把代碼還原成了迭代器的實現,這也是爲什麼遍歷循環須要被遍歷的類實現Iterable接口的緣由。最後再看看變長參數,它在調用的時候變成了一個數組類型的參數,在變長參數出現以前,程序員就是使用數組來完成相似功能的。
這些語法糖雖然看起來很簡單,但也不見得就沒有任何值得咱們注意的地方,代碼清單10-8演示了自動裝箱的一些錯誤用法。
代碼清單10-8 自動裝箱的陷阱
public static void main(String[] args) { Integer a = 1; Integer b = 2; Integer c = 3; Integer d = 3; Integer e = 321; Integer f = 321; Long g = 3L; System.out.println(c == d); System.out.println(e == f); System.out.println(c == (a + b)); System.out.println(c.equals(a + b)); System.out.println(g == (a + b)); System.out.println(g.equals(a + b)); }
閱讀完代碼清單10-8,讀者不妨思考兩個問題:一是這6句打印語句的輸出是什麼?二 是這6句打印語句中,解除語法糖後參數會是什麼樣子?這兩個問題的答案能夠很容易試驗出來 ,筆者就暫且略去答案,但願讀者本身上機實踐一下。不管讀者的回答是否正確,鑑於包裝類的「= 」運算在不遇到算術運算的狀況下不會自動拆箱,以及它們equals()方法不處理數據轉型的關係,筆者建議在實際編碼中儘可能避免這樣使用自動裝箱與拆箱。
許多程序設計語言都提供了條件編譯的途徑,如C、C++中使用預處理器指示符 (#ifdef)來完成條件編譯。C、C++的預處理器最初的任務是解決編譯時的代碼依賴關係( 如很是經常使用的#include預處理命令),而在Java語言之中並無使用預處理器,由於Java語言自然的編譯方式(編譯器並不是一個個地編譯Java文件 ,而是將全部編譯單元的語法樹頂級節點輸入到待處理列表後再進行編譯,所以各個文件之間可以互相提供符號信息)無須使用預處理器。那Java語言是否有辦法實現條件編譯呢?
Java語言固然也能夠進行條件編譯,方法就是使用條件爲常量的if語句。如代碼清單10-9所示 ,此代碼中的if語句不一樣於其餘Java代碼 ,它在編譯階段就會被「運行」,生成的字節碼之中只包括「System.out.println ( 「block 1」 ) ; 」一條語句,並不會包含if語句及另一個分子中的「System.out.println ( 「block 2」) ; 」
代碼清單10-9 Java語言的條件編譯
public static void main(String[] args) { if (true) { System.out.println("block 1"); } else { System.out.println("block 2"); } }
上述代碼編譯後Class文件的反編譯結果:
public static void main(String[] args) { System.out.println("block 1"); }
只能使用條件爲常量的if語句才能達到上述效果,若是使用常量與其餘帶有條件判斷能力的語句搭配,則可能在控制流分析中提示錯誤,被拒絕編譯,如代碼清單10-10所示的代碼就會被編譯器拒絕編譯。
代碼清單10-10 不能使用其餘條件語句來完成條件編譯
public static void main(String[] args) { // 編譯器將會提示「Unreachable code」 while (false) { System.out.println(""); } }
Java語言中條件編譯的實現,也是Java語言的一顆語法糖,根據布爾常量值的真假,編譯器將會把分支中不成立的代碼消除掉 ,這一工做將在編譯器解除語法糖階段
( com.sun.tools.javac.comp.Lower類中)完成。因爲這種條件編譯的實現方式使用了if語句,因此它必須遵循最基本的Java語法 ,只能寫在方法體內部,所以它只能實現語句基本塊 ( Block)級別的條件編譯,而沒有辦法實現根據條件調整整個Java類的結構。
除了本節中介紹的泛型、自動裝箱、自動拆箱、遍歷循環、變長參數和條件編譯以外 ,Java語言還有很多其餘的語法糖,如內部類、枚舉類、斷言語句、對枚舉和字符串(在 JDK 1.7中支持)的switch支持、try語句中定義和關閉資源(在JDK 1.7中支持)等 ,讀者能夠經過跟蹤Javac源碼、反編譯Class文件等方式瞭解它們的本質實現。
JDK編譯優化部分在本書中並無設置獨立的實戰章節,由於咱們開發程序,考慮的主要是程序會如何運行,不多會有針對程序編譯的需求。也由於這個緣由,在JDK的編譯子系統裏面,提供給用戶直接控制的功能相對較少,除了第11章會介紹的虛擬機JIT編譯的幾個相關參數之外,咱們就只有使用JSR-296中定義的插入式註解處理器API來對JDK編譯子系統的行爲產生一些影響。
可是筆者並不認爲相對於前兩部分介紹的內存管理子系統和字節碼執行子系統,JDK的編譯子系統就不那麼重要。一套編程語言中編譯子系統的優劣,很大程度上決定了程序運行性能的好壞和編碼效率的高低,尤爲在Java語言中,運行期即時編譯與虛擬機執行子系統很是緊密地互相依賴、配合運做(第11章將主要講解這方面的內容)。瞭解JDK如何編譯和優化代碼,有助於咱們寫出適合JDK自優化的程序。下面咱們回到本章的實戰中,看看插入式註解處理器API能實現什麼功能。
經過閱讀Javac編譯器的源碼,咱們知道編譯器在把Java程序源碼編譯爲字節碼的時候,會對Java程序源碼作各方面的檢查校驗。這些校驗主要以程序「寫得對不對」爲出發點,雖然也有各類WARNING的信息,但整體來說仍是較少去校驗程序「寫得好很差」。有鑑於此,業界出現了許多針對程序「寫得好很差」的輔助校驗工具,如CheckStyle、FindBug、Klocwork等。這些代碼校驗工具備一些是基於Java的源碼進行校驗,還有一些是經過掃描字節碼來完成,在本節的實戰中,咱們將會使用註解處理器API來編寫一款擁有本身編碼風格的校驗工具:NameCheckProcessor。
固然,因爲咱們的實戰都是爲了學習和演示技術原理,而不是爲了作出一款能媲美CheckStyle等工具的產品來,因此NameCheckProcessor的目標也僅定爲對Java程序命名進行檢查,根據《Java語言規範(第3版)》中第6.8節的要求,Java程序命名應當符合下列格式的書寫規範。
上文提到的駝式命名法(Camel Case Name),正如它的名稱所表示的那樣,是指混合使用大小寫字母來分割構成變量或函數的名字,猶如駝峯通常,這是當前Java語言中主流的命名規範,咱們的實戰目標就是爲Javac編譯器添加一個額外的功能,在編譯程序時檢查程序名是否符合上述對類(或接口)、方法、字段的命名要求。
要經過註解處理器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。關於註解處理器的API,筆者就簡單介紹這些,對這個領域有興趣的讀者能夠閱讀相關的幫助文檔。下面來看看註解處理器NameCheckProcessor的具體代碼,如代碼清單10-11所示。
代碼清單10-11 註解處理器NameCheckProcessor
// 能夠用"*"表示支持全部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; } }
從上面代碼能夠看出,NameCheckProcessor能處理基於JDK 1.6的源碼,它不限於特定的註解,對任何代碼都「感興趣」,而在process()方法中是把當前Round中的每個RootElement傳遞到一個名爲NameChecker的檢查器中執行名稱檢查邏輯,NameChecker的代碼如代碼清單10-12所示。
代碼清單10-12 命名檢查器NameChecker
/** * 程序名稱規範的編譯器插件:<br> * 若是程序命名不合規範,將會輸出一個編譯器的WARNING信息 */ public class NameChecker { private final Messager messager; NameCheckScanner nameCheckScanner = new NameCheckScanner(); NameChecker(ProcessingEnvironment processsingEnv) { this.messager = processsingEnv.getMessager(); } /** * 對Java程序命名進行檢查,根據《Java語言規範》第三版第6.8節的要求,Java程序命名應當符合下列格式: * * <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() == METHOD) { Name name = e.getSimpleName(); if (name.contentEquals(e.getEnclosingElement().getSimpleName())) messager.printMessage(WARNING, "一個普通方法 「" + name + "」不該當與類名重複,避免與構造函數產生混淆", e); checkCamelCase(e, false); } super.visitExecutable(e, p); return null; } /** * 檢查變量命名是否合法 */ @Override public Void visitVariable(VariableElement e, Void p) { // 若是這個Variable是枚舉或常量,則按大寫命名檢查,不然按照駝式命名法規則檢查 if (e.getKind() == ENUM_CONSTANT || e.getConstantValue() != null || heuristicallyConstant(e)) checkAllCaps(e); else checkCamelCase(e, false); return null; } /** * 判斷一個變量是不是常量 */ private boolean heuristicallyConstant(VariableElement e) { if (e.getEnclosingElement().getKind() == INTERFACE) return true; else if (e.getKind() == FIELD && e.getModifiers().containsAll(EnumSet.of(PUBLIC, STATIC, 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(WARNING, "名稱「" + name + "」應當以小寫字母開頭", e); return; } } else if (Character.isLowerCase(firstCodePoint)) { if (initialCaps) { messager.printMessage(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(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(WARNING, "常量「" + name + "」應當所有以大寫字母或下劃線命名,而且以字母開頭", e); } } }
NameChecker的代碼看起來有點長,但實際上註釋佔了很大一部分,其實即便算上註釋也不到190行。它經過一個繼承於javax.lang.model.util.ElementScanner6的NameCheckScanner類,以Visitor模式來完成對語法樹的遍歷,分別執行visitType()、visitVariable()和visitExecutable()方法來訪問類、字段和方法,這3個visit方法對各自的命名規則作相應的檢查,checkCamelCase()與checkAllCaps()方法則用於實現駝式命名法和全大寫命名規則的檢查。
整個註解處理器只需NameCheckProcessor和NameChecker兩個類就能夠所有完成,爲了驗證咱們的實戰成果,代碼清單10-13中提供了一段命名規範的「反面教材」代碼,其中的每個類、方法及字段的命名都存在問題,可是使用普通的Javac編譯這段代碼時不會提示任何一個Warning信息。
代碼清單10-13 包含了多處不規範命名的代碼樣例
public class BADLY_NAMED_CODE { enum colors { red, blue, green; } static final int _FORTY_TWO = 42; public static int NOT_A_CONSTANT = _FORTY_TWO; protected void BADLY_NAMED_CODE() { return; } public void NOTcamelCASEmethodNAME() { return; } }
咱們能夠經過Javac命令的「-processor」參數來執行編譯時須要附帶的註解處理器,若是有多個註解處理器的話,用逗號分隔。還可使用-XprintRounds和-XprintProcessorInfo參數來查看註解處理器運做的詳細信息,本次實戰中的NameCheckProcessor的編譯及執行過程如代碼清單10-14所示。
代碼清單10-14 註解處理器的運行過程
D:\src>javac org/fenixsoft/compile/NameChecker.java D:\src>javac org/fenixsoft/compile/NameCheckProcessor.java D:\src>javac-processor org.fenixsoft.compile.NameCheckProcessor org/fenixsoft/compile/BADLY_NAMED_CODE.java org\fenixsoft\compile\BADLY_NAMED_CODE.java:3:警告:名稱"BADLY_NAMED_CODE"應當符合駝式命名法(Camel Case Names) public class BADLY_NAMED_CODE{ ^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:5:警告:名稱"colors"應當以大寫字母開頭 enum colors{ ^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:6:警告:常量"red"應當所有以大寫字母或下劃線命名,而且以字母開頭 red,blue,green; ^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:6:警告:常量"blue"應當所有以大寫字母或下劃線命名,而且以字母開頭 red,blue,green; ^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:6:警告:常量"green"應當所有以大寫字母或下劃線命名,而且以字母開頭 red,blue,green; ^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:9:警告:常量"_FORTY_TWO"應當所有以大寫字母或下劃線命名,而且以字母開頭 static final int_FORTY_TWO=42; ^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:11:警告:名稱"NOT_A_CONSTANT"應當以小寫字母開頭 public static int NOT_A_CONSTANT=_FORTY_TWO; ^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:13:警告:名稱"Test"應當以小寫字母開頭 protected void Test(){ ^ org\fenixsoft\compile\BADLY_NAMED_CODE.java:17:警告:名稱"NOTcamelCASEmethodNAME"應當以小寫字母開頭 public void NOTcamelCASEmethodNAME(){ ^
NameCheckProcessor的實戰例子只演示了JSR-269嵌入式註解處理器API中的一部分功能,基於這組API支持的項目還有用於校驗Hibernate標籤使用正確性的Hibernate Validator Annotation Processor(本質上與NameCheckProcessor所作的事情差很少)、自動爲字段生成getter和setter方法的Project Lombok(根據已有元素生成新的語法樹元素)等,讀者有興趣的話能夠參考它們官方站點的相關內容。