深入JVM:(十二)編譯期優化

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

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

下面要介紹的編譯期優化僅限于第一類編譯期

Javac這類編譯器對代碼的運行效率幾乎沒有任何優化措施。虛擬機設計團隊把對性能的優化集中到了后端的即時編譯器中,這樣可以讓那些不是由Javac產生的Class文件(如JRuby、Groovy等語言的Class文件)也同樣能享受到編譯器優化所帶來的好處。但是Javac做了許多針對Java語言編碼過程的優化措施來改善程序員的編碼風格和提高編碼效率。相當多新生的Java語法特性,都是靠編譯器的“語法糖”來實現,而不是依賴虛擬機的底層改進來支持,可以說,Java中即時編譯器在運行期的優化過程對于程序運行來說更重要,而前端編譯器在編譯期的優化過程對于程序編碼來說關系更加密切。

Javac的編譯過程大致可以分為3個過程,分別是:

  • 解析與填充符號表過程。
  • 插入式注解處理器的注解處理過程。
  • 分析與字節碼生成過程。

一、 解析與填充符號表

1.詞法、語法分析

詞法分析是將源代碼的字符流轉變為標記(Token)集合,單個字符是程序編寫過程的最小元素,而標記則是編譯過程的最小元素,關鍵字、變量名、字面量、運算符都可以成為標記,如"int a=b+2"這句代碼包含了6個標記,分別是int、a、=、b、+、2,雖然關鍵字int由3個字符構成,但是它只是一個Token,不可再拆分。

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

2.填充符號表

完成了語法分析和詞法分析之后,下一步就是填充符號表的過程。符號表(Symbol Table)是由一組符號地址和符號信息構成的表格,可以把它想象成哈希表中K-V值對的形式(實際上符號表不一定是哈希表實現,可以是有序符號表、樹狀符號表、棧結構符號表等)。符號表中所登記的信息在編譯的不同階段都要用到。在語義分析中,符號表所登記的內容將用于語義檢查(如檢查一個名字的使用和原先的說明是否一致)和產生中間代碼。在目標代碼生成階段,當對符號名進行地址分配時,符號表是地址分配的依據。

二、注解處理器

在JDK 1.6中實現了JSR-269規范[1],提供了一組插入式注解處理器的標準API在編譯期間對注解進行處理,我們可以把它看做是一組編譯器的插件,在這些插件里面,可以讀取、修改、添加抽象語法樹中的任意元素。

有了編譯器注解處理的標準API后,我們的代碼才有可能干涉編譯器的行為,由于語法樹中的任意元素,甚至包括代碼注釋都可以在插件之中訪問到,“所以通過插入式注解處理器實現的插件在功能上有很大的發揮空間。

三、語義分析與字節碼生成

語法分析之后,編譯器獲得了程序代碼的抽象語法樹表示,語法樹能表示一個結構正確的源程序的抽象,但無法保證源程序是符合邏輯的。而語義分析的主要任務是對結構上正確的源程序進行上下文有關性質的審查,如進行類型審查。

1.標注檢查
Javac的編譯過程中,語義分析過程分為標注檢查以及數據及控制流分析兩個步驟
標注檢查步驟檢查的內容包括諸如變量使用前是否已被聲明、變量與賦值之間的數據類型是否能夠匹配等。在標注檢查步驟中,還有一個重要的動作稱為常量折疊,如果我們在代碼中寫了如下定義:

int a=1+2;

那么在語法樹上仍然能看到字面量“1”、“2”以及操作符“+”,但是在經過常量折疊之后,它們將會被折疊為字面量“3”

2.數據及控制流分析
數據及控制流分析是對程序上下文邏輯更進一步的驗證,它可以檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。編譯時期的數據及控制流分析與類加載時的數據及控制流分析的目的基本上是一致的,但校驗范圍有所區別,有一些校驗項只有在編譯期或運行期才能進行。

//方法一帶有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,對運行期是沒有影響的,變量的不變性僅僅由編譯器在編譯期間保障。

3.解語法糖
語法糖(Syntactic Sugar),也稱糖衣語法,是由英國計算機科學家彼得·約翰·蘭達(Peter J.Landin)發明的一個術語,指在計算機語言中添加的某種語法,這種語法對語言的功能并沒有影響,但是更方便程序員使用。通常來說,使用語法糖能夠增加程序的可讀性,從而減少程序代碼出錯的機會。

Java在現代編程語言之中屬于“低糖語言”(相對于C#及許多其他JVM語言來說),尤其是JDK 1.5之前的版本,“低糖”語法也是Java語言被懷疑已經“落后”的一個表面理由。Java中最常用的語法糖主要是泛型(泛型并不一定都是語法糖實現,如C#的泛型就是直接由CLR支持的)、變長參數、自動裝箱/拆箱等,虛擬機運行時不支持這些語法,它們在編譯階段還原回簡單的基礎語法結構,這個過程稱為解語法糖。

4.字節碼生成
字節碼生成是Javac編譯過程的最后一個階段,在Javac源碼里面由com.sun.tools.javac.jvm.Gen類來完成。字節碼生成階段不僅僅是把前面各個步驟所生成的信息(語法樹、符號表)轉化成字節碼寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作。

例如,實例構造器<init>()方法和類構造器<clinit>()方法就是在這個階段添加到語法樹之中的。

四、Java語法糖的味道

1.泛型與類型擦除
泛型是JDK 1.5的一項新增特性,它的本質是參數化類型(Parametersized Type)的應用,也就是說所操作的數據類型被指定為一個參數。這種參數類型可以用在類、接口和方法的創建中,分別稱為泛型類、泛型接口和泛型方法。
泛型思想早在C++語言的模板(Template)中就開始生根發芽,在Java語言處于還沒有出現泛型的版本時,只能通過Object是所有類型的父類和類型強制轉換兩個特點的配合來實現類型泛化。例如,在哈希表的存取中,JDK 1.5之前使用HashMap的get()方法,返回值就是一個Object對象,由于Java語言里面所有的類型都繼承于java.lang.Object,所以Object轉型成任何對象都是有可能的。

Java語言中的泛型則不一樣,它只在程序源碼中存在,在編譯后的字節碼文件中,就已經替換為原來的原生類型(Raw Type,也稱為裸類型)了,并且在相應的地方插入了強制轉型代碼,因此,對于運行期的Java語言來說,ArrayList<int>與ArrayList<String>就是同一個類,所以泛型技術實際上是Java語言的一顆語法糖,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文件,然后再用字節碼反編譯工具進行反編譯后,將會發現泛型都不見了,程序又變回了Java泛型出現之前的寫法,泛型類型都變回了原生類型
泛型擦除后的例子:

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?"));
}

2.自動裝箱、拆箱與遍歷循環
從純技術的角度來講,自動裝箱、自動拆箱與遍歷循環(Foreach循環)這些語法糖,無論是實現上還是思想上都不能和上文介紹的泛型相比,兩者的難度和深度都有很大差距。
自動裝箱、拆箱與遍歷循環:

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);
}

自動裝箱、拆箱與遍歷循環編譯之后:

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);
}

自動裝箱、拆箱在編譯之后被轉化成了對應的包裝和還原方法,如本例中的Integer.valueOf()與Integer.intValue()方法,而遍歷循環則把代碼還原成了迭代器的實現,這也是為何遍歷循環需要被遍歷的類實現Iterable接口的原因。最后再看看變長參數,它在調用的時候變成了一個數組類型的參數,在變長參數出現之前,程序員就是使用數組來完成類似功能的。

3.條件編譯
Java語言當然也可以進行條件編譯,方法就是使用條件為常量的if語句。如下代碼所示,此代碼中的if語句不同于其他Java代碼,它在編譯階段就會被“運行”,生成的字節碼之中只包括"System.out.println("block 1");"一條語句,并不會包含if語句及另外一個分子中的"System.out.println("block 2");

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");
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。