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 個處理過程:
-
準備過程:初始化插入式註解處理器
-
解析與填充符號表過程
-
詞法、語法分析:將源碼中的字符流轉變爲標記集合,構造抽象語法樹
-
填充符號表:產生符號地址和符號信息
-
-
插入式註解處理器的註解處理過程
-
分析與字節碼生成過程
-
標註檢查:對語法的靜態信息進行檢查
-
數據流及控制流分析:對程序的動態運行過程進行檢查
-
解語法糖:將簡化代碼編寫的語法糖還原爲原來的樣子
-
字節碼生成:將前面各個步驟所生成的信息轉化爲字節碼
-
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");
}
}
類型擦除是有缺點的,比如:
-
由於類型擦除,會將泛型的類型轉爲 Object,但是 int、long 等原始數據類型無法與 Object 互轉,這就導致了泛型不能支持原始數據類型。進而引起了使用包裝類(Integer、Long 等)帶來的拆箱、裝箱問題。
-
運行期無法獲取泛型信息。
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
}
}
可以看到,編譯器對上述代碼做了自動拆裝箱的操作。其中值得注意的是:
-
包裝類的 "==" 運算不遇到算術運算時,不會自動拆箱。
-
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 了。
本文內容就到這裏,希望對大家有所幫助~