深入理解JVM內幕:從基本結構到Java 7新特性

原文:http://www.importnew.com/1486.html

每個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 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
2
3
Exception in thread "main" java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V
    at com.nhn.service.UserService.add(UserService.java:14)
    at com.nhn.service.UserService.main(UserService.java:19)

應用的代碼如下,而且它沒有被改動過。

1
2
3
4
5
// UserService.java
public void add(String userName) {
    admin.addUser(userName);
}

更新後的類庫的源代碼和原始的代碼如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
// UserAdmin.java - Updated library source code
public User addUser(String userName) {
    User user = new User(userName);
    User prevUser = userMap.put(userName, user);
    return prevUser;
}
// UserAdmin.java - Original library source code
public void addUser(String userName) {
    User user = new User(userName);
    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;)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
2
3
4
5
6
7
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;)V
   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
2
3
4
5
6
7
8
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

你會發現,對應索引爲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 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
2
Servlet.service() for servlet jsp threw exception org.apache.jasper.JasperException: Unable to compile class for JSP Generated servlet error:
The code of method _jspService(HttpServletRequest, HttpServletResponse) is exceeding the 65535 bytes limit"

原因

在不同的Web服務器上,上面的錯誤信息可能會有點不同,不過有有一點肯定是相同的,它出現的原因是65535字節的限制。這個65535字節的限制是JVM規範裏的限制,它規定了一個方法的大小不能超過65535字節

 

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

Java字節碼裏的分支和跳轉指令分別是”goto"和"jsr"。

1
2
goto [branchbyte1] [branchbyte2]
jsr [branchbyte1] [branchbyte2]

這兩個指令都接收一個2字節的有符號的分支跳轉偏移量做爲操作數,因此偏移量最大隻能達到65535。不過,爲了支持更多的跳轉,Java字節碼提供了"goto_w"和"jsr_w"這兩個可以接收4字節分支偏移的指令。

1
2
goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]

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

爲了展示其他的限制,我會簡單講解一下class 文件的格式。

 

Java class文件的大致結構如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ClassFile {
    u4 magic;
    u2 minor_version;
    u2 major_version;
    u2 constant_pool_count;
    cp_info constant_pool[constant_pool_count-1];
    u2 access_flags;
    u2 this_class;
    u2 super_class;
    u2 interfaces_count;
    u2 interfaces[interfaces_count];
    u2 fields_count;
    field_info fields[fields_count];
    u2 methods_count;
    method_info methods[methods_count];
    u2 attributes_count;
    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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Compiled from "UserService.java"
 
public class com.nhn.service.UserService extends java.lang.Object
  SourceFile: "UserService.java"
  minor version: 0
  major version: 50
  Constant pool:const #1 = class        #2;     //  com/nhn/service/UserService
const #2 = Asciz        com/nhn/service/UserService;
const #3 = class        #4;     //  java/lang/Object
const #4 = Asciz        java/lang/Object;
const #5 = Asciz        admin;
const #6 = Asciz        Lcom/nhn/user/UserAdmin;;// … omitted - constant pool continued …
 
{
// … omitted - method information …
 
public void add(java.lang.String);
  Code:
   Stack=2, Locals=2, Args_size=2
   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  LineNumberTable:
   line 14: 0
   line 15: 9  LocalVariableTable:
   Start  Length  Slot  Name   Signature
   0      10      0    this       Lcom/nhn/service/UserService;
   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
2
3
4
5
6
7
8
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都是當前類(這裏是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
2
3
4
5
6
7
8
9
10
// 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 SE 7的javac來編譯。下面是通過javap -c來反編譯後的結果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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

在#5和#8字節處,首先是調用了hashCode()方法,然後它作爲參數調用了switch(int)。在lookupswitch的指令裏,根據hashCode的結果進行不同的分支跳轉。字符串“abc"的hashCode是96354,它會跳轉到#36處。字符串”123“的hashCode是48690,它會跳轉到#50處。生成的字節碼的長度比Java源碼長多了。首先,你可以看到字節碼裏用lookupswitch指令來實現switch()語句。不過,這裏使用了兩個lookupswitch指令,而不是一個。如果反編譯的是針對Int的switch()語句的話,字節碼裏只會使用一個lookupswitch指令。也就是說,針對string的switch語句被分成用兩個語句來實現。留心標號爲#5,#39和#53的指令,來看看switch()語句是如何處理字符串的。

在第#36,#37,#39,以及#42字節的地方,你可以看見str參數被equals()方法來和字符串“abc”進行比較。如果比較的結果是相等的話,‘0’會被放入到局部變量數組的索引爲#3的位置,然後跳抓轉到第#61字節。

在第#50,#51,#53,以及#56字節的地方,你可以看見str參數被equals()方法來和字符串“123”進行比較。如果比較的結果是相等的話,10’會被放入到局部變量數組的索引爲#3的位置,然後跳轉到第#61字節。

在第#61和#62字節的地方,局部變量數組裏索引爲#3的值,這裏是'0',‘1’或者其他的值,被lookupswitch用來進行搜索並進行相應的分支跳轉。

換句話來說,在Java代碼裏的用來作爲switch()的參數的字符串str變量是通過hashCode()和equals()方法來進行比較,然後根據比較的結果,來執行swtich()語句。

在這個結果裏,編譯後的字節碼和之前版本的JVM規範沒有不兼容的地方。Java SE 7的這個用字符串作爲switch參數的特性是通過Java編譯器來處理的,而不是通過JVM來支持的。通過這種方式還可以把其他的Java SE 7的新特性也通過Java編譯器來實現。

總結

我不認爲爲了使用好Java必須去了解Java底層的實現。許多沒有深入理解JVM的開發者也開發出了很多非常好的應用和類庫。不過,如果你更加理解JVM的話,你就會更加理解Java,這樣你會有助於你處理類似於我們前面的案例中的問題。

除了這篇文章裏提到的,JVM還是用了其他的很多特性和技術。JVM規範提供了是一種擴展性很強的規範,這樣就使得JVM的提供者可以選擇更多的技術來提高性能。值得特別說明的一點是,垃圾回收技術被大多數使用虛擬機的語言所使用。不過,由於這個已經在很多地方有更加專業的研究,我這篇文章就沒有對它進行深入講解了。

對於熟悉韓語的朋友,如果你想要深入理解JVM的內部結構的話,我推薦你參考《Java Performance Fundamental》(Hando Kim,Seoul,EXEM,2009)。這本書是用韓文寫的,更適合你去閱讀。我在寫這本書的時候,參考了JVM規範,同時也參考了這本書。對於熟悉英語的朋友,你可以找到大量的關於Java性能的書籍。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章