jdk原始碼解析(九)——早期(編譯期)優化
我們上一節,瞭解了執行引擎,那麼我們如何將程式碼從java檔案變成class檔案呢,class檔案又如何執行與優化呢?這節,我們先了解編譯期的優化
1 概述
Java 語言的 “編譯期” 其實是一段 “不確定” 的操作過程,因為它可能是指一個前端編譯器(其實叫 “編譯器的前端” 更準確一些)把 *.java 檔案轉變成 *.class 檔案的過程;也可能是指虛擬機器的後端執行期編譯器(JIT 編譯器,Just In Time Compiler)把位元組碼轉變成機器碼的過程;還可能是指使用靜態提前編譯器(AOT 編譯器,Ahead Of Time Compiler)直接把 *.java 檔案編譯成本地機器程式碼的過程
- 前端編譯器:Sun 的 Java、Eclipse JDT 中的增量式編譯器(ECJ)。
- JIT 編譯器:HotSpot VM 的 C1、C2 編譯器。
- AOT 編譯器:GNU Compiler for the Java (GCJ)、Excelsior JET。
這 3 類過程中最符合大家對 Java 程式編譯認知的應該是第一類,在本章的後續文字裡,筆者提到的 “編譯期” 和 “編譯器” 都僅限於第一類編譯過程,把第二類編譯過程留到下一章中討論。限制了編譯範圍後,我們對於 “優化” 二字的定義就需要寬鬆一些,因為 Javac 這類編譯器對程式碼的執行效率幾乎沒有任何優化措施(在 JDK 1.3 之後,Javac 的 -O 優化引數就不再有意義)。虛擬機器設計團隊把對效能的優化集中到了後端的即時編譯器中
2 javac編譯器
分析原始碼是瞭解一項技術的實現內幕的最有效的手段,Javac 編譯器不像 HotSpot 虛擬機器那樣使用 C++ 語言(包含少量 C 語言)實現,它本身就是一個由 Java 語言編寫的程式,這為純 Java 的程式設計師瞭解它的編譯過程帶來了很大的便利。
2.1 Javac 的原始碼與除錯
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/* 目錄下的原始檔全部複製到工程的原始碼目錄中,(本人jdk原始碼openjdk\langtools\src\share\classes\com\sun\)如圖 所示。
匯入程式碼期間,原始碼檔案 “AnnotationProxyMaker.java” 可能會提示 “AccessA Restriction”,被 Eclipse 拒絕編譯,
這是由於 Eclipse 的 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 個過程,分別是:
- 解析與填充符號表過程。
- 插入式註解處理器的註解處理過程。
- 分析與位元組碼生成過程。
這 3 個步驟之間的關係與互動順序如圖 10-4 所示。
Javac 編譯動作的入口是 com.sun.tools.javac.main.JavaCompiler 類,上述 3 個過程的程式碼邏輯集中在這個類的 compile() 和 compile2() 方法中,其中主體程式碼如圖 10-5 所示,整個編譯最關鍵的處理就由圖中標註的 8 個方法來完成,下面我們具體看一下這 8 個方法實現了什麼功能。
2.2 解析與填充符號表
解析步驟由圖 10-5 中的 parseFiles() 方法(圖 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 外掛(地址:http://www.eclipse.org/jdt/ui/astview/index.php)分析出來的某段程式碼的抽象語法樹檢視,讀者可以通過這張圖對抽象語法樹有一個直觀的認識。在 Javac 的原始碼中,語法分析過程由 com.sun.tools.javac.parser.Parser 類實現,這個階段產出的抽象語法樹由com.sun.tools.javac.tree.JCTree 類表示,經過這個步驟之後,編譯器就基本不會再對原始碼檔案進行操作了,後續的操作都建立在抽象語法樹之上。
2 填充符號表
完成了語法分析和詞法分析之後,下一步就是填充符號表的過程,也就是圖 10-5 中 enterTrees() 方法(圖 10-5 中的過程 1.2)所做的事情。符號表(Symbol Table)是由一組符號地址和符號資訊構成的表格,讀者可以把它想象成雜湊表中 K-V 值對的形式(實際上符號表不一定是雜湊表實現,可以是有序符號表、樹狀符號表、棧結構符號表等)。符號表中所登記的資訊在編譯的不同階段都要用到。在語義分析中,符號表所登記的內容將用於語義檢查(如檢查一個名字的使用和原先的說明是否一致)和產生中間程式碼。在目的碼生成階段,當對符號名進行地址分配時,符號表是地址分配的依據。
在 Javac 原始碼中,填充符號表的過程由 com.sun.tools.javac.comp.Enter 類實現,此過程的出口是一個待處理列表(To Do List),包含了每一個編譯單元的抽象語法樹的頂級節點,以及 package-info.java(如果存在的話)的頂級節點。
2.3 註解處理器
在 JDK 1.5 之後,Java 語言提供了對註解(Annotation)的支援,這些註解與普通 Java 程式碼一樣,是在執行期間發揮作用的。在 JDK 1.6 中實現了 JSR-269 規範(JSR-269:Pluggable Annotations Processing API(插入式註解處理 API)),提供了一組插入式註解處理器的標準 API 在編譯期間對註解進行處理,我們可以把它看做是一組編譯器的外掛,在這些外掛裡面,可以讀取、修改、新增抽象語法樹中的任意元素。如果這些外掛在處理註解期間對語法樹進行了修改,編譯器將回到解析及填充符號表的過程重新處理,知道所有插入式註解處理器都沒有再對語法樹進行修改為止,每一次迴圈稱為一個 Round,也就是圖 10-4 中的迴環過程。
有了編譯器註解處理的標準 API 後,我們的程式碼才有可能干涉編譯器的行為,由於語法樹中的任意元素,甚至包括程式碼註釋都可以在外掛之中訪問到,所以通過插入式註解處理器實現的外掛在功能上有很大的發揮空間。只要有足夠的創意,程式設計師可以使用插入式註解處理器來實現許多原本只能在編碼中完成的事情。
在 Javac 原始碼中,插入式註解處理器的初始化過程是在 initProcessAnnotations() 方法中完成的,而它的執行過程則是在 processAnnotations() 方法中完成的,這個方法判斷是否還有新的註解處理器需要執行,如果有的話,通過 com.sun.tools.javac.processing.JavacProcessingEnvironment 類的 doProcessing() 方法生成一個新的 JavaCompiler 物件對編譯的後續步驟進行處理。
2.4 語義分析與位元組碼生成
語法分析之後,編譯器獲得了程式程式碼的抽象語法樹表示,語法樹能表示一個結構正確的源程式的抽象,但無法保證源程式是符合邏輯的。而語義分析的主要任務是對結構上正確的源程式進行上下文有關性質的審查,如進行型別審查。舉個例子,假設有如下的 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 的編譯過程中,語義分析過程分為標註檢查以及資料及控制流分析兩個步驟,分別由圖 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。
// 方法一帶有 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 類來完成。
3. 解語法糖
語法糖(Syntactic Sugar),也稱糖衣語法,是由英國電腦科學家彼得·約翰·蘭達(Perter 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.normalizeDef() 方法來實現。除了生成構造器以外,還有其他的一些程式碼替換工作用於優化程式的實現邏輯,如把字串的加操作替換為 StringBuffer 或 StringBuilder(取決於目的碼的版本是否大於或等於 JDK 1.5)的 append() 操作等。
完成了對語法樹的遍歷和調整之後,就會把填充了所有所需資訊的符號表交給 com.sun.tools.javac.jvm.ClassWriter 類,由這個類的 writeClass() 方法輸出位元組碼,生成最終的 Class 檔案,到此為止整個編譯過程宣告結束。
3 java語法糖的味道
幾乎各種語言或多或少都提供過一些語法糖來方便程式設計師的程式碼開發,這些語法糖雖然不會提供實質性的功能改進,但是它們或能提高效率,或能提升語法的嚴謹性,或能減少編碼出錯的機會。不過也有一種觀點認為語法糖並不一定都是有益的,大量新增和使用 “含糖” 的語法,容易讓程式設計師產生依賴,無法看清語法糖的糖衣背後,程式程式碼的真實面目。
總而言之,語法糖可以看做是編譯器實現的一些 “小把戲”,這些 “小把戲” 可能會使得效率 “大提升”,但我們也應該去了解這些 “小把戲” 背後的真實世界,那樣才能利用好它們,而不是被它們所迷惑。
3.1 泛型與型別擦除
泛型是 JDK 1.5 的一項新增特性,它的本質是引數化型別(Parametersized Type)的應用,也就是說操作的資料型別被指定為一個引數。這種引數型別可以用在類、介面和方法的建立中,分別稱為泛型類、泛型介面和泛型方法。
泛型思想早在 C++ 語言的模板(Template)中就開始生根發芽,在 Java 語言處於還沒有出現泛型的版本時,只能通過 Object 是所有型別的父類和型別強制轉換兩個特點的配合來實現型別泛化。例如,在雜湊表的存取中,JDK 1.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 泛型的例子,我們可以看一下它編譯後的結果是怎樣的。
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 檔案,然後再用位元組碼反編譯工具進行反編譯後,將會發現泛型都不見了(用jd-gui 檢視發現宣告的時候泛型還在,其他地方就變成了強制型別轉換),程式又變回了 Java 泛型出現之前的寫法,泛型型別都變回了原生型別。
當初 JDK 設計團隊為什麼選擇型別擦除的方式來實現 Java 語言的泛型支援呢?是因為實現簡單、相容性考慮還是別的原因?我們已不得而知,但確實有不少人對 Java 語言提供的偽泛型頗有微詞,當時甚至連《Thinking in Java》一書的作者 Bruce Eckel 也發表了一篇《這不是泛型!》來批評 JDK 1.5 中的泛型實現。
在當時眾多的批評之中,有一些是比較表面的,還有一些從效能上說泛型會由於強制轉型操作和執行期缺少針對型別的優化等從而導致比 C# 的泛型慢一些,則是完全偏離了方向,姑且不論 Java 泛型是不是真的會比 C# 泛型慢,選擇從效能的角度上評價用於提升語義準確性的泛型思想就不太恰當。但筆者也並非在為 Java 的泛型辯護,它在某些場景下確實存在不足,筆者認為通過擦除法來實現泛型喪事了一些泛型思想應有的優雅,例如程式碼清單 10-4 的例子。
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>,擦除動作導致這兩種方法的特徵簽名變得一模一樣。初步看來,無法過載的原因已經找到了,但真的就是如此嗎?只能說,泛型擦除成相同的原生型別只是無法過載的其中一部分原因,請再接著看一看程式碼清單
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 方法添加了不同的返回值,由於這兩個返回值的加入,方法過載居然成功了,即這段程式碼可以被編譯和執行(注:測試的時候請使用 Sun JDK 1.6(1.7 和 1.8 也無法進行編譯) 進行編譯,其他編譯器,如 Eclipse JDT 的 ECJ 編譯器,仍然可能會拒絕這段程式碼)了。這是對 Java 語言中返回值不參與過載選擇的基本認知的挑戰嗎?
程式碼清單 10-5 中的過載當然不是根據返回值來確定的,之所以這次能編譯和執行成功,是因為兩個 method() 方法加入了不同的返回值後才能共存在一個 Class 檔案之中。前面介紹 Class 檔案方法表(method_info)的資料結構時曾經提到過,方法過載要求方法具備不同的特徵簽名,返回值並不包含在方法的特徵簽名之中,所以返回值不參與過載選擇,但是在 Class 檔案格式之中,只要描述符不是完全一致的兩個方法就可以共存。也就是說,兩個方法如果有相同的名稱和特徵簽名,但返回值不同,那它們也是可以合法地共存於一個 Class 檔案中的。
由於 Java 泛型的引入,各種場景(虛擬機器解析、反射等)下的方法呼叫都有可能對原有的基礎產生影響和新的需求,如在泛型類中如何獲取傳入的引數化型別等。因此,JCP 組織對虛擬機器規範作出了相應的修改,引入了諸如 Signature、LocalVariableTable 等新的屬性用於解決伴隨而來的引數型別的識別問題,Signature是其中最重要的一項屬性,它的作用就是儲存一個方法在位元組碼層面的特徵簽名,這個屬性中儲存的引數型別並不是原生型別,而是包括了引數化型別的資訊。修改後的虛擬機器規範要求所有能識別 49.0 以上版本的 Class 檔案的虛擬機器都要能正確地識別 Signature 引數。
從上面的例子可以看到擦除法對實際編碼帶來的影響,由於 List<String> 和 List<Integer> 擦除後是同一個型別,我們只能新增兩個並不需要實際使用到的返回值才能完成過載,這是一種毫無優雅和美感可言的解決方案,並且存在一定語意上的混亂。
另外,從 Signature 屬性的出現我們還可以得出結論,擦除法所謂的擦除,僅僅是對方法的 Code 屬性中的位元組碼進行擦除,實際上元資料中還是保留了泛型資訊,這也是我們能通過反射手段取得引數化型別的根本依據。
3.2 自動裝箱、拆箱與遍歷迴圈
從純技術的角度來講,自動裝箱、自動拆箱與遍歷迴圈(Foreach 迴圈)這些語法糖,無論是實現上還是思想上都不能和上文介紹的泛型相比,兩者的難度和深度都有很大差距。專門拿出一節來講解它們只有一個理由:毫無疑問,它們是 Java 語言裡使用得最多的語法糖。我們通過程式碼清單 10-6 和程式碼清單 10-7 中所示的程式碼來看看這些語法糖在編譯後會發生什麼樣的變化。
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4);
// 如果在 JDK 1.8 中,還有另外一顆語法糖
// 能讓上面這句程式碼進一步簡寫成 List<Integer> list = [1, 2, 3, 4];
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}
程式碼清單 10-6 中一共包含了泛型、自動裝箱、自動拆箱、遍歷迴圈與變長引數 5 種語法糖,程式碼清單 10-7 則展示了它們在編譯後的變化。泛型就不必說了,自動裝箱、拆箱在編譯之後被轉換成了對應的包裝和還原方法,如本例中的 Integer.valueOf() 與 Integer.intValue() 方法,而遍歷迴圈則把程式碼還原 成了迭代器的實現,這也是為何遍歷迴圈需要被遍歷的類實現Iterable 介面的原因。最後再看看變長引數,它在呼叫的時候變成了一個數組型別的引數,在變長引數出現之前,程式設計師就是使用陣列來完成類似功能的。
這些語法糖雖然看起來很簡單,但也不見得就沒有任何值得我們注意的地方,程式碼清單 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() 方法不處理資料轉型的關係,筆者建立在實際編碼中儘量避免這樣使用自動裝箱與拆箱。
3.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);”
public static void main(String[] args) {
if (true) {
System.out.println("block 1");
} else {
System.out.println("block 2");
}
}
上述程式碼編譯後 Class 檔案的反編譯結果:
只能使用條件為常量的 if 語句才能達到上述效果,如果使用常量與其他帶有條件判斷能力的語句搭配,則可能在控制流分析中提示錯誤,被拒絕編譯,如程式碼清單 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 檔案等方式瞭解它們的本質實現。
實戰這裡不做舉例,可以自己觀看。