1. 程式人生 > >深入理解JVM之早期(編譯期)優化

深入理解JVM之早期(編譯期)優化

        讀了深入理解JVM之早期優化這一章,JVM作者從編譯期原始碼實現的層次上讓我們瞭解了Java原始碼編譯為位元組碼的過程,分析了Java語言中泛型、主動裝箱/拆箱、條件編譯等多種語法糖的前因後果,並實戰練習瞭如何使用插入式註解處理器來完成一個檢查程式命令規範的編譯器外掛。在前端編譯器中,“優化”手段主要用於提升程式的編碼效率,之所以把Javac這類將java程式碼轉變為位元組碼的編譯器稱做“前端編譯器”,是因為它只完成了從程式到抽象語法樹或中間位元組碼的生成,而在此之後,還有一組內置於虛擬機器內部的“後端編譯器”完成了從位元組碼生成本地機器碼的過程,即前面多次提到了即時編譯器或JIT編譯器,這個編譯器的編譯速度及編譯結果的優劣,是衡量虛擬機器效能一個很重要的指標。

1:概述

        java語言的“編譯期”其實是一段“不確定”的操作過程,因為它可能是指一個前端編譯器(其實叫“編譯器的前端”更準確點)把*.java檔案轉化為*.class檔案的過程;也可能是指虛擬機器的後端執行期編譯器(JIT編譯器,Just In Time Compiler)把位元組碼轉化為機器碼的過程;還可能是指使用靜態提前編譯器(AOT編譯器,Ahead Of Time Compiler)直接把*.java檔案編譯成本地機器程式碼的過程。下面列舉了這3類編譯過程中一些比較有代表性的編譯器。

        1,前端編譯器:Sun的Javac、Eclipse JDT中的增量式編譯器(ECJ);

        2,JIT編譯器:HotSpot VM的C1、C2編譯器;

        3,AOT編譯器:GNU Compiler for the Java(GCJ)、Excelsior JET;

        這3類過程中最符合大家對Java程式編譯認知的應該是第一類,在本章的後續文字裡,JVM筆者提到的“編譯期”和“編譯器”都僅限於第一類編譯過程,把第二類編譯過程留到下一章中討論。限制了編譯範圍後,我們對於“優化”二字的定義就需要寬鬆一些,因為Javac這類編譯器對程式碼的執行效率幾乎沒有任何優化措施。虛擬機器設計團隊把對效能的優化集中到了後端的即時編譯器中,這樣可以讓那些不是由Javac產生的Class檔案(如JRuby、Groovy等語言的Class檔案)也同樣能享受到編譯器優化所帶來的好處。但是Javac做了許多針對Java語言編碼過程的優化措施來改善程式設計師的編碼風格和提高編碼效率。相當多新生的Java語法特性,都是靠編譯器的“語法糖”來實現,而不是依賴虛擬機器的底層改進來支援,可以說,Java中即時編譯器在執行期的優化過程對於程式執行來說更重要,而前端編譯器在編譯期的優化過程對於程式編碼來說關係更加密切。

2:Javac編譯器

        分析原始碼是瞭解一項技術的實現內幕最有效的手段,Javac編譯器不像HotSpot虛擬機器那樣使用C++語言(包含C少量C語言)實現,它本身就是一個由Java語言編寫的程式,這為純Java的程式設計師瞭解它的編譯過程帶來了很大的便利。

1:Javac的原始碼與除錯

        Javac的原始碼存放在JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac中,除了JDK自身的API外,就只用了JDK_SRC_HOME/langtools/src/share/classes/com/sum/*裡面的程式碼,除錯環境建立起來簡單方便,因為基本上不需要處理依賴關係。

         以Eclipse IDE環境為例,先建立一個名為“Compiler_javac”的Java工程,然後把JDK_HOME/langtools/src/share/classes/com/sun/*目錄下的原始檔全部複製到工程的原始碼目錄中,如圖10-1所示。


        匯入程式碼期間,原始檔“AnnotationProxyMaker.java”可能會提示"Access Restriction",被eclipse拒絕編譯,如圖10-2所示:


        這是由於Eclipse的JRE System Library中預設包含了一系列的程式碼訪問規則(Access Rules),如果程式碼中引用了這些訪問規則所禁止引用的類,就會提示這個錯誤。可以通過新增一條允許訪問JRE包中所有類的訪問規則來解決這個問題,如圖10-3所示:


        匯入了Javac的原始碼後,就可以執行com.sun.tools.javac.Main的main()方法來執行編譯了,與命令列中使用的Javac的命令沒有什麼區別,編譯的檔案與引數在Eclipse的“Debug Configurations”面板中的“Arguments”頁籤中制定。

        虛擬機器規範嚴格定義了Class檔案的格式,但是《JVM虛擬機器規範(第二版)》中,雖然有專門的一章“Compiling for the Java Virtual Machine”,但都是以舉例的形式描述,並沒有如何把Java原始碼檔案轉變為Class檔案的編譯過程進行十分嚴格的定義,這導致Class檔案編譯在某種程度上是與具體JDK實現相關的,在一些極端情況,可能出現一段程式碼Javac編譯器可以編譯,但是ECJ編譯器就不可以編譯的問題(10.3.1節中將會給出這樣的例子)。從Sun Javac的程式碼來看,編譯過程大致可以分為3個過程,分別是:

        1,解析與填充符合表過程;

        2,插入式註解處理器的註解處理過程;

        3,分析與位元組碼生成過程。

        這三個步驟之間的關係與互動順序如圖10-4所示:


        Javac編譯動作的入口是com.sun.tools.javac.main.JavaCompiler類,上述3個過程的程式碼邏輯集中在這個類的compile()和compile2()方法中,其中主體程式碼如圖10-5所示,整個編譯最關鍵的處理就由圖中標註的8個方法來完成,下面我們具體看一下這8個方法實現了什麼功能。


2:解析與填充符號表

        解析步驟由圖10-5中的parseFile()方法(圖10-5中的過程1.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類實現,編譯器就基本不會再對原始碼檔案進行操作了,後續的操作都建立在抽象語法樹之上。


2:填充符號表

        完成了語法分析和詞法分析之後,下一步就是填充符號表的過程,也就是圖10-5中enterTress()方法(圖10-5中的過程1.2)所做的事情。符號表(Symbol Table)是由一組符號地址和符號資訊構成的表格,讀者可以把它想象成雜湊表中K-V值對的形式(實際上符號表不一定是雜湊表實現,可以是有序號表、樹狀符號表、棧結構符號表等)。符號表中所登記的資訊在編譯的不同階段都要用到。在語義分析中,符號表所登記的內容將用於語義檢查(如檢查一個名字的使用和原先的說明是否一致)和產生中間程式碼。在目的碼生成階段,當對符號名進行地質分配時,符號表是地址分配的依據。

        在Javac原始碼中,填充符號表的過程由com.sun.tools.javac.comp.Enter類實現,此過程的出口是一個待處理列表(To Do List),包含了每一個編譯單元的抽象語法樹的頂級節點,以及package-info.java(如果存在的話)的頂級節點。

3:註解處理器

        在JDK1.5之後,Java語言提供了對註解(Annotation)的支援,這些註解與普通的Java程式碼一樣,是在執行期間發揮作用的。在JDK1.6中實現了JSR-269規範,提供了一組插入式註解處理器的標準API在編譯期間對註解進行處理,我們可以把它看做是一組編譯器的外掛,在這些外掛裡面,可以讀取、修改、新增抽象語法樹中的任意元素。如果這些外掛在處理註解期間對語法樹進行了修改,編譯器將回到解析及填充符號表的過程重新處理,直到所有插入式註解處理器都沒有再對語法樹進行修改為止,每一次迴圈稱為一個Round,也就是圖10-4中的迴環過程。

        有了編譯器註解處理的標準API後,我們的程式碼才有可能干涉編譯器的行為,由於語法樹中的任意元素,甚至包括程式碼註釋都可以在外掛之中訪問到,所以通過插入式註解處理器實現的外掛在功能上有很大的發揮空間。只要有足夠的創意,程式設計師可以使用插入式註解處理器來實現許多原本只能在編碼中完成的事情,本章最後會給出一個使用插入式註解處理器的簡單實戰。

        在Javac原始碼中,插入式註解處理器的初始化過程是在initPorcessAnnotations()方法中完成的,而它的執行過程則是在processAnnotations()方法中完成的,這個方法判斷是否還有新的註解處理器需要執行,如果有的話,通過com.sun.tools.javac.processing.JavacProcessingEnvironment類的doProcessing()方法生成一個新的JavaCompiler物件對編譯的後續步驟進行處理。

4:語義分析與位元組碼生成

        語法分析之後,編譯器獲得了程式程式碼的抽象語法樹表示,語法樹能表示一個結構正確的源程式的抽象,但無法保證源程式是符合邏輯的。而語義分析的主要任務是對結構上正確的源程式進行上下文有關性質的審查,如進行型別審查。舉個例子,假設有如下的3個變數定義語句:

int a = 1;
boolean b = false;
char c = 2;
        後續可能出現的賦值運算:
int d = a + c;
int d = b + c;
char d = a + c;

        後續程式碼中如果出現瞭如上3中賦值運算的話,那它們都能構成結構正確的語法樹,但是隻有第一種的寫法在語義上是沒有問題的,能夠通過編譯,其餘兩種在Java語言中是不合邏輯的,無法編譯(是否合乎語義邏輯必須限定在具體的語言與具體的上下文環境之中才有意義。如在C語言中,a、b、c的上下文定義不變,第2、3種寫法都是可以正確編譯)。

1:標註檢查

        Javac的編譯過程中,語義分析過程分為標註檢查以及資料及控制流分析兩個步驟,分別由圖10-5中所示的attribute()和flow()方法(分別對應圖10-5中的過程3.1和過程3.2)完成。

        標註檢查步驟檢查的內容包括諸如變數使用前是否已被宣告、變數與賦值之間的資料型別是否能夠匹配等。在標註檢查步驟中,還有一個重要的動作稱為常量摺疊,如果我們在程式碼中寫了如下定義:

int a = 1 + 2;
那麼在語法樹上仍然能看到字面量“1”、“2”以及操作符“+”,但是在經過常量摺疊之後,它們將會被摺疊為字面量“3”,如圖10-7所示,這個插入式表達(Infix Expression)的值已經在語法樹上標註出來了(ConstantExpressionValue:3)。由於編譯期間進行了常量摺疊,所以在程式碼裡面定義“a  = 1 + 2”比起直接定義“a = 3”,並不會增加程式執行期哪怕僅僅一個CPU指令的運算量。

        標註檢查步驟在Javac原始碼中的實現類是com.sun.tools.javac.comp.Attr和com.sun.tools.javac.comp.Check類。


2:資料及控制流分析

        資料及控制流分析是對程式上下文邏輯更進一步的驗證,它可以檢查出諸如程式區域性變數在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。編譯時期的資料及控制流分析與類載入時的資料及控制流分析的目的基本上是一致的,但校驗範圍有所區別,有一些校驗項只有在編譯期或執行期才能進行。下面舉一個關於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檔案是沒有任何一點區別的,通過第六章的講解我們已經知道,區域性變數與欄位(例項變數、類變數)是有區別的,它在常量池中沒有CONSTANT_Fieldref_info的符號引用,自然就沒有訪問標誌(Access_Flags)的資訊,甚至可能連名稱都不會保留下來(取決於編譯時的選項),自然在Class檔案中不可能知道一個區域性變數是不是宣告為final了。因此,將區域性變數宣告為final,對執行期是沒有影響的,變數的不變性僅僅由編譯器在編譯期間保障。在Javac的原始碼中,資料及控制流分析的入口是圖10-5中的flow()方法(對應圖10-5中的過程3.2),具體操作有com.sun.tools.javac.comp.Flow類來完成。

3:解語法糖

        語法糖(System Sugar),也成糖衣語法,是由英國電腦科學家彼得-約翰-蘭達(Peter J.Landin)發明的一個術語,指在計算機語言中新增的某種語法,這種語法對語言的功能並沒有影響,但是更方便程式設計師使用。通常來說,使用語法糖能夠增加程式的可讀性,從而減少程式程式碼出粗的機會。

        Java在現代程式語言之中屬於“低糖語言”(相對於C#及許多其他JVM語言來說),尤其是JDK1.5之前的版本,“低糖”語法也是Java語言被懷疑已經“落後”的一個表面理由。Java中最常用的語法糖主要是前面提到過的泛型(泛型並不一定都是語法糖實現,如C#的泛型就是直接由CLP支援的)、變長引數、自動裝箱/拆箱等,虛擬機器執行時不支援這些語法,它們在編譯階段還原回簡單地基礎語法結構,這個過程稱為解語法糖。Java的這些語法糖被解除後是什麼樣子,將在10.3節中詳細講述。

        在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(取決於目的碼的版本是否大於或等於JDK1.5)的append()操作。

        完成了對語法樹的遍歷和調整之後,就會把填充了所需資訊的符號表交給com.sun.tools.javac.jvm.ClassWriter類,由這個類的writeClass()方法輸出位元組碼,生成最終的Class檔案,到此為止整個編譯過程宣告結束。

3:Java語法糖的味道

        幾乎各種語言或多或少都提供過一些語法糖來方便程式設計師的程式碼開發,這些語法糖雖然不會提供實質性的功能改進,但是它們或能提高效率,或能提升語法的嚴謹性,或能較少編碼出錯的機會。不過也有一種觀點認為語法糖並不一定都是有益的,大量新增和使用“含糖”的語法,容易讓程式設計師產生依賴,無法看清語法糖的躺椅背後,程式程式碼的真實面目。

        總而言之,語法糖可以看做是編譯器實現的一些“小把戲”,這些“小把戲”可能會使得效率“大提升”,但我們也應該去了解這些“小把戲”背後的真實世界,那樣才能利用好它們,而不是被它們所誘惑。

1:泛型與型別擦除

        泛型是JDK1.5的一項新增特性,它的本質是引數化型別(Parametersized Type)的應用,也就是說所操作的資料型別被指定為一個引數。這種引數型別可以用在類、介面和方法的建立中,分別稱為泛型類、泛型介面和泛型方法。

        泛型思想早在C++語言的模板(Template)中就開始生根發芽,在Java語言處於還沒有出現泛型的版本時,只能通過Object是所有型別的父類和型別強制轉換兩個特點的配合來實現型別泛化。例如,在雜湊表的存取中,JDK1.5之前使用HashMap的get()方法,返回值就是一個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?"));
}

        這段程式碼編譯成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語言提供的偽泛型頗有微詞,當時甚至連《Thinking in Java》一書的作者Bruce Eckel也發表了一篇文章《這不是泛型!》來批評1.5中的泛型實現。

        在當時眾多的批評之中,有一些是比較表面的,還有一些從效能上說泛型會由於強制轉型操作和執行期缺少針對型別的優化等從而導致比C#的泛型慢一些,則是完全偏離了方法,姑且不論Java泛型是不是真的會比C#泛型慢,選擇從效能的角度上評價用於提升語義準確性的泛型思想就不太恰當。但筆者也並非在為Java的泛型辯護,它在某些場景下確實存在不足,筆者認為通過擦除法來實現泛型喪失了一些泛型實現應有的優雅,例如程式碼清單10-4的例子。

/**
 * 程式碼清單10-4
 * 當泛型遇到過載 1
 * @author Peter
 *
 */
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<String>編譯之後都會被擦除了,變成了一樣的原生型別List<E>,擦除動作導致這兩種方法的特徵簽名變得一模一樣。初步看來,無法過載的原因已經找到了,但真的就是如此麼?只能說,泛型擦除成相同的原生型別只是無法過載的其中一部分原因,請再看程式碼清單10-5中的內容
/**
 * 程式碼清單10-5
 * 當泛型遇到過載2
 * @author Peter
 *
 */
public class GenericTypes1 {

	public static String method(List<String> lits){
		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>());
	}
}

        執行結果:


        程式碼清單10-5與程式碼清單10-4的差別是兩個method方法添加了不同的返回值,由於這兩個返回值的加入,方法過載居然成功了,即這段程式碼可以被編譯和執行了。這是對Java語言中返回值不參與過載選擇的基本認知的挑戰嗎?

        程式碼清單10-5中的過載當然不是根據返回值來確定的,之所以這次能編譯和執行成功,是因為兩個method()方法加入了不同的返回值後才能共存一個Class檔案之中。第6章介紹Class檔案方法表(method_info)的資料結構時曾經提到過,方法過載要求方法具備不同的特徵簽名,返回值並不包含在方法的特徵簽名之中,所以返回值不參與過載選擇,但是在Class檔案格式之中,只要描述符不是完全一致的兩個方法就可以共存。也就是說,兩個方法如果有相同的名稱和特徵簽名,但返回值不同,那它們也是可以合法地共存於一個Class檔案中的。

        由於Java泛型的引入,各種場景(虛擬機器解析、反射等)下的方法呼叫都可能對原有的基礎產生影響和新的需求,如在泛型類中如何獲取傳入的引數化型別等。因此,JCP組織對虛擬機器規範做出了相應的修改,引入了諸如Signature、LocalVariableTypeTable等新的屬性用於解決伴隨泛型而來的引數型別的識別問題,Signature是其中最重要的一項屬性,它的作用就是儲存一個方法在位元組碼層面的特徵簽名,這個屬性中儲存的引數型別並不是原生型別,而是包括了引數化型別的資訊。修改後的虛擬機器規範要求所有能識別49.0以上版本的Class檔案的虛擬機器都要能正確地識別Signature引數。

        從上面的例子可以看出擦除法對實際編碼帶來的影響,由於List<String>和List<Integer>擦除後是同一個型別,我們只能新增兩個並不需要實際使用到的返回值才能完成過載,這是一種毫無優雅和美感可言的解決方案,並且存在一定語義上的混亂,比如上面腳註提到的,必須用Sun JDK1.6的Javac才能編譯成功,其他版本或者ECJ編譯器都可能拒絕編譯。

        另外,從Signature屬性的出現我們還可以得出結論,擦除法所謂的擦除,僅僅是對方法的Code屬性中的位元組碼進行擦除,實際上元資料中還是保留了泛型資訊,這也是我們能通過反射手段取得引數化型別的根本依據。

2:自動裝箱、拆箱與遍歷迴圈

        從純技術的角度來講,自動裝箱、自動拆箱與遍歷迴圈(Foreach迴圈)這些語法糖,無論是實現上還是思想上都不能和上文介紹的泛型相比,兩個的難度和深度都有很大的差距。它們是Java語言裡使用得最多的語法糖。通過程式碼清單10-6和程式碼10-7中所示的程式碼來看看語法糖在編譯後會發生什麼樣的變化。

/**
 * 程式碼清單10-6
 * 自動裝箱、拆箱與遍歷迴圈
 * @author Peter
 *
 */
public class Test03 {

	public static void main(String []args){
		
		List<Integer> list = Arrays.asList(1, 2, 3, 4);
		//如果在JDK1.7中,還有另外一種語法糖
		//能讓上面這句程式碼進一步簡寫成List<Integer> list = [1, 2, 3, 4];
		int sum = 0;
		for(int i : list){
			sum += i;
		}
		System.out.println(sum);
	}
}
/**
 * 程式碼清單10-7
 * 自動裝箱、拆箱與遍歷迴圈編譯之後
 * @author Peter
 *
 */
public class Test04 {
	
	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()方法,而遍歷迴圈則把程式碼還原成了迭代器的實現,這也是為何遍歷迴圈需要被遍歷的類實現Iterator介面的原因。最後再看看變長引數,它在呼叫的時候變成了一個數組型別的引數,在變長引數出現之前,程式設計師就是使用陣列來完成類似功能的。

        這些語法糖雖然看起來很簡單,但也不見得就沒有任何值得我們注意的地方,程式碼清單10-8演示了自動裝箱的一些錯誤用法。

/**
 * 程式碼清單10-8
 * 自動裝箱的陷阱
 * @author Peter
 *
 */
public class Test05 {
	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));
		
	}
}
        鑑於包裝類的“==”運算在不遇到算術運算的情況下不會自動拆箱,以及它們的equals方法不處理資料轉型的關係,試著分析下面的結果,並試著解除語法糖後引數會是神馬樣子。

3:編譯條件

        許多程式設計語言都提供了條件編譯的途徑,如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語言的條件編譯
 * @author Peter
 *
 */
public class Test06 {
	
	public static void main(String[] args) {
		if(true){
			System.out.println("block 1");
		}else{
			System.out.println("block 2");
		}
	}
}
上述程式碼編譯後Class檔案的反編譯結果:
public class Test06 {
	public static void main(String[] args) {
		System.out.println("block 1");
	}
}
        只能使用條件為常量的if語句才能達到上述效果,如果使用常量與其他帶有條件判斷能力的語句搭配,則可能在控制流分析中提示錯誤,被拒絕編譯,如程式碼清單10-10所示的程式碼就會被編譯器拒絕編譯。
/**
 * 程式碼清單10-10
 * 不能使用其他條件語句來完成條件編譯
 * @author Peter
 *
 */
public class Test07 {
	
	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語言還有不少其他的語法糖,如內部類、列舉類、斷言語句、對列舉和字串(在JDK1.7中支援)的switch支援、try語句中定義和關閉資源(在JDK1.7中支援)等,讀者可以通過跟蹤Javac原始碼、反編譯Class檔案等方式瞭解它們的本質實現。