[深入理解Java虛擬機器]第十章 程式編譯與程式碼優化-早期(編譯期)優化

Coding-lover發表於2015-11-03

概述

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

  • 前端編譯器:Sun的Javac、 Eclipse JDT中的增量式編譯器( ECJ ) 。
  • JIT編譯器:HotSpotVM的C1、C2編譯器。
  • AOT編譯器: GNU Compiler for the Java ( GCJ ) 、 Excelsior JET。

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

Javac編譯器

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

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/*目錄下的原始檔全部複製到工程的原始碼目錄中 ,如圖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個過程,分別是:

  • 解析與填充符號表的過程。
  • 插入式註解處理器的註解處理過程。
  • 分析與位元組碼生成過程。

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

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

解析與填充符號表

解析步驟由圖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外掛分析出來的某段程式碼的抽象語法樹檢視,讀者可以通過這張圖對抽象語法樹有一個直觀的認識。在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 ( 如果存在的話)的頂級節點。

註解處理器

在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種寫法都是可以正確編譯)。

1.標註檢查

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類。

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檔案是沒有任何一點區別的,通過第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 ) , 也稱糖衣語法,是由英國電腦科學家彼得•約翰•蘭達 ( 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類中完成。

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

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

Java語法糖的味道

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

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

泛型與型別擦除

泛型是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(根據已有元素生成新的語法樹元素)等,讀者有興趣的話可以參考它們官方站點的相關內容。

相關文章