1. 程式人生 > 其它 >深入理解java虛擬機器筆記-前端編譯與優化

深入理解java虛擬機器筆記-前端編譯與優化

一、概述

在Java技術下談“編譯期”而沒有具體上下文語境的話,因為它可能是指一個前端編譯器把.java檔案轉變成.class檔案的過程; 也可能是指Java虛擬機器的即時編譯器執行期把位元組碼轉變成本地機器碼的過程; 還可能是指使用靜態的提前編譯器(常稱AOT編譯器, Ahead Of Time Compiler) 直接把程式編譯成與目標機器指令集相關的二進位制程式碼的過程。

下面列舉了這3類編譯過程裡一些比較有代表性的編譯器產品:

·前端編譯器: JDK的Javac、 Eclipse JDT中的增量式編譯器(ECJ) 。

·即時編譯器: HotSpot虛擬機器的C1、 C2編譯器, Graal編譯器。

·提前編譯器: JDK的Jaotc、 GNU Compiler for the Java(GCJ)、 Excelsior JET。

這3類過程中最符合普通程式設計師對Java程式編譯認知的應該是第一類, 本章標題中的“前端”指的也是這種由前端編譯器完成的編譯行為。

因為Javac這類前端編譯器對程式碼的執行效率幾乎沒有任何優化措施可言。因為Java虛擬機器設計團隊選擇把對效能的優化全部集中到執行期的即時編譯器中, 這樣可以讓那些不是由Javac產生的Class檔案(如JRuby、 Groovy等語言的Class檔案) 也同樣能享受到編譯器優化措施所帶來的效能紅利。

但是, 如果把“優化”的定義放寬, 把對開發階段的優化也計算進來的話, Javac確實是做了許多針對Java語言編碼過程的優化措施來降低程式設計師的編碼複雜度、 提高編碼效率。 相當多新生的Java語法特性, 都是靠編譯器的“語法糖”來實現, 而不是依賴位元組碼或者Java虛擬機器的底層改進來支援。 我們可以這樣認為, Java中即時編譯器在執行期的優化過程, 支撐了程式執行效率的不斷提升; 而前端編譯器在編譯期的優化過程, 則是支撐著程式設計師的編碼效率和語言使用者的幸福感的提高。

二、javac編譯器

2.1.Javac的原始碼與除錯

從Javac程式碼的總體結構來看, 編譯過程大致可以分為1個準備過程和3個處理過程, 它們分別如下所示。

1) 準備過程: 初始化插入式註解處理器。

2) 解析與填充符號表過程, 包括:

·詞法、 語法分析。 將原始碼的字元流轉變為標記集合, 構造出抽象語法樹。

·填充符號表。 產生符號地址和符號資訊。

3) 插入式註解處理器的註解處理過程: 插入式註解處理器的執行階段, 本章的實戰部分會設計一個插入式註解處理器來影響Javac的編譯行為。

4) 分析與位元組碼生成過程, 包括:

·標註檢查。 對語法的靜態資訊進行檢查。

·資料流及控制流分析。 對程式動態執行過程進行檢查。

·解語法糖。 將簡化程式碼編寫的語法糖還原為原有的形式。

·位元組碼生成。 將前面各個步驟所生成的資訊轉化成位元組碼。

上述3個處理過程裡, 執行插入式註解時又可能會產生新的符號, 如果有新的符號產生, 就必須轉回到之前的解析、 填充符號表的過程中重新處理這些新符號, 從總體來看, 三者之間的關係與互動順序如圖10-4所示

 

 

我們可以把上述處理過程對應到程式碼中, Javac編譯動作的入口是com.sun.tools.javac.main.JavaCompiler類, 上述3個過程的程式碼邏輯集中在這個類的compile()和compile2()方法裡, 其中主體程式碼如圖10-5所示, 整個編譯過程主要的處理由圖中標註的8個方法來完成。

 

 

2.2 解析與填充符號表

解析過程由圖10-5中的parseFiles()方法(圖10-5中的過程1.1) 來完成, 解析過程包括了經典程式編譯原理中的詞法分析和語法分析兩個步驟。

1.詞法、 語法分析

詞法分析是將原始碼的字元流轉變為標記(Token) 集合的過程, 單個字元是程式編寫時的最小元素, 但標記才是編譯時的最小元素。 關鍵字、 變數名、 字面量、 運算子都可以作為標記, 如“int a=b+2”這句程式碼中就包含了6個標記, 分別是int、 a、 =、 b、 +、 2, 雖然關鍵字int由3個字元構成, 但是它只是一個獨立的標記, 不可以再拆分。 在Javac的原始碼中, 詞法分析過程由com.sun.tools.javac.parser.Scanner類來實現。

語法分析是根據標記序列構造抽象語法樹的過程, 抽象語法樹(Abstract Syntax Tree, AST) 是一種用來描述程式程式碼語法結構的樹形表示方式, 抽象語法樹的每一個節點都代表著程式程式碼中的一個語法結構(Syntax Construct) , 例如包、 型別、 修飾符、 運算子、 介面、 返回值甚至連程式碼註釋等都可以是一種特定的語法結構。

圖10-6是Eclipse AST View外掛分析出來的某段程式碼的抽象語法樹檢視, 讀者可以通過這個外掛工具生成的視覺化介面對抽象語法樹有一個直觀的認識。 在Javac的原始碼中, 語法分析過程由com.sun.tools.javac.parser.Parser類實現, 這個階段產出的抽象語法樹是以com.sun.tools.javac.tree.JCTree類表示的。

經過詞法和語法分析生成語法樹以後, 編譯器就不會再對原始碼字元流進行操作了, 後續的操作都建立在抽象語法樹之上。

2.填充符號表

完成了語法分析和詞法分析之後, 下一個階段是對符號表進行填充的過程, 也就是圖10-5中enterTrees()方法要做的事情。

符號表(Symbol Table) 是由一組符號地址和符號資訊構成的資料結構, 讀者可以把它類比想象成雜湊表中鍵值對的儲存形式(實際上符號表不一定是雜湊表實現, 可以是有序符號表、 樹狀符號表、 棧結構符號表等各種形式) 。

符號表中所登記的資訊在編譯的不同階段都要被用到。 譬如在語義分析的過程中, 符號表所登記的內容將用於語義檢查和產生中間程式碼, 在目的碼生成階段, 當對符號名進行地址分配時, 符號表是地址分配的直接依據。 在Javac原始碼中, 填充符號表的過程由com.sun.tools.javac.comp.Enter類實現, 該過程的產出物是一個待處理列表, 其中包含了每一個編譯單元的抽象語法樹的頂級節點, 以及package-info.java(如果存在的話) 的頂級節點。

2.3 註解處理器

JDK 5之後, Java語言提供了對註解(Annotations) 的支援, 註解在設計上原本是與普通的Java程式碼一樣, 都只會在程式執行期間發揮作用的。

但在JDK 6中又提出並通過了JSR-269提案[1], 該提案設計了一組被稱為“插入式註解處理器”的標準API, 可以提前至編譯期對程式碼中的特定註解進行處理,從而影響到前端編譯器的工作過程。 我們可以把插入式註解處理器看作是一組編譯器的外掛, 當這些外掛工作時, 允許讀取、 修改、 新增抽象語法樹中的任意元素。

如果這些外掛在處理註解期間對語法樹進行過修改, 編譯器將回到解析及填充符號表的過程重新處理, 直到所有插入式註解處理器都沒有再對語法樹進行修改為止, 每一次迴圈過程稱為一個輪次(Round) , 這也就對應著圖10-4的那個迴環過程。

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

譬如Java著名的編碼效率工具Lombok, 它可以通過註解來實現自動產生getter/setter方法、 進行空置檢查、 生成受查異常表、 產生equals()和hashCode()方法, 等等, 幫助開發人員消除Java的冗長程式碼, 這些都是依賴插入式註解處理器來實現的, 本章最後會設計一個如何使用插入式註解處理器的簡單實戰。

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

2.4 語義分析與位元組碼生成

經過語法分析之後, 編譯器獲得了程式程式碼的抽象語法樹表示, 抽象語法樹能夠表示一個結構正確的源程式, 但無法保證源程式的語義是符合邏輯的。 而語義分析的主要任務則是對結構上正確的源程式進行上下文相關性質的檢查, 譬如進行型別檢查、 控制流檢查、 資料流檢查, 等等。

1.標註檢查

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

標註檢查步驟要檢查的內容包括諸如變數使用前是否已被宣告、 變數與賦值之間的資料型別是否能夠匹配, 等等, 剛才3個變數定義的例子就屬於標註檢查的處理範疇。 在標註檢查中, 還會順便進行一個稱為常量摺疊的程式碼優化, 這是Javac編譯器會對原始碼做的極少量優化措施之一則在抽象語法樹上仍然能看到字面量“1”“2”和操作符“+”號, 但是在經過常量摺疊優化之後, 它們將會被摺疊為字面量“3”。

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

2.資料及控制流分析

資料流分析和控制流分析是對程式上下文邏輯更進一步的驗證, 它可以檢查出諸如程式區域性變數在使用前是否有賦值、 方法的每條路徑是否都有返回值、 是否所有的受查異常都被正確處理了等問題。 編譯時期的資料及控制流分析與類載入時的資料及控制流分析的目的基本上可以看作是一致的,但校驗範圍會有所區別, 有一些校驗項只有在編譯期或執行期才能進行。

在這兩個foo()方法中, 一個方法的引數和區域性變數定義使用了final修飾符, 另外一個則沒有, 在程式碼編寫時程式肯定會受到final修飾符的影響, 不能再改變arg和var變數的值, 但是如果觀察這兩段程式碼編譯出來的位元組碼, 會發現它們是沒有任何一點區別的, 每條指令, 甚至每個位元組都一模一樣。

通過第6章對Class檔案結構的講解我們已經知道, 區域性變數與類的欄位(例項變數、 類變數) 的儲存是有顯著差別的, 區域性變數在常量池中並沒有CONSTANT_Fieldref_info的符號引用, 自然就不可能儲存有訪問標誌(access_flags) 的資訊, 甚至可能連變數名稱都不一定會被保留下來(這取決於編譯時的編譯器的引數選項) , 自然在Class檔案中就不可能知道一個區域性變數是不是被宣告為final了。

因此,可以肯定地推斷出把區域性變數宣告為final, 對執行期是完全沒有影響的, 變數的不變性僅僅由Javac編譯器在編譯期間來保障, 這就是一個只能在編譯期而不能在執行期中檢查的例子。 在Javac的原始碼中,資料及控制流分析的入口是圖10-5中的flow()方法 , 具體操作由com.sun.tools.javac.comp.Flow類來完成。

3.解語法糖

語法糖(Syntactic Sugar) , 也稱糖衣語法, 是由英國電腦科學家Peter J.Landin發明的一種程式設計術語, 指的是在計算機語言中新增的某種語法, 這種語法對語言的編譯結果和功能並沒有實際影響,但是卻能更方便程式設計師使用該語言。 通常來說使用語法糖能夠減少程式碼量、 增加程式的可讀性, 從而 減少程式程式碼出錯的機會。

Java在現代程式語言之中已經屬於“低糖語言”(相對於C#及許多其他Java虛擬機器語言來說) , 尤其是JDK 5之前的Java。 “低糖”的語法讓Java程式實現相同功能的程式碼量往往高於其他語言, 通俗地說就是會顯得比較“囉嗦”, 這也是Java語言一直被質疑是否已經“落後”了的一個浮於表面的理由。

Java中最常見的語法糖包括了前面提到過的泛型(其他語言中泛型並不一定都是語法糖實現, 如C#的泛型就是直接由CLR支援的) 、 變長引數、 自動裝箱拆箱, 等等, Java虛擬機器執行時並不直接支援這些語法, 它們在編譯階段被還原回原始的基礎語法結構, 這個過程就稱為解語法糖。

在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()方法就是在這個階段被新增到語法樹之中的。 請注意這裡的例項構造器並不等同於預設建構函式, 如果使用者程式碼中沒有提供任何建構函式, 那編譯器將會新增一個沒有引數的、 可訪問性與當前型別一致的預設建構函式, 這個工作在填充符號表階段中就已經完成。

init()和clinit()這兩個構造器的產生實際上是一種程式碼收斂的過程, 編譯器會把語句塊(對於例項構造器而言是“{}”塊, 對於類構造器而言是“static{}”塊) 、 變數初始化(例項變數和類變數) 、 呼叫父類的例項構造器(僅僅是例項構造器, clinit()方法中無須呼叫父類的clinit()方法, Java虛擬機器會自動保證父類構造器的正確執行, 但在clinit()方法中經常會生成呼叫java.lang.Object的init()方法的程式碼) 等操作收斂到init()和clinit()方法之中, 並且保證無論原始碼中出現的順序如何, 都一定是按先執行父類的例項構造器, 然後初始化變數, 最後執行語句塊的順序進行, 上面所述的動作由Gen::normalizeDefs()方法來實現。

除了生成構造器以外, 還有其他的一些程式碼替換工作用於優化程式某些邏輯的實現方式, 如把字串的加操作替換為StringBuffer或StringBuilder(取決於目的碼的版本是否大於或等於JDK 5) 的append()操作, 等等。

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