1. 程式人生 > >JVM執行機制

JVM執行機制

每個Java開發者都知道Java位元組碼是執行在JRE(Java Runtime Environment Java執行時環境)上的。JRE中最重要的部分是Java虛擬機器(JVM),JVM負責分析和執行Java位元組碼。Java開發人員並不需要去關心JVM是如何執行的。在沒有深入理解JVM的情況下,許多開發者已經開發出了非常多的優秀的應用以及Java類庫。不過,如果你瞭解JVM的話,你會更加了解Java的,並且你會輕鬆解決那些看似簡單但是無從下手的問題。

因此,在這篇檔案裡,我會闡述JVM是如何執行的,包括它的結構,它如何去執行位元組碼,以及按照怎樣的順序去執行,同時我還會給出一些常見錯誤的示例以及對應的解決辦法。最後,我還會講解Java 7中的一些新特性。

虛擬機器(Virtual Machine)

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

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

Java虛擬機器的特點如下:

  1. 基於棧的虛擬機器:Intel x86和ARM這兩種最常見的計算機體系的機構都是基於暫存器的。不同的是,JVM是基於棧的。
  2. 符號引用:除了基本型別以外的資料(類和介面)都是通過符號來引用,而不是通過顯式地使用記憶體地址來引用。
  3. 垃圾回收機制:類的例項都是通過使用者程式碼進行建立,並且自動被垃圾回收機制進行回收。
  4. 通過對基本型別的清晰定義來保證平臺獨立性:傳統的程式語言,例如C/C++,int型別的大小取決於不同的平臺。JVM通過對基本型別的清晰定義來保證它的相容性以及平臺獨立性。
  5. 網路位元組碼順序: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. public void add(String userName) {  
  4.     admin.addUser(userName);  

更新後的類庫的原始碼和原始的程式碼如下。

  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. public void addUser(String userName) {  
  11.     User user = new User(userName);  
  12.     userMap.put(userName, user);  

簡而言之,之前沒有返回值的addUser()被改修改成返回一個User類的例項的方法。不過,應用的程式碼沒有做任何修改,因為它沒有使用addUser()的返回值。

咋一看,com.nhn.user.UserAdmin.addUser()方法似乎仍然存在,如果存在的話,那麼怎麼還會出現NoSuchMethodError的錯誤呢?

原因

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

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

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. public void 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 

invokeinterface:呼叫一個介面方法在這段Java彙編程式碼中,addUser()方法是在第四行的“5:invokevitual#23″進行呼叫的。這表示對應索引為23的方法會被呼叫。索引為23的方法的名稱已經被javap給註解在旁邊了。invokevirtual是Java位元組碼裡呼叫方法的最基本的操作碼。在Java位元組碼裡,有四種操作碼可以用來呼叫一個方法,分別是:invokeinterface,invokespecial,invokestatic以及invokevirtual。操作碼的作用分別如下:

  • invokespecial: 呼叫一個初始化方法,私有方法或者父類的方法
  • invokestatic:呼叫靜態方法
  • invokevirtual:呼叫例項方法

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

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

  1. public void 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位元組碼中的型別表示式在Java位元組碼裡,類的例項用字母“L;”表示,void 用字母“V”表示。通過這種方式,其他的型別也有對應的表示式。下面的表格對此作了總結。

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

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

想了解更多細節的話,參考《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程式碼沒有正常執行,而是出現了下面的錯誤。

現象

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

  1. Servlet.service() for servlet jsp threw exception org.apache.jasper.JasperException: Unable to compile class for 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以及是不是介面的標誌。
  • 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. public class 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. public void 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.    0      10      0    this       Lcom/nhn/service/UserService;  
  27.    0      10      1    userName       Ljava/lang/String; // … Omitted - Other method information …  

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),然後執行引擎執行會執行這些位元組碼。

類載入器(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. public void 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執行的語言。位元組碼可以通過以下兩種方式轉換成合適的語言。

  • 直譯器:一條一條地讀取,解釋並且執行位元組碼指令。因為它一條一條地解釋和執行指令,所以它可以很快地解釋位元