1. 程式人生 > >深入JVM及類的載入連結初始化

深入JVM及類的載入連結初始化

虛擬機器(Virtual Machine)

  JRE是由Java API和JVM組成的。JVM的主要作用是通過Class Loader來載入Java程式,並且按照Java API來執行載入的程式。

  虛擬機器是通過軟體的方式來模擬實現的機器(比如說計算機),它可以像物理機一樣執行程式。設計虛擬機器的初衷是讓Java能夠通過它來實現WORA(Write Once Run Anywhere 一次編譯,到處執行),儘管這個目標現在已經被大多數人忽略了。因此,JVM可以在不修改Java程式碼的情況下,在所有的硬體環境上執行Java位元組碼

  Java虛擬機器的特點如下:

  • 基於棧的虛擬機器
    :Intel x86和ARM這兩種最常見的計算機體系的機構都是基於暫存器的。不同的是,JVM是基於棧的。
  • 符號引用除了基本型別以外的資料(類和介面)都是通過符號來引用,而不是通過顯式地使用記憶體地址來引用。
  • 垃圾回收機制:類的例項都是通過使用者程式碼進行建立,並且自動被垃圾回收機制進行回收。
  • 通過對基本型別的清晰定義來保證平臺獨立性:傳統的程式語言,例如C/C++,int型別的大小取決於不同的平臺。JVM通過對基本型別的清晰定義來保證它的相容性以及平臺獨立性。
  • 網路位元組碼順序:Java class檔案用網路位元組碼順序來進行儲存:為了保證和小端的Intel x86架構以及大端的RISC系列的架構保持無關性,JVM使用用於網路傳輸的網路位元組順序,也就是大端。

  雖然是Sun公司開發了Java,但是所有的開發商都可以開發並且提供遵循Java虛擬機器規範的JVM。正是由於這個原因,使得Oracle HotSpot和IBM JVM等不同的JVM能夠並存。Google的Android系統裡的Dalvik VM也是一種JVM,雖然它並不遵循Java虛擬機器規範。和基於棧的Java虛擬機器不同,Dalvik VM是基於暫存器的架構,因此它的Java位元組碼也被轉化成基於暫存器的指令集。

Java位元組碼(Java bytecode)

  為了保證WORA,JVM使用Java位元組碼這種介於Java和機器語言之間的中間語言。位元組碼是部署Java程式碼的最小單位。

  在解釋Java位元組碼之前,我們先通過例項來簡單瞭解它。這個案例是一個在開發環境出現的真實案例的總結。

現象

  一個一直執行正常的應用突然無法運行了。在類庫被更新之後,返回下面的錯誤。

  1. Exception in thread "main" java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V  
  2.     at com.nhn.service.UserService.add(UserService.java:14)  
  3.     at com.nhn.service.UserService.main(UserService.java:19)  
  應用的程式碼如下,而且它沒有被改動過。
  1. // UserService.java
  2. …  
  3. publicvoid add(String userName) {  
  4.     admin.addUser(userName);  
  5. }  
  更新後的類庫的原始碼和原始的程式碼如下。
  1. // UserAdmin.java - Updated library source code
  2. …  
  3. public User addUser(String userName) {  
  4.     User user = new User(userName);  
  5.     User prevUser = userMap.put(userName, user);  
  6.     return prevUser;  
  7. }  
  8. // UserAdmin.java - Original library source code
  9. …  
  10. publicvoid addUser(String userName) {  
  11.     User user = new User(userName);  
  12.     userMap.put(userName, user);  
  13. }  

  簡而言之,之前沒有返回值的addUser()被改修改成返回一個User類的例項的方法。不過,應用的程式碼沒有做任何修改,因為它沒有使用addUser()的返回值。咋一看,com.nhn.user.UserAdmin.addUser()方法似乎仍然存在,如果存在的話,那麼怎麼還會出現NoSuchMethodError的錯誤呢?

原因

  上面問題的原因是在於應用的程式碼沒有用新的類庫來進行編譯。換句話來說,應用程式碼似乎是調了正確的方法,只是沒有使用它的返回值而已。不管怎樣,編譯後的class檔案表明了這個方法是有返回值的。你可以從下面的錯誤資訊裡看到答案。

  1. java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V  

  NoSuchMethodError出現的原因是“com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V”方法找不到。注意一下”Ljava/lang/String;”和最後面的“V”。在Java位元組碼的表示式裡,”L<classname>;”表示的是類的例項。這裡表示addUser()方法有一個java/lang/String的物件作為引數。在這個類庫裡,引數沒有被改變,所以它是正常的。最後面的“V”表示這個方法的返回值。在Java位元組碼的表示式裡,”V”表示沒有返回值(Void)。綜上所述,上面的錯誤資訊是表示有一個java.lang.String型別的引數,並且沒有返回值的com.nhn.user.UserAdmin.addUser方法沒有找到。

  因為應用是用之前的類庫編譯的,所以返回值為空的方法被呼叫了。但是在修改後的類庫裡,返回值為空的方法不存在,並且添加了一個返回值為“Lcom/nhn/user/User”的方法。因此,就出現了NoSuchMethodError。

注:

這個錯誤出現的原因是因為開發者沒有用新的類庫來重新編譯應用。不過,出現這種問題的大部分責任在於類庫的提供者。這個public的方法本來沒有返回值的,但是後來卻被修改成返回User類的例項。很明顯,方法的簽名被修改了,這也表明了這個類庫的後向相容性被破壞了。因此,這個類庫的提供者應該告知使用者這個方法已經被改變了。

   我們再回到Java位元組碼上來。Java位元組碼是JVM很重要的部分。JVM是模擬執行Java位元組碼的一個模擬器。Java編譯器不會直接把高階語言(例如C/C++)編寫的程式碼直接轉換成機器語言(CPU指令);它會把開發者可以理解的Java語言轉換成JVM能夠理解的Java位元組碼。因為Java位元組碼本身是平臺無關的,所以它可以在任何安裝了JVM(確切地說,是相匹配的JRE)的硬體上執行,即使是在CPU和OS都不相同的平臺上(在Windows PC上開發和編譯的位元組碼可以不做任何修改就直接執行在Linux機器上)。編譯後的程式碼的大小和原始碼大小基本一致,這樣就可以很容易地通過網路來傳輸和執行編譯後的程式碼。

  Java class檔案是一種人很難去理解的二進檔案。為了便於理解它,JVM提供者提供了javap,反彙編器。使用javap產生的結果是Java組合語言。在上面的例子中,下面的Java彙編程式碼是通過javap -c對UserServiceadd()方法進行反彙編得到的。

  1. publicvoid add(java.lang.String);  
  2.   Code:  
  3.    0:   aload_0  
  4.    1:   getfield        #15//Field admin:Lcom/nhn/user/UserAdmin;
  5.    4:   aload_1  
  6.    5:   invokevirtual   #23//Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)V
  7.    8:   return
  在這段Java彙編程式碼中,addUser()方法是在第四行的“5:invokevitual#23″進行呼叫的。這表示對應索引為23的方法會被呼叫。索引為23的方法的名稱已經被javap給註解在旁邊了。invokevirtual是Java位元組碼裡呼叫方法的最基本的操作碼。在Java位元組碼裡,有四種操作碼可以用來呼叫一個方法,分別是:invokeinterface,invokespecial,invokestatic以及invokevirtual。操作碼的作用分別如下:
  • invokeinterface: 呼叫一個介面方法
  • invokespecial: 呼叫一個初始化方法,私有方法或者父類的方法
  • invokestatic:呼叫靜態方法
  • invokevirtual:呼叫例項方法

  Java位元組碼的指令集由操作碼和運算元組成。類似invokevirtual這樣的運算元需要2個位元組的運算元。

  用更新過的類庫來編譯上面的應用程式碼,然後反編譯它,將會得到下面的結果。

  1. publicvoid add(java.lang.String);  
  2.   Code:  
  3.    0:   aload_0  
  4.    1:   getfield        #15//Field admin:Lcom/nhn/user/UserAdmin;
  5.    4:   aload_1  
  6.    5:   invokevirtual   #23//Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;
  7.    8:   pop  
  8.    9:   return

  你會發現,對應索引為23的方法被替換成了一個返回值為”Lcom/nhn/user/User”的方法。

  在上面的反彙編程式碼裡,程式碼前面的數字程式碼什麼呢?

  它表示的是位元組偏移。大概這就是為什麼執行在JVM上面的程式碼成為Java“位元組”碼的原因。簡而言之,Java位元組碼指令的操作碼,例如aload_0,getfield和invokevirtual等,都是用一個位元組的數字來表示的(aload_0=0x2a,getfield=0xb4,invokevirtual=0xb6)。由此可知Java位元組碼指令的操作碼最多有256個。

  aload_0和aload_1這樣的指令不需要任何運算元。因此,aload_0指令的下一個位元組是下一個指令的操作碼。不過,getfield和invokevirtual指令需要2位元組的運算元。因此,getfiled的下一條指令是跳過兩個位元組,寫在第四個位元組的位置上的。十六進位制編譯器裡檢視位元組碼的結果如下所示。

  1. 2a b4 00 0f 2b b6 00 17 57 b1  
  在Java位元組碼裡,類的例項用字母“L;”表示,void 用字母“V”表示。通過這種方式,其他的型別也有對應的表示式。下面的表格對此作了總結。 表一:Java位元組碼中的型別表示式
Java Bytecode Type Description
B byte signed byte
C char Unicode character
D double double-precision floating-point value
F float single-precision floating-point value
I int integer
J long long integer
L<classname> reference an instance of class <classname>
S short signed short
Z boolean true or false
[ reference one array dimension

  下面的表格給出了位元組碼錶達式的幾個例項。

表二:Java位元組碼錶達式範例

Java Code Java Bytecode Expression
double d[ ][ ][ ]; [[[D
Object mymethod(int I, double d, Thread t) (IDLjava/lang/Thread;)Ljava/lang/Object;

  想了解更多細節的話,參考《The java Virtual Machine Specification,第二版》中的“4.3 Descriptors"。想了解更多的Java位元組碼的指令的話,參考《The Java Virtual Machined Instruction Set》的“6.The Java Virtual Machine Instruction Set"。

Class檔案格式

  在講解Java class檔案格式之前,我們先看看一個在Java Web應用中經常出現的問題。

現象

  當我們編寫完jsp程式碼,並且在Tomcat執行時,Jsp程式碼沒有正常執行,而是出現了下面的錯誤。

  1. Servlet.service() for servlet jsp threw exception org.apache.jasper.JasperException: Unable to compile classfor JSP Generated servlet error:  
  2. The code of method _jspService(HttpServletRequest, HttpServletResponse) is exceeding the 65535 bytes limit"  
原因

  在不同的Web伺服器上,上面的錯誤資訊可能會有點不同,不過有有一點肯定是相同的,它出現的原因是65535位元組的限制。這個65535位元組的限制是JVM規範裡的限制,它規定了一個方法的大小不能超過65535位元組。

  下面我會更加詳細地講解這個65535位元組限制的意義以及它出現的原因。

  Java位元組碼裡的分支和跳轉指令分別是”goto"和"jsr"。

  1. goto [branchbyte1] [branchbyte2]  
  2. jsr [branchbyte1] [branchbyte2]  
  這兩個指令都接收一個2位元組的有符號的分支跳轉偏移量做為運算元,因此偏移量最大隻能達到65535。不過,為了支援更多的跳轉,Java位元組碼提供了"goto_w"和"jsr_w"這兩個可以接收4位元組分支偏移的指令。
  1. goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]  
  2. jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]  

  有了這兩個指令,索引超過65535的分支也是可用的。因此,Java方法的65535位元組的限制就可以解除了。不過,由於Java class檔案的更多的其他的限制,使得Java方法還是不能超過65535位元組。

  為了展示其他的限制,我會簡單講解一下class 檔案的格式。

  Java class檔案的大致結構如下:

  1. ClassFile {  
  2.     u4 magic;  
  3.     u2 minor_version;  
  4.     u2 major_version;  
  5.     u2 constant_pool_count;  
  6.     cp_info constant_pool[constant_pool_count-1];  
  7.     u2 access_flags;  
  8.     u2 this_class;  
  9.     u2 super_class;  
  10.     u2 interfaces_count;  
  11.     u2 interfaces[interfaces_count];  
  12.     u2 fields_count;  
  13.     field_info fields[fields_count];  
  14.     u2 methods_count;  
  15.     method_info methods[methods_count];  
  16.     u2 attributes_count;  
  17.     attribute_info attributes[attributes_count];}  

  上面的內容是來自《The Java Virtual Machine Specification,Second Edition》的4.1節“The ClassFile Structure"。

  之前反彙編的UserService.class檔案反彙編的結果的前16個位元組在十六進位制編輯器中如下所示:

  ca fe ba be 00 00 00 32 00 28 07 00 02 01 00 1b

  通過這些數值,我們可以來看看class檔案的格式。

  •  magic:class檔案最開始的四個位元組是魔數。它的值是用來標識Java class檔案的。從上面的內容裡可以看出,魔數的值是0xCAFEBABE。簡而言之,只有一個檔案的起始4位元組是0xCAFEBABE的時候,它才會被當作Java class檔案來處理。
  •  minor_version,major_version:接下來的四個位元組表示的是class檔案的版本。UserService.class檔案裡的是0x00000032,所以這個class檔案的版本是50.0。JDK 1.6編譯的class檔案的版本是50.0,JDK 1.5編譯出來的class檔案的版本是49.0。JVM必須對低版本的class檔案保持後向相容性,也就是低版本的class檔案可以執行在高版本的JVM上。不過,反過來就不行了,當一個高版本的class檔案執行在低版本的JVM上時,會出現java.lang.UnsupportedClassVersionError的錯誤。
  • constant_pool_count,constant_pool[]:在版本號之後,存放的是類的常量池。這裡儲存的資訊將會放入執行時常量池(Runtime Constant Pool)中去,這個後面會講解的。在載入一個class檔案的時候,JVM會把常量池裡的資訊存放在方法區的執行時常量區裡。UserService.class檔案裡的constant_pool_count的值是0x0028,這表示常量池裡有39(40-1)個常量。
  •  access_flags:這是表示一個類的描述符的標誌;換句話說,它表示一個類是public,final還是abstract以及是不是介面的標誌。
  •  this_class, super_class: The index in the constant_pool for the class corresponding to this and super, respectively.
  •  interfaces_count, interfaces[]: The index in the the constant_pool for the number of interfaces implemented by the class and each interface.
  •  fields_count,fields[]:當前類的成員變數的數量以及成員變數的資訊。成員變數的資訊包含變數名,型別,修飾符以及變數在constant_pool裡的索引。
  •  methods_count,methods[]:當前類的方法數量以及方法的資訊。方法的資訊包含方法名,引數的數量和型別,返回值的型別,修飾符,以及方法在constant_pool裡的索引,方法的可執行程式碼以及異常資訊。
  •  attributes_count,attributes[]:attribution_info結構包含不同種類的屬性。field_info和method_info裡都包含了attribute_info結構。

  javap簡要地給出了class檔案的一個可讀形式。當你用"java -verbose"命令來分析UserService.class時,會輸出如下的內容:

  1. Compiled from "UserService.java"
  2. publicclass com.nhn.service.UserService extends java.lang.Object  
  3.   SourceFile: "UserService.java"
  4.   minor version: 0
  5.   major version: 50
  6.   Constant pool:const #1 = class        #2;     //  com/nhn/service/UserService
  7. const #2 = Asciz        com/nhn/service/UserService;  
  8. const #3 = class        #4;     //  java/lang/Object
  9. const #4 = Asciz        java/lang/Object;  
  10. const #5 = Asciz        admin;  
  11. const #6 = Asciz        Lcom/nhn/user/UserAdmin;;// … omitted - constant pool continued …
  12. {  
  13. // … omitted - method information …
  14. publicvoid add(java.lang.String);  
  15.   Code:  
  16.    Stack=2, Locals=2, Args_size=2
  17.    0:   aload_0  
  18.    1:   getfield        #15//Field admin:Lcom/nhn/user/UserAdmin;
  19.    4:   aload_1  
  20.    5:   invokevirtual   #23//Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;
  21.    8:   pop  
  22.    9:   return  LineNumberTable:  
  23.    line 140
  24.    line 159  LocalVariableTable:  
  25.    Start  Length  Slot  Name   Signature  
  26.    0100this       Lcom/nhn/service/UserService;  
  27.    0101    userName       Ljava/lang/String; // … Omitted - Other method information …
  28. }  

  javap輸出的內容太長,我這裡只是提出了整個輸出的一部分。整個的輸出展示了constant_pool裡的不同資訊,以及方法的內容。

  關於方法的65565位元組大小的限制是和method_info struct相關的。method_info結構包含Code,LineNumberTable,以及LocalViriable attribute幾個屬性,這個在“javap -verbose"的輸出裡可以看到。Code屬性裡的LineNumberTable,LocalVariableTable以及exception_table的長度都是用一個固定的2位元組來表示的。因此,方法的大小是不能超過LineNumberTable,LocalVariableTable以及exception_table的長度的,它們都是65535位元組。

  許多人都在抱怨方法的大小限制,而且在JVM規範裡還說名了”這個長度以後有可能會是可擴充套件的“。不過,到現在為止,還沒有為這個限制做出任何動作。從JVM規範裡的把class檔案裡的內容直接拷貝到方法區這個特點來看,要想在保持後向相容性的同時來擴充套件方法區的大小是非常困難的。
  如果因為Java編譯器的錯誤而導致class檔案的錯誤,會怎麼樣呢?或者,因為網路傳輸的錯誤導致拷貝的class檔案的損壞呢?

  為了預防這種場景,Java的類裝載器通過一個嚴格而且慎密的過程來校驗class檔案。在JVM規範裡詳細地講解了這方面的內容。

注意

我們怎樣能夠判斷JVM正確地執行了class檔案校驗的所有過程呢?我們怎麼來判斷不同提供商的不同JVM實現是符合JVM規範的呢?為了能夠驗證以上兩點,Oracle提供了一個測試工具TCK(Technology Compatibility Kit)。這個TCK工具通過執行成千上萬的測試用例來驗證一個JVM是否符合規範,這些測試裡面包含了各種非法的class檔案。只有通過了TCK的測試的JVM才能稱作JVM。

和TCK相似,有一個組織JCP(Java Community Process;http://jcp.org)負責Java規範以及新的Java技術規範。對於JCP而言,如果要完成一項Java規範請求(Java Specification Request, JSR)的話,需要具備規範文件,可參考的實現以及通過TCK測試。任何人如果想使用一項申請JSR的新技術的話,他要麼使用RI提供許可的實現,要麼自己實現一個並且保證通過TCK的測試。

JVM結構

   Java編寫的程式碼會按照下圖的流程來執行:


圖 1: Java程式碼執行流程

類裝載器裝載負責裝載編譯後的位元組碼,並載入到執行時資料區(Runtime Data Area),然後執行引擎執行會執行這些位元組碼。

1.方法區

也稱"永久代” 、“非堆”,  它用於儲存虛擬機器載入的類資訊、常量、靜態變數、是各個執行緒共享的記憶體區域。預設最小值為16MB,最大值為64MB,可以通過-XX:PermSize 和 -XX:MaxPermSize 引數限制方法區的大小。

執行時常量池:是方法區的一部分,Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用於存放編譯器生成的各種符號引用,這部分內容將在類載入後放到方法區的執行時常量池中。

2.虛擬機器棧

描述的是java 方法執行的記憶體模型:每個方法被執行的時候 都會建立一個“棧幀”用於儲存區域性變量表(包括引數)、操作棧、方法出口等資訊。每個方法被呼叫到執行完的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。宣告週期與執行緒相同,是執行緒私有的

 區域性變量表存放了編譯器可知的各種基本資料型別(boolean、byte、char、short、int、float、long、double)、物件引用(引用指標,並非物件本身),其中64位長度的long和double型別的資料會佔用2個區域性變數的空間,其餘資料型別只佔1個。區域性變量表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的區域性變數是完全確定的,在執行期間棧幀不會改變區域性變量表的大小空間。

3.本地方法棧

 與虛擬機器棧基本類似,區別在於虛擬機器棧為虛擬機器執行的java方法服務,而本地方法棧則是為Native方法服務。

4.堆 

也叫做java 堆、GC堆是java虛擬機器所管理的記憶體中最大的一塊記憶體區域,也是被各個執行緒共享的記憶體區域,在JVM啟動時建立。該記憶體區域存放了物件例項及陣列(所有new的物件)。其大小通過-Xms(最小值)和-Xmx(最大值)引數設定,-Xms為JVM啟動時申請的最小記憶體,預設為作業系統實體記憶體的1/64但小於1G,-Xmx為JVM可申請的最大記憶體,預設為實體記憶體的1/4但小於1G,預設當空餘堆記憶體小於40%時,JVM會增大Heap到-Xmx指定的大小,可通過-XX:MinHeapFreeRation=來指定這個比列;當空餘堆記憶體大於70%時,JVM會減小heap的大小到-Xms指定的大小,可通過XX:MaxHeapFreeRation=來指定這個比列,對於執行系統,為避免在執行時頻繁調整Heap的大小,通常-Xms與-Xmx的值設成一樣

由於現在收集器都是採用分代收集演算法,堆被劃分為新生代和老年代。新生代主要儲存新建立的物件和尚未進入老年代的物件。老年代儲存經過多次新生代GC(Minor GC)任然存活的物件。

新生代:

 程式新建立的物件都是從新生代分配記憶體,新生代由Eden Space和兩塊相同大小的Survivor Space(通常又稱S0和S1或From和To)構成,可通過-Xmn引數來指定新生代的大小,也可以通過-XX:SurvivorRation來調整Eden Space及Survivor Space的大小。

老年代:

用於存放經過多次新生代GC任然存活的物件,例如快取物件,新建的物件也有可能直接進入老年代,主要有兩種情況:①.大物件,可通過啟動引數設定-XX:PretenureSizeThreshold=1024(單位為位元組,預設為0)來代表超過多大時就不在新生代分配,而是直接在老年代分配。②.大的陣列物件,切陣列中無引用外部物件。

老年代所佔的記憶體大小為-Xmx對應的值減去-Xmn對應的值。

5.程式計數器 

是最小的一塊記憶體區域,它的作用是當前執行緒所執行的位元組碼的行號指示器,在虛擬機器的模型裡,位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、異常處理、執行緒恢復等基礎功能都需要依賴計數器完成。

類載入器(Class Loader)

  Java提供了動態的裝載特性;它會在執行時的第一次引用到一個class的時候對它進行裝載和連結,而不是在編譯期進行。JVM的類裝載器負責動態裝載。Java類裝載器有如下幾個特點:

  •  層級結構:Java裡的類裝載器被組織成了有父子關係的層級結構。Bootstrap類裝載器是所有裝載器的父親。
  • 代理模式:基於層級結構,類的裝載可以在裝載器之間進行代理。當裝載器裝載一個類時,首先會檢查它是否在父裝載器中進行裝載了。如果上層的裝載器已經裝載了這個類,這個類會被直接使用。反之,類裝載器會請求裝載這個類。
  • 可見性限制:一個子裝載器可以查詢父裝載器中的類,但是一個父裝載器不能查詢子裝載器裡的類。
  • 不允許解除安裝:類裝載器可以裝載一個類但是不可以解除安裝它,不過可以刪除當前的類裝載器,然後建立一個新的類裝載器。

  每個類裝載器都有一個自己的名稱空間用來儲存已裝載的類。當一個類裝載器裝載一個類時,它會通過儲存在名稱空間裡的類全侷限定名(Fully Qualified Class Name)進行搜尋來檢測這個類是否已經被載入了。如果兩個類的全侷限定名是一樣的,但是如果名稱空間不一樣的話,那麼它們還是不同的類。不同的名稱空間表示class被不同的類裝載器裝載。

  下圖展示了類裝載器的代理模型。


圖 2: 類載入器代理模型

  當一個類裝載器(class loader)被請求裝載類時,它首先按照順序在上層裝載器、父裝載器以及自身的裝載器的快取裡檢查這個類是否已經存在。簡單來說,就是在快取裡檢視這個類是否已經被自己裝載過了,如果沒有的話,繼續查詢父類的快取,直到在bootstrap類裝載器裡也沒有找到的話,它就會自己在檔案系統裡去查詢並且載入這個類。

  • 啟動類載入器(Bootstrap class loader):這個類裝載器是在JVM啟動的時候建立的。它負責裝載Java API,包含Object物件。和其他的類裝載器不同的地方在於這個裝載器是通過native code來實現的,而不是用Java程式碼。
  • 擴充套件類載入器(Extension class loader):它裝載除了基本的Java API以外的擴充套件類。它也負責裝載其他的安全擴充套件功能。
  • 系統類載入器(System class loader):如果說bootstrap class loader和extension class loader負責載入的是JVM的元件,那麼system class loader負責載入的是應用程式類。它負責載入使用者在$CLASSPATH裡指定的類。
  • 使用者自定義類載入器(User-defined class loader):這是應用程式開發者用直接用程式碼實現的類裝載器。

   類似於web應用服務(WAS)之類的框架會用這種結構來對Web應用和企業級應用進行分離。換句話來說,類裝載器的代理模型可以用來保證不同應用之間的相互獨立。WAS類裝載器使用這種層級結構,不同的WAS供應商的裝載器結構有稍許區別。

  如果類裝載器查詢到一個沒有裝載的類,它會按照下圖的流程來裝載和連結這個類:


圖 3: 類載入的各個階段

  每個階段的描述如下:

  •  Loading:類的資訊從檔案中獲取並且載入到JVM的記憶體裡。
  •  Verifying:檢查讀入的結構是否符合Java語言規範以及JVM規範的描述。這是類裝載中最複雜的過程,並且花費的時間也是最長的。並且JVM TCK工具的大部分場景的用例也用來測試在裝載錯誤的類的時候是否會出現錯誤。
  • Preparing:分配一個結構用來儲存類資訊,這個結構中包含了類中定義的成員變數,方法和介面的資訊。
  • Resolving:把這個類的常量池中的所有的符號引用改變成直接引用。
  • Initializing:把類中的變數初始化成合適的值。執行靜態初始化程式,把靜態變數初始化成指定的值。

  JVM規範定義了上面的幾個任務,不過它允許具體執行的時候能夠有些靈活的變動。

執行時資料區(Runtime Data Areas)

圖 4: 執行時資料區

  執行時資料區是在JVM執行的時候作業系統所分配的記憶體區。執行時記憶體區可以劃分為6個區域。在這6個區域中,一個PC Register,JVM stack 以及Native Method Statck都是按照執行緒建立的,Heap,Method Area以及Runtime Constant Pool都是被所有執行緒公用的。

  •  PC暫存器(PC register):每個執行緒啟動的時候,都會建立一個PC(Program Counter,程式計數器)暫存器。PC暫存器裡儲存有當前正在執行的JVM指令的地址。
  • JVM 堆疊(JVM stack):每個執行緒啟動的時候,都會建立一個JVM堆疊。它是用來儲存棧幀的。JVM只會在JVM堆疊上對棧幀進行push和pop的操作。如果出現了異常,堆疊跟蹤資訊的每一行都代表一個棧幀立的資訊,這些資訊是通過類似於printStackTrace()這樣的方法來展示的。

圖 5: JVM堆疊

  --- 棧幀(stack frame):每當一個方法在JVM上執行的時候,都會建立一個棧幀,並且會新增到當前執行緒的JVM堆疊上。當這個方法執行結束的時候,這個棧幀就會被移除。每個棧幀裡都包含有當前正在執行的方法所屬類的本地變數陣列,運算元棧,以及執行時常量池的引用。本地變數陣列的和運算元棧的大小都是在編譯時確定的。因此,一個方法的棧幀的大小也是固定不變的。

--- 區域性變數陣列(Local variable array):這個陣列的索引從0開始。索引為0的變量表示這個方法所屬的類的例項。從1開始,首先存放的是傳給該方法的引數,在引數後面儲存的是方法的區域性變數。

  --- 運算元棧(Operand stack):方法實際執行的工作空間。每個方法都在運算元棧和區域性變數陣列之間交換資料,並且壓入或者彈出其他方法返回的結果。運算元棧所需的最大空間是在編譯期確定的。因此,運算元棧的大小也可以在編譯期間確定。

  •  本地方法棧(Native method stack):供用非Java語言實現的本地方法的堆疊。換句話說,它是用來呼叫通過JNI(Java Native Interface Java本地介面)呼叫的C/C++程式碼。根據具體的語言,一個C堆疊或者C++堆疊會被建立。
  •  方法區(Method area):方法區是所有執行緒共享的,它是在JVM啟動的時候建立的。它儲存所有被JVM載入的類和介面的執行時常量池,成員變數以及方法的資訊,靜態變數以及方法的位元組碼。JVM的提供者可以通過不同的方式來實現方法區。在Oracle 的HotSpot JVM裡,方法區被稱為永久區或者永久代(PermGen)。是否對方法區進行垃圾回收對JVM的實現是可選的。
  •   執行時常量池(Runtime constant pool):這個區域和class檔案裡的constant_pool是相對應的。這個區域是包含在方法區裡的,不過,對於JVM的操作而言,它是一個核心的角色。因此在JVM規範裡特別提到了它的重要性。除了包含每個類和介面的常量,它也包含了所有方法和變數的引用。簡而言之,當一個方法或者變數被引用時,JVM通過執行時常量區來查詢方法或者變數在記憶體裡的實際地址。
  • 堆(Heap):用來儲存例項或者物件的空間,而且它是垃圾回收的主要目標。當討論類似於JVM效能之類的問題時,它經常會被提及。JVM提供者可以決定怎麼來配置堆空間,以及不對它進行垃圾回收。

  現在我們再回過頭來看看之前反彙編的位元組碼。

  1. publicvoid add(java.lang.String);  
  2.   Code:  
  3.    0:   aload_0  
  4.    1:   getfield        #15//Field admin:Lcom/nhn/user/UserAdmin;
  5.    4:   aload_1  
  6.    5:   invokevirtual   #23//Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;
  7.    8:   pop  
  8.    9:   return

  把上面的反彙編程式碼和我們平時所見的x86架構的彙編程式碼相比較,我們會發現這兩者的結構有點相似,都使用了操作碼;不過,有一點不同的地方是Java位元組碼並不會在運算元裡寫入暫存器的名稱、記憶體地址或者偏移量。之前已經說過,JVM用的是棧,它不會使用暫存器。和使用暫存器的x86架構不同,它自己負責記憶體的管理。它用索引例如15和23來代替實際的記憶體地址。15和23都是當前類(這裡是UserService類)的常量池裡的索引。簡而言之,JVM為每個類建立了一個常量池,並且這個常量池裡儲存了實際目標的引用。

  每行反彙編程式碼的解釋如下:

  •  aload_0: 把區域性變數陣列中索引為#0的變數新增到運算元棧上。索引#0所表示的變數是this,即是當前例項的引用。
  • getfield #15: 把當前類的常量池裡的索引為#15的變數新增到運算元棧。這裡新增的是UserAdmin的admin成員變數。因為admin變數是個類的例項,因此新增的是一個引用。
  • aload_1: 把區域性變數數組裡的索引為#1的變數新增到運算元棧。來自區域性變數數組裡的索引為1的變數是方法的一個引數。因此,在呼叫add()方法的時候,會把userName指向的String的引用新增到運算元棧上。
  • invokevirtual #23: 呼叫當前類的常量池裡的索引為#23的方法。這個時候,通過getfile和aload_1新增到運算元棧上的引用都被作為方法的引數。當方法執行完成並且返回時,它的返回值會被新增到運算元棧上。
  •  pop: 把通過invokevirtual呼叫的方法的返回值從運算元棧裡彈出來。你可以看到,在前面的例子裡,用老的類庫編譯的那段程式碼是沒有返回值的。簡而言之,正因為之前的程式碼沒有返回值,所以沒必要吧把返回值從運算元棧上給彈出來。
  •  return:結束當前方法呼叫。

  下圖可以幫助你更好地理解上面的內容。


6: 裝載到執行時資料區的Java位元組碼示例

  順便提一下,在這個方法裡,區域性變數陣列沒有被修改。所以上圖只顯示了運算元棧的變化。不過,大部分的情況下,區域性變數陣列也是會改變的。區域性變數陣列和運算元棧之間的資料傳輸是使用通過大量的load指令(aload,iload)和store指令(astore,istore)來實現的。

  在這個圖裡,我們簡單驗證了執行時常量池和JVM棧的描述。當JVM執行的時候,每個類的例項都會在堆上進行分配,User,UserAdmin,UserService以及String等類的資訊都會儲存在方法區。

執行引擎(Execution Engine)

  通過類裝載器裝載的,被分配到JVM的執行時資料區的位元組碼會被執行引擎執行。執行引擎以指令為單位讀取Java位元組碼。它就像一個CPU一樣,一條一條地執行機器指令。每個位元組碼指令都由一個1位元組的操作碼和附加的運算元組成。執行引擎取得一個操作碼,然後根據運算元來執行任務,完成後就繼續執行下一條操作碼。

  不過Java位元組碼是用一種人類可以讀懂的語言編寫的,而不是用機器可以直接執行的語言。因此,執行引擎必須把位元組碼轉換成可以直接被JVM執行的語言。位元組碼可以通過以下兩種方式轉換成合適的語言。

  • 直譯器:一條一條地讀取,解釋並且執行位元組碼指令。因為它一條一條地解釋和執行指令,所以它可以很快地解釋位元組碼,但是執行起來會比較慢。這是解釋執行的語言的一個缺點。位元組碼這種“語言”基本來說是解釋執行的。
  • 即時(Just-In-Time)編譯器:即時編譯器被引入用來彌補直譯器的缺點。執行引擎首先按照解釋執行的方式來執行,然後在合適的時候,即時編譯器把整段位元組碼編譯成原生代碼。然後,執行引擎就沒有必要再去解釋執行方法了,它可以直接通過原生代碼去執行它。執行原生代碼比一條一條進行解釋執行的速度快很多。編譯後的程式碼可以執行的很快,因為原生代碼是儲存在快取裡的。

  不過,用JIT編譯器來編譯程式碼所花的時間要比用直譯器去一條條解釋執行花的時間要多。因此,如果程式碼只被執行一次的話,那麼最好還是解釋執行而不是編譯後再執行。因此,內建了JIT編譯器的JVM都會檢查方法的執行頻率,如果一個方法的執行頻率超過一個特定的值的話,那麼這個方法就會被編譯成原生代碼。


圖 7:Java編譯器和JIT編譯器

  JVM規範沒有定義執行引擎該如何去執行。因此,JVM的提供者通過使用不同的技術以及不同型別的JIT編譯器來提高執行引擎的效率。

  大部分的JIT編譯器都是按照下圖的方式來執行的:


圖 8: JIT編譯器

  JIT編譯器把位元組碼轉換成一箇中間層表示式,一種中間層的表示方式,來進行優化,然後再把這種表示轉換成原生代碼。

  Oracle Hotspot VM使用一種叫做熱點編譯器的JIT編譯器。它之所以被稱作”熱點“是因為熱點編譯器通過分析找到最需要編譯的“熱點”程式碼,然後把熱點程式碼編譯成原生代碼。如果已經被編譯成原生代碼的位元組碼不再被頻繁呼叫了,換句話說,這個方法不再是熱點了,那麼Hotspot VM會把編譯過的原生代碼從cache裡移除,並且重新按照解釋的方式來執行它。Hotspot VM分為Server VM和Client VM兩種,這兩種VM使用不同的JIT編譯器。


Figure 9: Hotspot Client VM and Server VM

  Client VM 和Server VM使用完全相同的執行時,不過如上圖所示,它們所使用的JIT編譯器是不同的。Server VM用的是更高階的動態優化編譯器,這個編譯器使用了更加複雜並且更多種類的效能優化技術。

  IBM 在IBM JDK 6裡不僅引入了JIT編譯器,它同時還引入了AOT(Ahead-Of-Time)編譯器。它使得多個JVM可以通過共享快取來共享編譯過的原生代碼。簡而言之,通過AOT編譯器編譯過的程式碼可以直接被其他JVM使用。除此之外,IBM JVM通過使用AOT編譯器來提前把程式碼編譯器成JXE(Java EXecutable)檔案格式來提供一種更加快速的執行方式。

  大部分Java程式的效能都是通過提升執行引擎的效能來達到的。正如JIT編譯器一樣,很多優化的技術都被引入進來使得JVM的效能一直能夠得到提升。最原始的JVM和最新的JVM最大的差別之處就是在於執行引擎。

  Hotspot編譯器在1.3版本的時候就被引入到Oracle Hotspot VM裡了,JIT編譯技術在Anroid 2.2版本的時候被引入到Dalvik VM裡。

  注意:

  引入一種中間語言,例如位元組碼,虛擬機器執行位元組碼,並且通過JIT編譯器來提升JVM的效能的這種技術以及廣泛應用在使用中間語言的程式語言上。例如微軟的.Net,CLR(Common Language Runtime 公共語言執行時),也是一種VM,它執行一種被稱作CIL(Common Intermediate Language)的位元組碼。CLR提供了AOT編譯器和JIT編譯器。因此,用C#或者VB.NET編寫的原始碼被編譯後,編譯器會生成CIL並且CIL會執行在有JIT編譯器的CLR上。CLR和JVM相似,它也有垃圾回收機制,並且也是基於堆疊執行。

Java虛擬機器規範,Java SE 第7版

  2011年7月28日,Oracle釋出了Java SE的第7個版本,並且把JVM規也更新到了相應的版本。在1999年釋出《The Java Virtual Machine Specification,Second Edition》後,Oracle花了12年來發布這個更新的版本。這個更新的版本包含了這12年來累積的眾多變化以及修改,並且更加細緻地對規範進行了描述。此外,它還反映了《The Java Language Specificaion,Java SE 7 Edition》裡的內容。主要的變化總結如下:

  • 來自Java SE 5.0裡的泛型,支援可變引數的方法
  • 從Java SE 6以來,位元組碼校驗的處理技術所發生的改變
  • 新增invokedynamic指令以及class檔案對於該指令的支援
  • 刪除了關於Java語言概念的內容,並且指引讀者去參考Java語言規範
  •  刪除關於Java執行緒和鎖的描述,並且把它們移到Java語言規範裡

  最大的改變是添加了invokedynamic指令。也就是說JVM的內部指令集做了修改,使得JVM開始支援動態型別的語言,這種語言的型別不是固定的,例如指令碼語言以及來自Java SE 7裡的Java語言。之前沒有被用到的操作碼186被分配給新指令invokedynamic,而且class檔案格式裡也添加了新的內容來支援invokedynamic指令。

  Java SE 7的編譯器生成的class檔案的版本號是51.0。Java SE 6的是50.0。class檔案的格式變動比較大,因此,51.0版本的class檔案不能夠在Java SE 6的虛擬機器上執行。

  儘管有了這麼多的變動,但是Java方法的65535位元組的限制還是沒有被去掉。除非class檔案的格式徹底改變,否者這個限制將來也是不可能去掉的。

  值得說明的是,Oracle Java SE 7 VM支援G1這種新的垃圾回收機制,不過,它被限制在Oracle JVM上,因此,JVM本身對於垃圾回收的實現不做任何限制。也因此,在JVM規範裡沒有對它進行描述。

switch語句裡的String

  Java SE 7裡添加了很多新的語法和特性。不過,在Java SE 7的版本里,相對於語言本身而言,JVM沒有多少的改變。那麼,這些新的語言特性是怎麼來實現的呢?我們通過反彙編的方式來看看switch語句裡的String(把字串作為switch()語句的比較物件)是怎麼實現的?

  例如,下面的程式碼:

  1. // SwitchTest
  2. publicclass SwitchTest {  
  3.     publicint doSwitch(String str) {  
  4.         switch (str) {  
  5.         case"abc":        return1;  
  6.         case"123":        return2;  
  7.         default:         return0;  
  8.         }  
  9.     }  
  10. }  
  因為這是Java SE 7的一個新特性,所以它不能在Java SE 6或者更低版本的編譯器上來編譯。用Java SE 7的javac來編譯。下面是通過javap -c來反編譯後的結果。
  1. C:Test>javap -c SwitchTest.classCompiled from "SwitchTest.java"
  2. publicclass SwitchTest {  
  3.   public SwitchTest();  
  4.     Code:  
  5.        0: aload_0  
  6.        1: invokespecial #1// Method java/lang/Object."<init>":()V
  7.        4returnpublicint doSwitch(java.lang.String);  
  8.     Code:  
  9.        0: aload_1  
  10.        1: astore_2  
  11.        2: iconst_m1  
  12.        3: istore_3  
  13.        4: aload_2  
  14.        5: invokevirtual #2// Method java/lang/String.hashCode:()I
  15.        8: lookupswitch  { // 2
  16.                  4869050
  17.                  9635436
  18.                default61
  19.           }  
  20.       36: aload_2  
  21.       37: ldc           #3// String abc
  22.       39: invokevirtual #4// Method java/lang/String.equals:(Ljava/lang/Object;)Z
  23.       42: ifeq          61
  24.       45: iconst_0  
  25.       46: istore_3  
  26.       47goto61
  27.       50: aload_2  
  28.       51: ldc           #5// String 123
  29.       53: invokevirtual #4// Method java/lang/String.equals:(Ljava/lang/Object;)Z
  30.       56: ifeq          61
  31.       59: iconst_1  
  32.       60: istore_3  
  33.       61: iload_3  
  34.       62: lookupswitch  { // 2
  35.                      088
  36.                      190
  37.                default92
  38.           }  
  39.       88: iconst_1  
  40.       89: ireturn  
  41.       90: iconst_2  
  42.       91: ireturn  
  43.       92: iconst_0  
  44.