Java.JVM.白盒測試總結

每個使用Java的開發者都知道Java字節碼是在JRE中運行(JRE: Java 運行時環境)。JVM則是JRE中的核心組成部分,承擔分析和執行Java字節碼的工作,而Java程序員通常并不需要深入了解JVM運行情況就可以開發出大型應用和類庫。盡管如此,如果你對JVM有足夠了解,就會對Java有更好的掌握,并且能解決一些看起來簡單但又尚未解決的問題。
所以,在本篇文章中,我將會介紹JVM工作原理,內部結構,Java字節碼的執行及指令的執行順序,并會介紹一些常見的JVM錯誤及其解決方案。最后會簡單介紹下Java SE7帶來的新特性。
虛擬機
JRE由Java API和JVM組成,JVM通過類加載器(Class Loader)加類Java應用,并通過Java API進行執行。
虛擬機(VM: Virtual Machine)是通過軟件模擬物理機器執行程序的執行器。最初Java語言被設計為基于虛擬機器在而非物理機器,重而實現WORA(一次編寫,到處運行)的目的,盡管這個目標幾乎被世人所遺忘。所以,JVM可以在所有的硬件環境上執行Java字節碼而無須調整Java的執行模式。
JVM的基本特性:
基于棧(Stack-based)的虛擬機: 不同于Intel x86和ARM等比較流行的計算機處理器都是基于寄存器(register)架構,JVM是基于棧執行的

符號引用(Symbolic reference): 除基本類型外的所有Java類型(類和接口)都是通過符號引用取得關聯的,而非顯式的基于內存地址的引用。

垃圾回收機制: 類的實例通過用戶代碼進行顯式創建,但卻通過垃圾回收機制自動銷毀。

通過明確清晰基本類型確保平臺無關性: 像C/C++等傳統編程語言對于int類型數據在同平臺上會有不同的字節長度。JVM卻通過明確的定義基本類型的字節長度來維持代碼的平臺兼容性,從而做到平臺無關。

網絡字節序(Network byte order): Java class文件的二進制表示使用的是基于網絡的字節序(network byte order)。為了在使用小端(little endian)的Intel x86平臺和在使用了大端(big endian)的RISC系列平臺之間保持平臺無關,必須要定義一個固定的字節序。JVM選擇了網絡傳輸協議中使用的網絡字節序,即基于大端(big endian)的字節序。

Sun 公司開發了Java語言,但任何人都可以在遵循JVM規范的前提下開發和提供JVM實現。所以目前業界有多種不同的JVM實現,包括Oracle Hostpot JVM和IBM JVM。Google公司使用的Dalvik VM也是一種JVM實現,盡管其并未完全遵循JVM規范。與基于棧機制的Java 虛擬機不同的是Dalvik VM是基于寄存器的,Java 字節碼也被轉換為Dalvik VM使用的寄存器指令集。
Java 字節碼
JVM使用Java字節碼—一種運行于Java(用戶語言)和機器語言的中間語言,以達到WORA的目的。Java字節碼是部署Java程序的最小單元。
在介紹Java 字節碼之前,我們先來看一下什么是字節碼。下面涉及的案例是曾在一個真實的開發場景中遇到過的情境。
現象
一個曾運行完好的程序在更新了類庫后卻不能再次運行,并拋出了如下異常:
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)

程序代碼如下,并在更新類庫之前未曾對這段代碼做過變更:
// UserService.java…public void add(String userName) { admin.addUser(userName);}

類庫中更新過的代碼前后對比如下:
// 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()方法在更新之前返回void而在更新之后返回了User類型實例。而程序代碼因為不關心addUser的返回值,所以在使用的過程中并未做過改變。
初看起來,com.mhn.user.UserAdmin.addUser()依然存在,但為什么會出現NoSuchMethodError?

問題分析
主要原因是程序代碼在更新類庫時并未重新編譯代碼,也就是說,雖然程序代碼看起來依然是在調用addUser方法而不關心其返回值,而對編譯的類文件來說,他是要明確知道調用方法的返回值類型的。
可以通過下面的異常信息說明這一點:
java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/langString;)V

NoSuchMethodError 是因為 "com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V" 方法找不到引起的。看一下"Ljava/lang/String;"和后面的"V"。在Java字節碼表示中,"L<classname>;"表示類的實例。所以上面的addUser方法需要一個java/lang/String對象作為參數。就這個案例中,類庫中的addUser()方法的參數未發生變化,所以參數是正常的。再看一下異常信息中最后面的"V",它表示方法的返回值類型。在Java字節碼表示中,"V"意味著該方法沒有返回值。所以上面的異常信息就是說需要一個java.lang.String參數且沒有任何返回值的com.nhn.user.UserAdmin.addUser方法找不到。
因為程序代碼是使用之前版本的類庫進編譯的,class文件中定義的是應該調用返回"V"類型的方法。然而,在改變類庫后,返回"V"類型的方法已不存在,取而代之的是返回類型為"Lcom/nhn/user/User;"的方法。所以便發生了上面看到的NoSuchMethodError。
注釋
因為開發者未針對新類庫重新編譯程序代碼,所以發生了錯誤。盡管如此,類庫提供者卻也要為此負責。因為之前沒有返回值的addUser()方法既然是public方法,但后面卻改成了會返回user實現,這意味著方法簽名發生了明顯的變化。這意味了該類庫不能對之前的版本進行兼容,所以類庫提供者必須事前對此進行通知。

我們重新回到Java 字節碼,Java 字節碼是JVM的基本元素,JVM本身就是一個用于執行Java字節碼的執行器。Java編譯器并不會把像C/C++那樣把高級語言轉為機器語言(CPU執行指令),而是把開發者能理解的Java語言轉為JVM理解的Java字節碼。因為Java字節碼是平臺無關的,所以它可以在安裝了JVM(準確的說,是JRE環境)的任何硬件環境執行,即使它們的CPU和操作系統各不相同(所以在Windows PC機上開發和編譯的class文件在不做任何調整的情況下就可以在Linux機器上執行)。編譯后文件的大小與源文件大小基本一致,所以比較容易通過網絡傳輸和執行Java字節碼。
Java class文件本身是基于二進制的文件,所以我們很難直觀的理解其中的指令。為了管理這些class 文件, JVM提供了javap命令來對二進制文件進行反編譯。執行javap得到的是直觀的java指令序列。在上面的案例中,通過對程序代碼執行javap -c就可得到應用中的UserService.add()方法的指令序列,如下:
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

在上面的Java指令中,addUser()方法是在第五行被調用,即"5: invokevirtual #23"。這句的意思是索引位置為23的方法會被調用,方法的索引位置是由javap程序標注的。invokevirtual是Java 字節碼中最常用到的一個操作碼,用于調用一個方法。另外,在Java字節碼中有4個表示調用方法的操作碼: invokeinterface_, invokespecial, invokestatic, _invokevirtual 。他們每個的含義如下:
invokeinterface: 調用接口方法

invokespecial: 調用初始化方法、私有方法、或父類中定義的方法

invokestatic: 調用靜態方法

invokevirtual: 調用實例方法

Java 字節碼的指令集包含操作碼(OpCode)和操作數(Operand)。像invokevirtual這樣的操作碼需要一個2字節長度的操作數。
對上面案例中的程序代碼,如果在更新類庫后重新編譯程序代碼,然后我們再反編譯字節碼將看到如下結果:
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執行的代碼被稱為“字節碼”。像 aload_0, getfieldinvokevirtual 都被表示為一個單字節數字。(aload_0 = 0x2a, getfiled = 0xb4, invokevirtual = 0xb6)。因此Java字節碼表示的最大指令碼為256。
像aload_0和aload_1這樣的操作碼不需要任何操作數,因此aload_0的下一個字節就是下一個指令的操作碼。而像getfield和invokevirtual這樣的操作碼卻需要一個2字節的操作數,因此第一個字節里的第二個指令getfield指令的一下指令是在第4個字節,其中跳過了2個字節。通過16進制編輯器查看字節碼如下:
2a b4 00 0f 2b b6 00 17 57 b1

在Java字節碼中,類實例表示為"L;",而void表示為"V",類似的其他類型也有各自的表示。下表列出了Java字節碼中類型表示。
表1: Java字節碼里的類型表示
Java 字節碼
類型
描述

B
byte
單字節

C
char
Unicode字符

D
double
雙精度浮點數

F
float
單精度浮點數

I
int
整型

J
long
長整型

L<classname>
引用
classname類型的實例

S
short
短整型

Z
boolean
布爾類型

[
引用
一維數組

表2: Java代碼的字節碼示例
java 代碼
Java 字節碼表示

double d[][][]
[[[D

Object mymethod(int i, double d, Thread t)
mymethod(I,D,Ljava/lang/Thread;)Ljava/lang/Object;

在《Java虛擬機技術規范第二版》的4.3 描述符(Descriptors)章節中有關于此的詳細描述,在第6章"Java虛擬機指令集"中介紹了更多不同的指令。
類文件格式
在解釋類文件格式之前,先看一個在Java Web應用中經常發生的問題。
現象
在Tomcat環境里編寫和運行JSP時,JSP文件未被執行,并伴隨著如下錯誤:
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字節的限制。這個限制是JVM定義的,用于規定方法的定義不能大于65535個字節
下面我將先介紹65535個的字節限制,然后詳細說明為什么要有這個限制。
Java字節碼中,"goto"和"jsr"指令分別表示分支和跳轉。
goto [branchbyte1] [branchbyte2]jsr [branchbyte1] [branchbyte2]

這兩個操作指令都跟著一個2字節的操作數,而2個字節能表示的最大偏移量只能是65535。然而為了支持更大范圍的分支,Java字節碼又分別定義了"goto_w" 和 "jsr_w" 用于接收4個字節的分支偏移量。
goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]

受這兩個指令所賜,分支能表示的最大偏移遠遠超過了65535,這么說來java 方法就不會再有65535個字節的限制了。然而,由于Java 類文件的各種其他限制,java方法的定義仍然不能夠超過65535個字節的限制。下面我們通過對類文件的解釋來看看java方法不能超過65535字節的其他原因。
Java類文件的大體結構如下:
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];}

上面的文件結構出自《Java虛擬機技術規范第二版》的4.1節"類文件結構"。
之前講過的UserService.class文件的前16個字節的16進制表示如下:
ca fe ba be 00 00 00 32 00 28 07 00 02 01 00 1b

我們通過對這一段符號的分析來了解一個類文件的具體格式。
magic: 類文件的前4個字節是一組魔數,是一個用于區分Java類文件的預定義值。如上所看到的,其值固定為0xCAFEBABE。也就是說一個文件的前4個字節如果是0xCAFABABE,就可以認為它是Java類文件。"CAFABABE"是與"JAVA"有關的一個有趣的魔數。

minor_version, major_version: 接下來的4個字節表示類的版本號。如上所示,0x00000032表示的類版本號為50.0。由JDK 1.6編譯而來的類文件的版本號是50.0,而由JDK 1.5編譯而來的版本號則是49.0。JVM必須保持向后兼容,即保持對比其版本低的版本的類文件的兼容。而如果在一個低版本的JVM中運行高版本的類文件,則會出現java.lang.UnsupportedClassVersionError的發生。

constant_pool_count, constant_pool[]: 緊接著版本號的是類的常量池信息。這里的信息在運行時會被分配到運行時常量池區域,后面會有對內存分配的介紹。在JVM加載類文件時,類的常量池里的信息會被分配到運行時常量池,而運行時常量池又包含在方法區內。上面UserService.class文件的constant_pool_count為0x0028,所以按照定義contant_pool數組將有(40-1)即39個元素值。

access_flags: 2字節的類的修飾符信息,表示類是否為public, private, abstract或者interface。

this_class, super_class: 分別表示保存在constant_pool數組中的當前類及父類信息的索引值。

interface_count, interfaces[]: interface_count為保存在constant_pool數組中的當前類實現的接口數的索引值,interfaces[]即表示當前類所實現的每個接口信息。

fields_count, fields[]: 類的字段數量及字段信息數組。字段信息包含字段名、類型、修飾符以及在constant_pool數組中的索引值。

methods_count, methods[]: 類的方法數量及方法信息數組。方法信息包括方法名、參數的類型及個數、返回值、修飾符、在constant_pool中的索引值、方法的可執行代碼以及異常信息。

attributes_count, attributes[]: attribute_info有多種不同的屬性,分別被field_info, method_into使用。

javap程序把class文件格式以可閱讀的方式輸出來。在對UserService.class文件使用"javap -verbose"命令分析時,輸出內容如下:
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/UserServiceconst #2 = Asciz com/nhn/service/UserService;const #3 = class #4; // java/lang/Objectconst #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 …}

由于篇幅原因,上面只抽取了部分輸出結果。在全部的輸出信息中,會為你展示包括常量池和每個方法內容等各種信息。
方法的65535個字節的限制受到了結構體method_info的影響。如上面"javap -verbose"的輸出所示,結構體method_info包括代碼(Code)、行號表(LineNumberTable)以及本地變量表(LocalVariableTable)。其中行號表、本地變量表以及代碼里的異常表(exception_table)的總長度為一個固定2字節的值。因此方法的大小不能超過行號表、本地變量表、異常表的長度,即不能超過65535個字節。
盡管很多人抱怨方法的大小限制,JVM規范也聲稱將會對此大小進行擴充,然而到目前為止并沒有明確的進展。因為JVM技術規范里定義要把幾乎整個類文件的內容都加載到方法區,因此如果方法長度將會對程序的向后兼容帶來極大的挑戰。
對于一個由Java編譯器錯誤而導致的錯誤的類文件將發生怎樣的情況?如果是在網絡傳輸或文件復制過程中,類文件被損壞又將發生什么?

為了應對這些場景,Java類加載器的加載過程被設計為一個非常嚴謹的處理過程。JVM規范詳細描述了這個過程。
注釋
我們如何驗證JVM成功執行了類文件的驗證過程?如何驗證不同的JVM實現是否符合JVM規范?為此,Oracle提供了專門的測試工具:TCK(Technology Compatibility Kit)。TCK通過執行大量的測試用例(包括大量通過不同方式生成的錯誤類文件)來驗證JVM規范。只有通過TCK測試的JVM才能被稱作是JVM。
類似TCK,還有一個JCP(Java Community Process; http://jcp.org),用于驗證新的Java技術規范。對于一個JCP,必須具有詳細的文檔,相關的實現以及提交給JSR(Java Specification Request)的TCK測試。如果用戶想像JSR一樣使用新的Java技術,那他必須先從RI提供者那里得到許可,或者自己直接實現它并對之進行TCK測試。

JVM 結構
Java程序的執行過程如下圖所示:

圖1: Java代碼執行過程
類加載器把Java字節碼載入到運行時數據區,執行引擎負責Java字節碼的執行。
類加載
Java提供了動態加載的特性,只有在運行時第一次遇到類時才會去加載和鏈接,而非在編譯時加載它。JVM的類加載器負責類的動態加載過程。Java類加載器的特點如下:
層次結構:Java的類加載器按是父子關系的層次結構組織的。Boostrap類加載器處于層次結構的頂層,是所有類加載器的父類。

委派模式:基于類加載器的層次組織結構,類加載器之間是可以進行委派的。當一個類需要被加載,會先去請求父加載器判斷該類是否已經被加載。如果父類加器已加載了該類,那它就可以直接使用而無需再次加載。如果尚未加載,才需要當前類加載器來加載此類。

可見性限制:子類加載器可以從父類加載器中獲取類,反之則不行。

不能卸載: 類加載器可以載入類卻不能卸載它。但是可以通過刪除類加載器的方式卸載類。

每個類加載器都有自己的空間,用于存儲其加載的類信息。當類加載器需要加載一個類時,它通過FQCN)(Fully Quanlified Class Name: 全限定類名)的方式先在自己的存儲空間中檢測此類是否已存在。在JVM中,即便具有相同FQCN的類,如果出現在了兩個不同的類加載器空間中,它們也會被認為是不同的。存在于不同的空間意味著類是由不同的加載器加載的。
下圖解釋了類加載器的委派模型:

圖2: 類加載器的委派模型
當JVM請示類加載器加載一個類時,加載器總是按照從類加載器緩存、父類加載器以及自己加載器的順序查找和加載類。也就是說加載器會先從緩存中判斷此類是否已存在,如果不存在就請示父類加載器判斷是否存在,如果直到Bootstrap類加載器都不存在該類,那么當前類加載器就會從文件系統中找到類文件進行加載。
Bootstrap加載器:Bootstrap加載器在運行JVM時創建,用于加載Java APIs,包括Object類。不像其他的類加載器由Java代碼實現,Bootstrap加載器是由native代碼實現的。

擴展加載器(Extension class loader):擴展加載器用于加載除基本Java APIs以外擴展類。也用于加載各種安全擴展功能。

系統加載器(System class loader):如果說Bootstrap和Extension加載器用于加載JVM運行時組件,那么系統加載器加載的則是應用程序相關的類。它會加載用戶指定的CLASSPATH里的類。

用戶自定義加載器:這個是由用戶的程序代碼創建的類加載器。

像Web應用服務器(WAS: Web Application Server)等框架通過使用用戶自定義加載器使Web應用和企業級應用可以隔離開在各自的類加載空間獨自運行。也就是說可以通過類加載器的委派模式來保證應用的獨立性。不同的WAS在自定義類加載器時會有略微不同,但都不外乎使用加載器的層次結構原理。
如果一個類加載器發現了一個未加載的類,則該類的加載和鏈接過程如下圖:

圖3: 類加載步驟
每一步的具體描述如下:
加載(Loading): 從文件中獲取類并載入到JVM內存空間。

驗證(Verifying): 驗證載入的類是否符合Java語言規范和JVM規范。在類加載流程的測試過程中,這一步是最為復雜且耗時最長的部分。大部分JVM TCK的測試用例都用于檢測對于給定的錯誤的類文件是否能得到相應的驗證錯誤信息。

準備(Preparing): 根據內存需求準備相應的數據結構,并分別描述出類中定義的字段、方法以及實現的接口信息。

解析(Resolving): 把類常量池中所有的符號引用轉為直接引用。

初始化(Initializing): 為類的變量初始化合適的值。執行靜態初始化域,并為靜態字段初始化相應的值。

JVM規范定義了規則,但也允許在運行時靈活處理。
運行時數據區

圖4: 運行時數據區結構
運行時數據區是JVM程序運行時在操作系統上分配的內存區域。運行時數據區又可細分為6個部分,即:為每個線程分別創建的PC寄存器JVM棧本地方法棧和被所有線程共用的數據堆方法區運行時常量池
PC 寄存器:每個線程都會有一個PC(Program Counter)寄存器,并跟隨線程的啟動而創建。PC寄存器中存有將執行的JVM指令的地址。

JVM 棧:每個線程都有一個JVM棧,并跟隨線程的啟動而創建。其中存儲的數據元素稱為棧幀(Stack Frame),JVM會把棧楨壓入棧或從其中彈出。如果有任何異常,printStackTrace()方法輸出的棧跟蹤信息的每一行就都是一個棧幀信息。

圖5: JVM棧結構

  • 棧幀:在JVM中一旦有方法執行,JVM就會為之創建一個棧幀,并把其添加到當前線程的JVM棧中。當方法運行結束時,棧幀也會相應的從JVM棧中移除。棧幀中存放著對本地變量數組、操作數棧以及屬于當前運行方法的運行時常量池的引用。本地變量數組和操作數棧的大小在編譯時就已確定,所以屬在運行時屬于方法的棧幀大小是固定的。- 本地變量數組:本地變量數組的索引從0開始計數,其位置存儲著對方法所屬類實例的引用。從索引位置1開始的保存的是傳遞給該方法的參數。其后存儲的就是真正的方法的本地變量了。- 操作數棧:是方法的實際運行空間。每個方法變換操作數棧和本地變量數組,并把調用其它方法的結果從棧中彈或壓入。在編譯時,編譯器就能計算出操作數棧所需的內存窨,因此操作數棧的大小在編譯時也是確定的。

本地方法棧:為非Java編寫的本地代程定義的棧空間。也就是說它基本上是用于通過JNI(Java Native Interface)方式調用和執行的C/C++代碼。根據具體情況,C棧或C++棧將會被創建。

方法區:方法區是被所有線程共用的內存空間,在JVM啟動時創建。它存儲了運行時常量池、字段和方法信息、靜態變量以及被JVM載入的所有類和接口的方法的字節碼。不同的JVM提供者在實現方法區時會通常有不同的形式。在Oracle的Hotspot JVM里方法區被稱為Permanent Area(永久區)或Permanent Generation(PermGen, 永久代)。JVM規范并對方法區的垃圾回收未做強制限定,因此對于JVM實現者來說,方法區的垃圾回收是可選操作。

運行時常量池:一個存儲了類文件格式中的常量池表的內存空間。這部分空間雖然存在于方法區內,但卻在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架構中使用的寄存器。因為JVM自己管理內存,所以Java字節碼中使用像15、23這樣的索引值而非直接的內存地址。上面的15和23指向的是當前類的常量池中的位置(即UserService類)。也就是JVM為每個類創建一個常量池,并在常量池中存儲真實對象的引用。
上面每行代碼的解釋如下:
aload_0: 把本地變量數組的0號元素添加到操作數棧。本地變量數組的0號元素始終是 this ,即當前類實例對象的引用。

getfield #15: 在當前類的常量池中,把15號元素添加到操作數棧。上面的15號元素是UserAdmin admin字段。因為admin是一個類實例對象,因此其引用被加入到操作數棧。

aload_1: 把本地變量數組的1號元素添加到操作數棧中。本地變量數組中從第1個位置開始的元素存儲著方法的參數。因此調用add()方法傳入的String userName參數的引用將會添加到操作數棧。

invokevirtual #23: 調用當前類常量池中的第23號元素所引用的方法,同時被aload_1和getField #15操作添加到操作數棧中的引用信息將被傳給方法調用。當方法調用完成后,其結果將被添加到操作數棧。

pop: 把通過invokevirtual方法調用得到的結果從操作數棧中彈出。在前面講述中使用之前類庫時沒有返回值,也就不需要把結果從操作數棧中彈出了。

return: 方法完成。

下圖將幫忙容易理解上面的文字解釋:

圖6: 從運行時數據區加載Java字節碼示例
作為示例,上面的方法中本地變量數組中的值未曾有任何改變,所以上圖中我們只看到操作數棧的變化。實際上,在大多數場景中本地變量數組也是被發生變化的。數據通過加載指令(aload, iload)和存儲指令(astore, istore)在本地變量數組和操作數棧之間發生變化和移動。
在本章節我們對運行時常量池和JVM棧作了清晰的介紹。在JVM運行時,每個類的實例被分配到數據堆上,類信息(包括User, UserAdmin, UserService, String)等被存儲在方法區。
執行引擎
JVM通過類加載器把字節碼載入運行時數據區是由執行引擎執行的。執行引擎以指令為單位讀入Java字節碼,就像CPU一個接一個的執行機器命令一樣。每個字節碼命令包含一字節的操作碼和可選的操作數。執行引擎讀取一個指令并執行相應的操作數,然后去讀取并執行下一條指令。
盡管如此,Java字節碼還是以一種可以理解的語言編寫的,而不像那些機器直接執行的無法讀懂的語言。所以JVM的執行引擎必須要把字節碼轉換為能被機器執行的語言指令。執行引擎有兩種常用的方法來完成這一工作:
解釋器(Interpreter):讀取、解釋并逐一執行每一條字節碼指令。因為解釋器逐一解釋和執行指令,因此它能夠快速的解釋每一個字節碼,但對解釋結果的執行速度較慢。所有的解釋性語言都有類似的缺點。叫做字節碼的語言人本質上就像一個解釋器一樣運行。

即時編譯器(JIT: Just-In-Time):即時編譯器的引入用來彌補解釋器的不足。執行引擎先以解釋器的方式運行,然后在合適的時機,即時編譯器把整修字節碼編譯成本地代碼。然后執行引擎就不再解釋方法的執行而是通過使用本地代碼直接執行。執行本地代碼較逐一解釋執行每條指令在速度上有較大的提升,并且通過對本地代碼的緩存,編譯后的代碼能具有更快的執行速度。

然而,即時編譯器在編譯代碼時比逐一解釋和執行每條指令更耗時,所以如果代碼只會被執行一次,解釋執行可能會具有更好的性能。所以JVM通過檢查方法的執行頻率,然后只對達到一定頻率的方法才會做即時編譯。

圖7: Java編譯器和即時編譯器
JVM規范中并未強行約束執行引擎如何運行。所以不同的JVM在實現各種的執行引擎時通過各種技術手段并引入多種即時編譯器來提升性能。
大部分的即時編譯器運行流程如下圖:
圖8: 即時編譯器
即時編譯器先把字節碼轉為一種中間形式的表達式(IR: Itermediate Representation),并對之進行優化,然后再把這種表達式轉為本地代碼。
Oracel Hotspot VM使用的即時編譯器稱為Hotspot編譯器。之所以稱為Hotspot是因為Hotspot Compiler會根據分析找到具有更高編譯優先級的熱點代碼,然后所這些熱點代碼轉為本地代碼。如果一個被編譯過的方法不再被頻繁調用,也即不再是熱點代碼,Hotspot VM會把這些本地代碼從緩存中刪除并對其再次使用解釋器模式執行。Hotspot VM有Server VM和Client VM之后,它們所使用的即時編譯器也有所不同。
圖9: Hotspot ClientVM 和Server VM
Client VM和Server VM使用相同的運行時環境,如上圖所示,它們的區別在于使用了不同的即時編譯器。Server VM通過使用多種更為復雜的性能優化技術從而具有更好的表現。
IBM VM在他的IBM JDK6中引入了AOT(Ahead-Of-Time) 編譯器技術。通過此種技術使得多個JVM之間能通過共享緩存分享已編譯的本地代碼。也就是說通過AOT編譯器編譯的代碼能被其他JVM直接使用而無須再次編譯。另外IBM JVM通過使用AOT編譯器把代碼預編譯為JXE(Java Executable)文件格式從而提供了一種快速執行代碼的方式。
大多數的Java性能提升都是通過優化執行引擎的性能實現的。像即時編譯等各種優化技術被不斷的引入,從而使得JVM性能得到了持續的優化和提升。老舊的JVM與最新的JVM之間最大的差異其實就來自于執行引擎的提升。
Hotspot編譯器從Java 1.3開始便引入到了Oracle Hotspot VM中,而即時編譯器從Android 2.2開始便被引入到了Android Dalvik VM中。
注釋
像其他使用了像字節碼一樣的中間層語言的編譯語言,VM在執行中間層字節碼時也像JVM執行字節碼一樣,引入了即時編譯等技術來提高VM的執行效率。像Microsoft的.Net語言,其運行時的VM叫做CLR(Common Language Runtime)。CLR執行一種類似字節碼的語言CIL(Common Intermediate Language)。CLR同時提供了AOT編譯器和即時編譯器。因為如果使用C#或VB.NET編寫程序,編譯器會把源碼編譯成CIL,CLR通過使用即時編譯器來執行CIL。CLR也有垃圾回收,并且和JVM一樣也是以基于棧的方式運行。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容