JVM筆記-前端編譯與優化

1. 概述

所謂”編譯“,通俗來講就是把我們寫的代碼“翻譯“成機器可以讀懂的機器碼。而編譯器就是做這個翻譯工作的。

Java 技術中的編譯器可以分爲如下三類:

  • 前端編譯器:把 *.java 文件轉變爲 *.class 文件的過程。比如 JDK 的 Javac。

  • 即時編譯器:Just In Time Compiler,常稱 JIT 編譯器,在「運行期」把字節碼轉變爲本地機器碼的過程。比如 HotSpot VM 的 C1、C2 編譯器,Graal 編譯器。

  • 提前編譯器:Ahead Of Time Compiler,常稱 AOT 編譯器,直接把程序編譯成與目標機器指令集相關的二進制代碼的過程。比如 JDK 的 Jaotc,GNU Compiler for the Java。

其中後面兩類都屬於後端編譯器。

本文主要分析前端編譯器 Javac 的相關內容,後文再介紹後端編譯器。

2. Javac 編譯器

Javac 的編譯過程大致可以分爲 1 個準備過程和 3 個處理過程:

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

  2. 解析與填充符號表過程

    1. 詞法、語法分析:將源碼中的字符流轉變爲標記集合,構造抽象語法樹

    2. 填充符號表:產生符號地址和符號信息

  3. 插入式註解處理器的註解處理過程

  4. 分析與字節碼生成過程

    1. 標註檢查:對語法的靜態信息進行檢查

    2. 數據流及控制流分析:對程序的動態運行過程進行檢查

    3. 解語法糖:將簡化代碼編寫的語法糖還原爲原來的樣子

    4. 字節碼生成:將前面各個步驟所生成的信息轉化爲字節碼

2.1 解析與填充符號表

2.1.1 詞法、語法分析

  • 詞法分析

將源碼中的字符流轉變爲標記(Token)集合的過程。關鍵字、變量名、運算符等都可作爲標記。比如下面一行代碼:

int a = b + 2;

在字符流中,關鍵字 int 由三個字符組成,但它是一個獨立的標記,不可再分。

該過程有點類似“分詞”的過程。雖然這些代碼我們一眼就能認出來,但編譯器要逐個分析過之後才能知道。

  • 語法分析

根據上面的標記序列構造抽象語法樹的過程。

抽象語法樹(Abstract Syntax Tree,AST)是一種用來描述程序代碼語法結構的樹形表示方法,每個節點都代表程序代碼中的一個語法結構(Syntax Construct),比如包、類型、修飾符等。

通俗來講,詞法分析就是對源碼文件做分詞,語法分析就是檢查源碼文件是否符合 Java 語法。

2.1.2 填充符號表

符號表(Symbol Table)是一種數據結構,它由一組符號地址和符號信息組成(類似“鍵-值”對的形式)。

符號由抽象類 com.sun.tools.javac.code.Symbol 表示,Symbol 類有多種擴展類型的符號,比如 ClassSymbol 表示類、MethodSymbol 表示方法等。

符號表記錄的信息在編譯的不同階段都要用到,如:

  • 用於語義檢查和產生中間代碼;

  • 在目標代碼生成階段,符號表是對符號名進行地址分配的依據。

這個階段主要是根據上一步生成的抽象語法樹列表完成符號填充,返回填充了類中所有符號的抽象語法樹列表。

2.2 註解處理器

JDK 5 提供了註解(Annotations)支持,JDK 6 提供了“插入式註解處理器”,可以在「編譯期」對代碼中的特定註解進行處理,從而影響前端編譯器的工作過程。

比如效率工具 Lombok 就是在這個階段進行處理的。示例代碼:

import lombok.Getter;


@Getter
public class Person {
  private String name;


  private Integer age;
}

該代碼編譯後:

public class Person {
    private String name;
    private Integer age;


    public Person() {
    }


    public String getName() {
        return this.name;
    }


    public Integer getAge() {
        return this.age;
    }
}

其中兩個 getter 方法就是 @Getter 註解在這個階段產生的效果(具體實現原理網上可以找到相關內容)。

2.3 語義分析與字節碼生成

抽象語法樹能表示一個結構正確的源程序,卻無法保證語義是否符合邏輯。

而語義分析就對語法正確的源程序結合上下文進行相關性質的檢查(類型檢查、控制流檢查等)。比如:

int a = 1;
boolean b = false;


// 這樣賦值顯然是錯誤的
// 但在語法上是沒問題的,這個錯誤是在語義分析時檢查的
int c = a + b;

Javac 在編譯過程中,語義分析過程可分爲標註檢查和數據及控制流分析兩個步驟。

2.3.1 標註檢查

檢查內容:變量使用前是否已被聲明、變量與賦值之間的數據類型是否匹配等。

  • 常量摺疊

該過程中,還會進行一個常量摺疊(Constant Folding)的代碼優化。

比如,我們在代碼中定義如下:

int a = 1 + 2;

在抽象語法樹上仍能看到字面量 "1"、"2" 和操作符 "+",但經過常量摺疊優化後,在語法樹上將會被標註爲 "3"。

2.3.2 數據及控制流分析

主要檢查內容:

  • 局部變量使用前是否賦值

  • 方法的每條路徑是否有返回值

  • 受檢查異常是否被正確處理等

2.3.3 解語法糖

語法糖(Syntactic Sugar):也稱糖衣語法,指的是在計算機語言中添加某種語法,該語法對語言的編譯結果和功能並沒有實際影響,卻能更方便程序猿使用該語言。

PS: 就是讓我們寫代碼更舒服的語法,像吃了糖一樣甜。

Java 中常見的語法糖有泛型、變長參數、自動裝箱拆箱等。

JVM 其實並不支持這些語法,它們在編譯階段要被還原成原始的基礎語法結構。該過程就稱爲解語法糖(打回原形)。

2.3.4 字節碼生成

Javac 編譯過程的最後一個階段。主要是把前面各個步驟生成的信息轉換爲字節碼指令寫入磁盤中。

此外,編譯器還進行了少量的代碼添加和轉換工作。比如實例構造器 <init>() 和類構造器 <clinit>() 方法就是在這個階段被添加到語法樹的。

還有一些代碼替換工作,例如將字符串的 "+" 操作替換爲 StringBuilder(JDK 5 及以後)或 StringBuffer(JDK 5 之前) 的 append() 操作。

3. Java 語法糖

3.1 泛型

泛型這個概念大家應該都不陌生,Java 是從 5.0 開始支持泛型的。

由於歷史原因,Java 使用的是“類型擦除式泛型(Type Erasure Generics)”,也就是泛型只會在源碼中存在,編譯後的字節碼文件中,全部泛型會被替換爲原先的裸類型(Raw Type)。

因此,在運行期間 List<String>List<Integer> 其實是同一個類型。例如:

public class GenericTest {
  public static void main(String[] args) {
    List<Integer> l1 = new ArrayList<>();
    l1.add(1);
    List<String> l2 = new ArrayList<>();
    l2.add("2");
  }
}

經編譯器擦除類型後:

public class GenericTest {
    public GenericTest() {
    }


    public static void main(String[] var0) {
        // 原先的泛型都沒了
        ArrayList var1 = new ArrayList();
        var1.add(1);
        ArrayList var2 = new ArrayList();
        var2.add("2");
    }
}

類型擦除是有缺點的,比如:

  1. 由於類型擦除,會將泛型的類型轉爲 Object,但是 int、long 等原始數據類型無法與 Object 互轉,這就導致了泛型不能支持原始數據類型。進而引起了使用包裝類(Integer、Long 等)帶來的拆箱、裝箱問題。

  2. 運行期無法獲取泛型信息。

3.2 自動裝箱、拆箱與遍歷

  • 遍歷代碼示例

public class GenericTest {
  public static void main(String[] args) {
    List<String> list = Arrays.asList("hello", "world");
    for (String s : list) {
      System.out.println(s);
    }
  }
}

反編譯版本 1:

public class GenericTest {
    public GenericTest() {
    }


    public static void main(String[] args) {
        List<String> list = Arrays.asList("hello", "world");
        // 使用了迭代器 Iterator 遍歷
        Iterator var2 = list.iterator();


        while(var2.hasNext()) {
            String s = (String)var2.next();
            System.out.println(s);
        }
    }
}

反編譯版本 2:

public class GenericTest {
  public static void main(String[] args) {
    // 創建一個數組
    List<String> list = Arrays.asList(new String[] { "hello", "world" });
    for (String s : list)
      System.out.println(s); 
  }
}

不同的反編譯器得出的結果可能有所不同,這裏找了兩個版本對比分析。

從上面兩個版本的反編譯結果可以看出:Arrays.asList() 方法其實創建了一個數組,而增強 for 循環實際調用了迭代器 Iterator。

  • 自動拆裝箱代碼示例

public class GenericTest {
  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));
  }
}

類似代碼估計大家都見過,畢竟有些面試題就喜歡這麼搞,這些語句的輸出結果是什麼呢?

我們先看反編譯後的代碼:

public class GenericTest {
  public static void main(String[] args) {
    Integer a = Integer.valueOf(1);
    Integer b = Integer.valueOf(2);
    Integer c = Integer.valueOf(3);
    Integer d = Integer.valueOf(3);
    Integer e = Integer.valueOf(321);
    Integer f = Integer.valueOf(321);
    Long g = Long.valueOf(3L);


    System.out.println((c == d)); // t
    System.out.println((e == f)); // f
    System.out.println((c.intValue() == a.intValue() + b.intValue())); // t
    System.out.println(c.equals(Integer.valueOf(a.intValue() + b.intValue()))); // t
    System.out.println((g.longValue() == (a.intValue() + b.intValue()))); // t
    System.out.println(g.equals(Integer.valueOf(a.intValue() + b.intValue()))); // f
  }
}

可以看到,編譯器對上述代碼做了自動拆裝箱的操作。其中值得注意的是:

  1. 包裝類的 "==" 運算不遇到算術運算時,不會自動拆箱。

  2. equals() 方法不會處理數據轉型。

此外,還有個值得玩味的地方:爲何 c==d 是 true、而 e==f 是 false 呢?似乎也是個考點。

這就要查看 Integer 類的 valueOf() 方法的源碼了:

static final int low = -128;


public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}


private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];


    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;


        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);


        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }
    
    private IntegerCache() {}
}

可以看到 Integer 內部使用了緩存 IntegerCache:其最小值爲 -128,最大值默認是 127。因此,[-128, 127] 範圍內的數字都會直接從緩存獲取。

而且,該緩存的最大值是可以修改的,可以使用如下 VM 參數將其修改爲 500:

-XX:AutoBoxCacheMax=500

增加該參數後,上述 e==f 也是 true 了。


本文內容就到這裏,希望對大家有所幫助~

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章