jvm規範中定義了class文件的格式,但並未定義Java源碼如何編譯爲class文件,在Sun JDK中就是javac編譯器,可分爲下面三個步驟:
1.分析和輸入到符號表(Parse and Enter)
Parse 過程所做的是詞法和語法分析
Enter 過程是將符號輸入到符號表
2.註解處理(Annotation Processing)
該步驟主要用於處理用戶自定義的annotation,可能帶來的好處是基於annotation來生成附加的代碼,如:
編譯時引入Lombok對User.java進行編譯後,再通過javap查看class文件可看到自動生成了public String getUsername()方法
3.語義分析和生成class文件(Analyse and Generate)
class 文件中並不僅僅存放了字節碼,還存放了很多輔助jvm來執行class文件的附加信息,一個class文件包含了以下信息:
- 結構信息:包括class文件格式版本號及各部分的數量與大小的信息
- 元數據:可以認爲元數據就是java源碼中“聲明”與“常量”信息
- 方法信息:可以說就是java源碼中“語句”和“表達式”對應的信息
class文件是個完整的自描述文件,字節碼在其中只佔了很小的部分,源碼編譯爲class文件後,即可放入jvm中執行,執行時jvm首先要做的是裝載class文件,這個就是類加載機制
2.類加載機制
jvm將類加載過程劃分爲三個步驟:
1.裝載(Load) 裝載過程負責找到二進制字節碼並加載到jvm中,jvm通過類的全限定義名(com.bluedavy.HelloWord)及類加載器(ClassLoaderA 實例)完成類的加載,同樣,也採用以上兩個素來標識一個被加載了的類:類的全限定名+ClassLoader 實例ID,類名的命名方式如下:
對於接口式非數組型的類,其名稱即爲類名,此種類型的類由所在的ClassLoader負責加載:數組型類中的元素類型由所在的ClassLoader負責加載,但數組類則由JVM直接創建。
2.鏈接(Link) 鏈接過程負責對二進制字節碼的格式進行校驗、初始化裝載類中的靜態變量及解析類中調用的接口、類。
3.初始化(Initialize)初始化過程即即執行類中的靜態初始化代碼、構造器代碼及靜態屬性的初始化,在下面的四種情況下初始化過程會被觸發執行:
- 調用了new
- 反射調用了類中的方法
- 子類調用了初始化
- JVM啓動過程中指定的初始化類
JVM的類加載通過ClassLoader及其子類來完成,分爲Bootstrap ClassLoader、Extensin ClassLoader、System ClassLoader(在Sun JDK中對應的類名爲AppClassLoader)及 User-Defined ClassLoader,其關係如下圖:
1.Bootstrap ClassLoader Sun JDK 採用C++實現了此類,此類並非ClassLoader的子類,在代碼中沒有辦法拿到這個對象,Sun JDK 啓動時會初始化此ClassLoader,並由ClassLoader完成$JAVA_HOME 中jre/lib/rt.jar裏所有class文件的加載;
2.Extension ClassLoader JVM用此ClassLoader來加載擴展功能的一些jar包,例如JDK中dns工具jar包等,對應的類名爲ExtClassLoader;
3.System ClassLoader JVM 用此ClassLoader來加載啓動參數中指定的Classpath中的jar包及目錄,在Sun JDK中ClassLoader對應的類名爲AppClassLoader;
4.User-Defined ClassLoader 這是開發人員繼承ClassLoader抽象類自行實現的ClassLoader,可以用加載非Classpath中的jar及目錄、還可以在加載之前對class文件做一些動作,例如解密等;
JVM會保證同一個ClassLoader實例對象中只能加載一次同樣名稱的Class
Cloader抽象類提供了幾個關鍵的方法
- loadClass 此方法負責加載指定名字的類,ClassLoader的實現方法爲先從已經加載的類中尋找,如沒有,則繼續從 parent ClassLoader中尋找;如果仍然沒有找到,則從System ClassLoader中尋找,最後再調用findClass方法來尋找;如果最終沒有找到就拋出ClassNotFoundException;
- findLoaderClass 方法負責從當前ClassLoader實例對象的緩存中尋找已加載的類,調用的爲native的方法;
- findClass 此方法直接拋出ClassNotFoundException,因此要通過覆蓋loadClass或此方法來以自定義的方式加載相應的類;
- findSystemClass 此負責從System ClassLoader 中尋找,如未找到,則繼續從Bootstrap ClassLoader 中尋找,如果仍然未找到,則返回null;
- defineClass 此方法負責將二進制的字節碼轉換成Class對象,如果二進制的字節碼的格式不符合JVM Class文件的格式,則拋出ClassFormatError;如果生成的類名和二進制字節碼中的不同,則拋出NoClassDefFoundError;如果加載的class是受保護的、採用不同簽名的或者類名是以java.開頭的,則拋出SecurityException;如果加載的class在此ClassLoader中已加載,則拋出LinkageError;
- resolveClass 此方法負責完成Class對象的鏈接,如果鏈接過,則會直接返回;
3.類的執行機制
在源碼編譯階段源碼編譯爲JVM字節碼,JVM字節碼是一種中間代碼的方式,要由JVM在運行期對其進行解釋並執行,這種方式稱爲字節碼解釋執行方式;
1.字節碼解釋執行
JVM採用了invokestatic,invokevirtual,invokeinterface和invokespecial四個指令來執行不同的方法調用。
invokestatic對應的是調用static方法,invokevirtual對應的是調用對象實例的方法,invokeinterface對應的是調用接口的方法,invokespecial對應的是調用private方法和編譯源碼後生成的<init>方法;
Sun JDK基於棧的體系結構來執行字節碼,基於棧方式的好處爲代碼緊湊,體積小,線程在創建後,都會產生程序計數器(pc)(或稱爲PC registers)和棧(Stack);PC存放了下一條要執行的指令在方法內的偏移量;棧中存放了棧幀(StackFrame),每個方法每次調用都會產生棧幀。棧幀主要分爲局部變量區和操作數棧兩部分,局部變量區用於存放方法中的局部變量和參數,操作數棧中用於存放方法執行過程中產生的中間結果,棧幀中還會有一些雜用空間,例如指向方法已解析的常量池的引用、其他一些JVM內部實現需要的數據等;
2.編譯執行
解釋執行的效率較低,Sun JDK在執行過程中對執行頻率高的代碼進行編譯,對執行不頻繁的代碼則繼續採用解釋的方式;在編譯上Sun JDK提供了兩種模式:client compiler(-client)和server compiler(-server)。
client compiler又稱爲C1,較爲輕量級,只做少量性能開銷比高的優化,它佔用內存較少,適合於桌面交互式應用,其他方面的優化主要有:
- 方法內聯 這種方法把調用到的方法的指令直接植入當前方法中,如下代碼:
- public void bar(){
- ....
- bar2();
- ....
- }
- private void bar2(){
- //bar2;
- }
public void bar(){
....
bar2();
....
}
private void bar2(){
//bar2;
}
上面代碼編譯後就變成類似下面的結構:
- public void bar(){
- ....
- //bar2
- ....
- }
public void bar(){
....
//bar2
....
}
- 去虛擬化 去虛擬化是指在裝載class文件後,進行類層次的分析,如發現類中的方法只提供一個實現類,那麼對於調用了此方法的代碼,也可進行方法內聯;
- 冗餘削除 冗餘削除是指在編譯時,根據運行時狀況進行代碼摺疊或削除,例如一段這樣的代碼:
- public void execute(){
- if(isDebug){
- log.debug("enter this method: execute");
- }
- //do something
- }
public void execute(){
if(isDebug){
log.debug("enter this method: execute");
}
//do something
}
如isDebug的值是false,在執行C1編譯後,這段代碼就變成類似下面的結構:
- public void execute(){
- //do something
- }
public void execute(){
//do something
}
Server compiler又稱C2,較爲重量級,它採用大量的傳統編譯優化技巧來進行優化,佔用內存相對會多一些,適合於服務器端的應用;逃逸分析是C2進行很多優化的基礎,逃逸分析是指根據運行狀況來判斷方法中的變量是否會被外部讀取,如不會則認爲此變量是逃逸的,基於逃逸分析C2在編譯時會標量替換,棧上分配和同步消除等優化;
- 標量替換 就是用標量替換聚合量,例如下面這段代碼:
- Point point=new Point(1,2);
- ystem.out.println("point.x="+point.x+";point.y"+point.y);
Point point=new Point(1,2);
System.out.println("point.x="+point.x+";point.y"+point.y);
當point對象在後面的執行過程中未用到時,經過編譯後,代碼會變成類似下面的結構:
- int x=1;
- int y=2;
- System.out.println("point.x="+x+";point.y"+y);
int x=1;
int y=2;
System.out.println("point.x="+x+";point.y"+y);
- 棧上分配 在上面的例子中,如果p沒有逃逸,那麼C2會選擇在棧上直接創建Point對象實例,而不是在堆上;
- 同步削除 指如果發現同步的對象未逃逸,那也沒有同步的必要了,在C2編譯時會直接去掉同步,例如:
- Point point=new Point(1,2);
- synchronized(point) {
- //do something;
- }
Point point=new Point(1,2);
synchronized(point) {
//do something;
}
經過分析如果發現point未逃逸,在編譯後,代碼就會變成下面的結構:
- Point point = new Point(1,2);
- //do something
Point point = new Point(1,2);
//do something
Sun JDK之所以未選擇在啓動時即編譯成機器碼,有下面幾個原因:
- 靜態編譯並不能根據程序的運行狀況來優化執行的代碼
- 解釋執行比編譯執行更節省內存
- 啓動解釋執行的啓動速度比編譯再啓動更快