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 個處理過程:
準備過程:初始化插入式注解處理器
解析與填充符號表過程
詞法、語法分析:將源碼中的字符流轉變為標記集合,構造抽象語法樹
填充符號表:產生符號地址和符號信息
插入式注解處理器的注解處理過程
分析與字節碼生成過程
標注檢查:對語法的靜態信息進行檢查
數據流及控制流分析:對程序的動態運行過程進行檢查
解語法糖:將簡化代碼編寫的語法糖還原為原來的樣子
字節碼生成:將前面各個步驟所生成的信息轉化為字節碼
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 編譯過程的最后一個階段。主要是把前面各個步驟生成的信息轉換為字節碼指令寫入磁盤中。
此外,編譯器還進行了少量的代碼添加和轉換工作。比如實例構造器
() 和類構造器
() 方法就是在這個階段被添加到語法樹的。
還有一些代碼替換工作,例如將字符串的 "+" 操作替換為 StringBuilder(JDK 5 及以后)或 StringBuffer(JDK 5 之前) 的 append() 操作。
3. Java 語法糖
3.1 泛型
泛型這個概念大家應該都不陌生,Java 是從 5.0 開始支持泛型的。
由于歷史原因,Java 使用的是“類型擦除式泛型(Type Erasure Generics)”,也就是泛型只會在源碼中存在,編譯后的字節碼文件中,全部泛型會被替換為原先的裸類型(Raw Type)。
因此,在運行期間 List
和 List
其實是同一個類型。例如:
public class GenericTest { public static void main(String[] args) { List
l1 = new ArrayList<>(); l1.add(1); List
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
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
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
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 了。
Java IDE web前端 JVM
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。