由于原文太長,所以拆成兩部分。
JVM 結構
Java 編寫的代碼通過下圖所展示的流程執行。
類加載器將編譯后的 Java 字節碼加載到運行時方法區,由執行引擎執行 Java 字節碼。
類加載器
Java 提供了動態加載功能;當在運行時第一次引用一個類時類加載再加載并鏈接相應的類,而不是在編譯時加載鏈接。動態加載由 JVM 的類加載器執行。Java 的類加載器特點如下:
- 層次結構:Java 類加載器以父-子關系按層組織,Bootstrap 類加載器是所有類加載器的父節點。
- 代理模式:基于層級結構,加載是在類加載器之間代理實現。當一個類被加載時,父類加載器會檢查并決定該類是否存在父類加載器中。如果父類加載器擁有這個類,就使用這個類。如果沒有,類加載器就申請加載。
- 限制可見性:子類加載器可以查看父類加載器中的類;但是父類加載器不能查看子類加載器中的類。
- 不可卸載:類加載器可以加載類,但是不能卸載類。取代卸載的處理方法是:當前類加載器可以被刪除,然后再創建一個新的類加載器。
每一個類加載器都有其獨立的命名空間用于存儲已經加載的類。當加載一個類時,它首先基于 FNCQ(Fully Qualified Class Name)在其命名空間中搜索檢查該類是否已經被加載。如果一個類有相同的 FQCN 但是不同的命名空間,它會被當做不同的類。不同的命名空間意味著這個類已經被其他的類加載器加載。
下圖說明了類加載器的代理模式。
當一個類加載器申請加載類時,會按照圖中的順序依次檢查該類是否存在于類加載器緩存、父類加載器的緩存和它自己。也就是首先要檢查該類是否已經加載到類加載器緩存中。如果沒有,檢查父類加載器。如果在 bootstrap 類加載器中還沒有找到該類,申請加載的加載器就會在文件系統中搜索。
- Bootstrap class loader:這個加載器在 JVM 運行時就被創建。它加載 Java API,包括 Object 類。和其他類加載器不一樣的是,它是用 native 而不是 Java 實現的。
- Extansion class loader:它加載 Java API 以外的擴展類。同時也加載各種安全擴展函數。
- System class loader:如果說 bootstrap 類加載器和 extension 類加載器加載 JVM 組件,那么 System 類加載器就加載應用類。它加載用戶指定的 $CLASSPATH 中的類。
- User-defined class loader:這是一個通過應用代碼創建的類加載器。
像 Web 應用服務(WAS)這樣的框架使用這一架構保證 Web 應用和企業應用獨立運行。換句話說,通過加載器代理模式保證應用獨立運行。不同廠商提供的使用層級結構的 WAS 類加載器會有一些細微差別。
如果類加載器發現一個類沒有被加載,就會按照下面圖示的過程加載、鏈接。
每一個階段的描述如下:
- Loading:從文件獲取類并加載到 JVM 的內存。
- Verifying:檢查讀取的類是否按照 Java 語言規范和 JVM 規范配置。這是類加載過程中最復雜的一個測試步驟,并且耗費的時間最長。多數 JVM TCK 的測試用例就是通過加載錯誤的類檢查是否有驗證錯誤發生。
- Preparing:準備數據結構,并分配類需要的內存,并標識類中定義好的字段、方法、和接口。
- Initializing:將類的屬性初始化到合適的值。執行靜態初始化,將靜態字段初始化到預先定義的值。
JVM 規范定義了這些任務。然而,它也允許靈活處理。
運行時數據區
運行時數據區是 JVM 程序在 OS 上運行時分配的內存。運行時數據區可以分為6個區域,每個線程獨有的一個PC 寄存器,JVM 棧,Native 方法棧。所有線程共用的堆、方法區和運行時常量池。
PC register:每一個線程都有一個 PC(程序計數器)寄存器,它在線程啟動時被創建。PC 寄存器中存放有當前被執行的 JVM 指令的地址。
-
JVM stack:每一個線程都有一條 JVM 棧,同樣也是在線程啟動時被創建。它是用于存儲結構(Stack Frame)的棧。JVM 只是將棧幀(stack frame)壓入或者彈出 JVM 棧。如果發生任何異常,像 printStackTrace()這樣的方法打印的調用棧信息每一行描述了一個棧幀。
圖5:JVM Stack 配置 Stack frame:每當一個方法在 JVM 中執行時,就會創建一個棧幀(stack frame),并且該棧幀會被添加到當前線程的 JVM stack。當方法執行完畢,該棧幀會被移除。每一個棧幀都包含對本地變量數組、操作數棧、和該方法所屬類的運行時常量池的引用。本地變量數組和操作數棧的大小是在編譯時就確定的。所以每一個方法的棧幀大小是固定的。
本地變量數組:它的索引值從0開始。0指向該方法所述類的實例。從1開始,發送給該函數的參數將被保存。方法的本地變量將被存儲在方法的參數之后。
操作數棧:這個一個方法的實際操作空間。每一個方法都在操作數棧和本地變量數組之間交換數據,并且將調用其他方法的結果壓入或者彈出。操作數棧所需要的空間大小在編譯時就可以確定。所以操作數棧的大小也可以在編譯時就確定。
Native 方法棧:用 Java 以外的語言為 native 代碼編寫的棧。換句話說,它是一個用于通過 JNI 執行 C/C++ 代碼的棧。根據具體的語言,會創建一個 C 或者 C++ 的棧。
方法區:方法區在 JVM 啟動時創建,所有線程共享。JVM 讀取的每一個類和接口的運行時常量池、字段和方法信息、靜態變量、方法字節碼都存放在這里。Oracle Hotspot JVM 稱之為永久區或者永久代。對每一個 JVM 廠商來說這一區域的垃圾回收是可選的。
運行時常量池:和類文件格式中 constant_pool table 相關的一個區域。這個區域屬于方法區,然而,它在 JVM 的運轉中扮演了最核心的角色。所以,JVM 規范中單獨描述了它的重要性。與每一個類和接口的常量一樣,它包含了所有的方法和字段的引用。即,當一個方法或者字段被調用的時候,JVM 會通過運行時常量池來搜索該方法或者字段在內存中的地址。
堆:存儲類實例或者對象的空間,也是垃圾回收的目標場所。當討論 JVM 的性能時,這一切區域是被提及次數最多的區域。JVM 廠商可以決定怎樣配置堆或者不執行垃圾回收。
讓我回到全面討論的反編譯字節碼。
public void add(java.lang.String);
Code:
0: aload_0
1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin;
4: aload_1
5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;
8: pop
9: return
將這段反編譯代碼和我們在其他地方見過的 X86 架構匯編語言比較,會發現它們兩者有著相似的格式和操作碼;然而,有一點不同的是,Java 字節碼不會寫寄存器名稱、內存地址或者操作數的偏移地址。就如前面描述,JVM 使用棧。所以它不會使用寄存器,和 x86 架構使用寄存器不同,它使用索引數比如 15 和 23,而不是使用內存地址,因為它自己管理內存。這里 15 和 23 是當前類(這里是指 UserServies 類)的常量池索引。即,JVM 為每一個類創建一個常量池,這個池中存放實際目標的引用。
這一段反編譯代碼每一行的解釋如下。
- aload_0:將本地變量數組中的第 #0 個變量添加到操作棧。本地變量棧的第 #0 個變量總是 this,也就是當前類實例的引用。
- getfield #15:在當前類的常量池中,將第 #15 個元素添加到操作數棧。 UserAdmin admin 這個字段被添加。因為 admin 字段是一個類實例,所以添加的是其引用。
- aload_1:添加本地變量數組第#1 個元素到操作數棧。從本地變量數組的第#1個元素開始是方法參數。所以,調用 add()方法是傳入的 String userName 的引用被添加到操作數棧。
- invokevirtual #23:調用當前類常量池中第#23元素對應的方法。同時,通過使用 getfield 添加的引用和通過使用 aload_1 添加的參數會被發送到將調用的方法。當方法執行完畢,結返回值被存放到操作數棧。
- pop:將使用 invokevirtual 的返回值彈出操作數棧。你可以發現使用舊版的庫編譯的代碼沒有返回值。即,舊版本沒有返回值,所有不需要將返回值從操作數棧彈出。
- return:方法執行完畢。
下圖將幫助你理解這些解釋。
作為參考,在這個方法中,本地變量數組沒有做任何變化。所以上圖僅僅展示了操作數棧的變化。然而,在多數情況下,本地變量數組會發生變化。本地變量數組和操作數棧之間的數據交換通過一系列的加載指令(aload,iload)和存儲指令(astore,istore)完成。
在上圖中,我么已經簡要的描述了運行時常量池和 JVM 棧。當 JVM 運行時,每一個類實例都會被分配到堆上,而類信息(包括 User, UserAdmin,UserServices 和String)會被存儲到方法區。
執行引擎(Execution Engine)
通過類加載器分配到運行時數據區的字節碼通過執行引擎執行。執行引擎以指令為單位讀取字節碼。它就像 CPU 執行機器命令一樣一條一條的執行。每一個字節碼命令都包含一個字節的操作碼和附加的操作數。執行引擎讀取一條操作碼然后使用相應的操作數執行任務,完成以后再執行下一條操作碼。
但是 Java 字節碼是以人類能夠理解的語言編寫的,而不是機器能夠直接執行的語言。所以執行引擎需要將字節碼轉換成機器中的 JVM 能夠執行的語言。字節碼會按照以下兩種方式之一轉換成合適的語言。
- 解釋器(Interpreter):一條一條的讀取、解釋、執行字節碼命令。由于是逐條解釋、執行指令,它可以快速的解釋一條字節碼,但是執行解釋的結果會較慢。這是解釋語言的缺點。這一類“語言”就像口譯者一樣調用字節碼。
- 及時編譯器(JIT (Just-In-Time) compile):JIT 編譯器的引入時為了解決解釋器的缺點。執行引擎首先運行一個解釋器,在適當的時候,JIT 編譯器將整字節碼轉換成 native code。在此之后,執行引擎就不再解釋方法,而是直接執行native code。執行 native code 會比逐條解釋指令快的多。編譯后的代碼可以快速執行,因為native code 存儲在緩存中。
但是,JIT 編譯器編譯代碼會比解釋器逐條解釋代碼使用更長的時間。所以如果代碼只需要執行一次,使用解釋器會更好。所以,各種內部使用了 JIT 編譯器的 JVM 都會檢查方法被執行的頻率,只有當一個方法被執行的頻率高于某一個水平時才將它編譯成 native code。
JVM 規范并沒有定義執行引擎如何運行。所以,JVM 廠商們通過各種技術來提高其執行引擎的效率,并發明各種類型的 JIT 編譯器。
大多數 JIT 編譯器像下圖這樣運行:
JIT 編譯器將字節碼轉換成一個中間表達式,IR(Intermediate Representation),然后執行優化,最后將中間表達式轉換成 native code。
Oracle Hotspots 虛擬機使用的 JIT 編譯器稱為 Hotspot 編譯器。它被稱作 Hotspot 是因為 Hotspot編譯器會根據 profiling 找到具有最高編譯優先級的“熱點”代碼,并將熱點編譯成 native code。如果編譯后的方法不再被頻繁調用,這個方法就不再是熱點,Hotspot 虛擬機會將相應的 native code 從緩存中移除,并使用解釋模式運行。Hotspot 虛擬機分為服務端虛擬機和客戶端虛擬機,兩者使用了不同的 JIT 編譯器。
如上圖所示,客戶端虛擬機和服務端虛擬機使用相同的運行時,但是,它們使用不同的 JIT 編譯器。服務端虛擬機使用的高級動態優化編譯器使用了更復雜以及各種各樣的性能優化技術。
IBM JVM 從IBM JDK 6 開始使用了 AOT (Ahead-Of-Time)編譯器作為其 JIT 編譯器。這意味著多個 JVM 通過共享緩存共享 native code。即:通過 AOT 編譯器編譯好的代碼可以被其他 JVM 直接使用而不需要重新編譯。另外 IBM JVM 還提供了快速執行方式,即通過 AOT 編譯器將代碼預編譯成 JXE(Java EXecutable)文件格式。
大多數 java 性能優化都是通過提高執行引擎的性能完成。和 JIT 編譯器一樣,各種優化技術還在不斷的發展,所以 JVM 的性能會得到持續提高。最新的 JVM和最初的 JVM 之間最大的差別就是執行引擎。
Oracle Hotspot 虛擬機從1.3版本開始引入 Hotspot 編譯器,Android 2.2 版本開始在 Dalvik 虛擬機中引入了 JIT 編譯器。
注釋
JVM 通過中間語言來提高性能的技術(如引入字節碼,虛擬機執行字節碼以及 JIT 編譯器等)在其他使用了中間語言的語言中也經常使用。例如微軟的 .Net,CLR(Common Language Runtime)也是一種虛擬機,它執行的是一種的稱為CIL(Common Intermediate Language)的字節碼。CLR 也提供了像 JIT 編譯器一樣的 AOT 編譯器。如果源碼是用 C# 或者 VB.NET 編寫、編譯,編譯器就會創建 CIL,CIL運行在使用了 JIT 編譯器的 CLR 上面。CLR 像 JVM 一樣使用了垃圾回收基于棧運行。
Java 虛擬機規范,Java SE 7 版本
2011年7月28日,Oracle 發布了 Java SE7, 并更新了相應的 JVM 規范。在1999年發布“Java 虛擬機規范,第二版”之后,Oracle 使用了12年時間來發布一個更新。這個更新版本包含了12年來積累的各種變化和修改,并且對規范提供更清晰的描述。另外,它也反應了隨 Java Se 7 一同發布“Java 語言規范,Java SE7 版本”所包含的內容。主要的變化總結如下:
- 從 Java SE 5.0 開始引入泛型,支持可變參數方法
- 字節碼驗證流程技術從 Java SE 6 開始已經改變
- 為了支持動態類型語言,添加了 invokedynamic 指令以及相應的類文件格式
- 刪除了 Java 語言本身概念的描述,將相關內容移動到 Java 語言規范中
最大的變化是添加了 invokedynamic 指令。這意味著這一變化發生在 JVM 內部的指令集,JVM 從 Java SE 7 版本開始會像支持 Java 語言一樣支持動態類型語言,比如腳本語言。以前沒有使用的操作碼 186 被分配給這個新的指令(invokedynamic),新的內容也被添加到類文件格式以支持 invokedynamic。
通過 Java SE 7 的編譯器編譯的類文件版本是51.0。Java SE 6的版本是50.0。由于類文件格的變化,51.0 版本的類文件無法在 Java SE6 的 JVM 上運行。
盡管有了各種變化,但是Java 方法65535字節的限制仍然存在。除非 JVM 的類文件格式被創新性的更改,這一限制在未來不會被移除。
作為參考,Oracle java SE 7 虛擬機支持新的垃圾回收 G1,但是它僅限于 Oracle JVM,所以 JVM 本身沒有限定任何垃圾回收類型。所以 JVM 規范中沒有相應的描述。
switch 語句中的字符串
Java SE7 添加了各種語法和特性。但是和 Java SE 7 語言的各種變化相比,JVM 并沒有那么多變化。所以,Java SE 7 的這些新功能是怎么實現的呢?我們將通過反編譯代碼來看看 switch 語句中的 String(將字符串比較添加到 switch()語句這樣一個功能)是怎么實現的。
例如,我們寫下了如下代碼:
// SwitchTest
public class SwitchTest {
public int doSwitch(String str) {
switch (str) {
case "abc": return 1;
case "123": return 2;
default: return 0;
}
}
}
由于這是 Java SE 7 的新功能,它不能使用 Java SE 6 或者更低版本的 Java 編譯器編譯。使用 Java SE 7版本的 javac 編譯。以下是使用 javap -c 打印出來的編譯結果。
C:Test>javap -c SwitchTest.classCompiled from "SwitchTest.java"
public class SwitchTest {
public SwitchTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return public int doSwitch(java.lang.String);
Code:
0: aload_1
1: astore_2
2: iconst_m1
3: istore_3
4: aload_2
5: invokevirtual #2 // Method java/lang/String.hashCode:()I
8: lookupswitch { // 2
48690: 50
96354: 36
default: 61
}
36: aload_2
37: ldc #3 // String abc
39: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
42: ifeq 61
45: iconst_0
46: istore_3
47: goto 61
50: aload_2
51: ldc #5 // String 123
53: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
56: ifeq 61
59: iconst_1
60: istore_3
61: iload_3
62: lookupswitch { // 2
0: 88
1: 90
default: 92
}
88: iconst_1
89: ireturn
90: iconst_2
91: ireturn
92: iconst_0
93: ireturn
比 Java 源碼長的多的 字節碼。首先你會看到 Java 字節碼中 lookupswitch 指令被用于 switch() 語句,但是使用了兩個lookupswitch 指令,而不是一個。當反編譯使用int 作為比較的switch() 語句時,你會發現只使用了一個 lookupswitch 指令。這意味著 switch() 語句為了處理字符串被分成了兩部分。通過分析被標注為#5,#39,#53 的這幾條指令可以發現 switch() 語句是怎么處理字符串的。
首先,在#5 和 #8字節中,hashCode()方法被執行,然后根據hashCode() 方法返回的結果執行 switch(int) 方法。在lookupswitch 指令的括弧內,根據 hashCode 的結果各個分支跳轉到不同的位置。字符串“abc” 的哈希值是 96345,就會跳轉到#36字節。字符串“123”的哈希值是48690,所以跳轉到#50字節。
在#36, #37, #39 和#42 字節,你會看到 str 參數的值被接收,然后作為參數調用 equals 方法與字符串“abc”進行比較。如果結果相同,‘0’ 就被添加到本地變量數組的第#3個位置,字符被移到第#61字節。
同樣的,在#50, #51, #53 和#56 字節,你會看到 str 參數的值被接收,然后作為參數調用 equals 方法與字符串“123”進行比較。如果結果相同,‘1’ 就被添加到本地變量數組的第#3個位置,字符被移到第#61字節。
在#61 和#62 字節,本地變量數組第#3 位置的值(即'0','1' 或者其他的任何值)被用于查找分支好分支跳轉。
換句話說,在 Java 代碼中, switch() 語句接收的字符串通過 hashCode() 方法和 equals() 方法進行比較。根據比較的結果再次執行 switch()。
這樣,編譯后的字節碼就和前面的版本的 JVM 規范沒有差別。Java SE 7中的 switch 語句支持字符串這一新功能是通過 Java 編譯器實現而不是 JVM 自身實現的。同樣,Java SE 7的其他新功能也是通過 Java 編譯器實現的。
結束語
雖然使用Java并不需要了解Java是如何被開發出來的。并且很多程序員在并沒有深入了解 JVM 的情況下依然開發出了很多偉大的應用和類庫。但是如果能夠了解JVM,你將能夠更深入的了解 Java,并在解決像文中所討論的案例問題時有所幫助。
除了上文所述,JVM 還有很多特性和技術。JVM 規范為 JVM 廠商提供了靈活的規范,以便各個廠商使用各種不同的技術來提高 JVM
的性能。另外垃圾回收已被很多具有類似虛擬機能力的編程語言作為常用的性能優化手段。但因有很多資料對這一主題做詳細的介紹,所以這里沒有深入討論。