深度解析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?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字節碼之前,我們先通過實例來簡單了解它。這個案例是一個在開發環境出現的真實案例的總結。
?現象
一個一直運行正常的應用突然無法運行了。在類庫被更新之后,返回下面的錯誤。
[java]?view?plaincopy
?
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)??
應用的代碼如下,而且它沒有被改動過。
[java]?view?plaincopy
?
4?//?UserService.java??
5?…??
6?public?void?add(String?userName)?{??
7?????admin.addUser(userName);??
8?}??
更新后的類庫的源代碼和原始的代碼如下。
[java]?view?plaincopy
?
9?//?UserAdmin.java?-?Updated?library?source?code??
10?…??
11?public?User?addUser(String?userName)?{??
12?????User?user?=?new?User(userName);??
13?????User?prevUser?=?userMap.put(userName,?user);??
14?????return?prevUser;??
15?}??
16?//?UserAdmin.java?-?Original?library?source?code??
17?…??
18?public?void?addUser(String?userName)?{??
19?????User?user?=?new?User(userName);??
20?????userMap.put(userName,?user);??
21?}??
簡而言之,之前沒有返回值的addUser()被改修改成返回一個User類的實例的方法。不過,應用的代碼沒有做任何修改,因為它沒有使用addUser()的返回值。咋一看,com.nhn.user.UserAdmin.addUser()方法似乎仍然存在,如果存在的話,那么怎么還會出現NoSuchMethodError的錯誤呢?
?原因
上面問題的原因是在于應用的代碼沒有用新的類庫來進行編譯。換句話來說,應用代碼似乎是調了正確的方法,只是沒有使用它的返回值而已。不管怎樣,編譯后的class文件表明了這個方法是有返回值的。你可以從下面的錯誤信息里看到答案。
[java]?view?plaincopy
?
22?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()方法進行反匯編得到的。
[java]?view?plaincopy
?
23?public?void?add(java.lang.String);??
24???Code:??
25????0:???aload_0??
26????1:???getfield????????#15;?//Field?admin:Lcom/nhn/user/UserAdmin;??
27????4:???aload_1??
28????5:???invokevirtual???#23;?//Method?com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)V??
29????8:???return??
在這段Java匯編代碼中,addUser()方法是在第四行的“5:invokevitual#23″進行調用的。這表示對應索引為23的方法會被調用。索引為23的方法的名稱已經被javap給注解在旁邊了。invokevirtual是Java字節碼里調用方法的最基本的操作碼。在Java字節碼里,有四種操作碼可以用來調用一個方法,分別是:invokeinterface,invokespecial,invokestatic以及invokevirtual。操作碼的作用分別如下:
·?invokeinterface:?調用一個接口方法
·?invokespecial:?調用一個初始化方法,私有方法或者父類的方法
·?invokestatic:調用靜態方法
·?invokevirtual:調用實例方法
Java字節碼的指令集由操作碼和操作數組成。類似invokevirtual這樣的操作數需要2個字節的操作數。
用更新過的類庫來編譯上面的應用代碼,然后反編譯它,將會得到下面的結果。
[java]?view?plaincopy
?
30?public?void?add(java.lang.String);??
31???Code:??
32????0:???aload_0??
33????1:???getfield????????#15;?//Field?admin:Lcom/nhn/user/UserAdmin;??
34????4:???aload_1??
35????5:???invokevirtual???#23;?//Method?com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;??
36????8:???pop??
37????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的下一條指令是跳過兩個字節,寫在第四個字節的位置上的。十六進制編譯器里查看字節碼的結果如下所示。
[plain]?view?plaincopy
?
38?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代碼沒有正常運行,而是出現了下面的錯誤。
[java]?view?plaincopy
?
39?Servlet.service()?for?servlet?jsp?threw?exception?org.apache.jasper.JasperException:?Unable?to?compile?class?for?JSP?Generated?servlet?error:??
40?The?code?of?method?_jspService(HttpServletRequest,?HttpServletResponse)?is?exceeding?the?65535?bytes?limit"??
原因
在不同的Web服務器上,上面的錯誤信息可能會有點不同,不過有有一點肯定是相同的,它出現的原因是65535字節的限制。這個65535字節的限制是JVM規范里的限制,它規定了一個方法的大小不能超過65535字節。
下面我會更加詳細地講解這個65535字節限制的意義以及它出現的原因。
Java字節碼里的分支和跳轉指令分別是”goto"和"jsr"。
[java]?view?plaincopy
?
41?goto?[branchbyte1]?[branchbyte2]??
42?jsr?[branchbyte1]?[branchbyte2]??
這兩個指令都接收一個2字節的有符號的分支跳轉偏移量做為操作數,因此偏移量最大只能達到65535。不過,為了支持更多的跳轉,Java字節碼提供了"goto_w"和"jsr_w"這兩個可以接收4字節分支偏移的指令。
[java]?view?plaincopy
?
43?goto_w?[branchbyte1]?[branchbyte2]?[branchbyte3]?[branchbyte4]??
44?jsr_w?[branchbyte1]?[branchbyte2]?[branchbyte3]?[branchbyte4]??
有了這兩個指令,索引超過65535的分支也是可用的。因此,Java方法的65535字節的限制就可以解除了。不過,由于Java?class文件的更多的其他的限制,使得Java方法還是不能超過65535字節。
為了展示其他的限制,我會簡單講解一下class?文件的格式。
Java?class文件的大致結構如下:
[java]?view?plaincopy
?
45?ClassFile?{??
46?????u4?magic;??
47?????u2?minor_version;??
48?????u2?major_version;??
49?????u2?constant_pool_count;??
50?????cp_info?constant_pool[constant_pool_count-1];??
51?????u2?access_flags;??
52?????u2?this_class;??
53?????u2?super_class;??
54?????u2?interfaces_count;??
55?????u2?interfaces[interfaces_count];??
56?????u2?fields_count;??
57?????field_info?fields[fields_count];??
58?????u2?methods_count;??
59?????method_info?methods[methods_count];??
60?????u2?attributes_count;??
61?????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時,會輸出如下的內容:
[java]?view?plaincopy
?
62?Compiled?from?"UserService.java"??
63????
64?public?class?com.nhn.service.UserService?extends?java.lang.Object??
65???SourceFile:?"UserService.java"??
66???minor?version:?0??
67???major?version:?50??
68???Constant?pool:const?#1?=?class????????#2;?????//??com/nhn/service/UserService??
69?const?#2?=?Asciz????????com/nhn/service/UserService;??
70?const?#3?=?class????????#4;?????//??java/lang/Object??
71?const?#4?=?Asciz????????java/lang/Object;??
72?const?#5?=?Asciz????????admin;??
73?const?#6?=?Asciz????????Lcom/nhn/user/UserAdmin;;//?…?omitted?-?constant?pool?continued?…??
74????
75?{??
76?//?…?omitted?-?method?information?…??
77????
78?public?void?add(java.lang.String);??
79???Code:??
80????Stack=2,?Locals=2,?Args_size=2??
81????0:???aload_0??
82????1:???getfield????????#15;?//Field?admin:Lcom/nhn/user/UserAdmin;??
83????4:???aload_1??
84????5:???invokevirtual???#23;?//Method?com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;??
85????8:???pop??
86????9:???return??LineNumberTable:??
87????line?14:?0??
88????line?15:?9??LocalVariableTable:??
89????Start??Length??Slot??Name???Signature??
90????0??????10??????0????this???????Lcom/nhn/service/UserService;??
91????0??????10??????1????userName???????Ljava/lang/String;?//?…?Omitted?-?Other?method?information?…??
92?}??
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堆?;蛘?/span>C++堆棧會被創建。
·??方法區(Method?area):方法區是所有線程共享的,它是在JVM啟動的時候創建的。它保存所有被JVM加載的類和接口的運行時常量池,成員變量以及方法的信息,靜態變量以及方法的字節碼。JVM的提供者可以通過不同的方式來實現方法區。在Oracle?的HotSpot?JVM里,方法區被稱為永久區或者永久代(PermGen)。是否對方法區進行垃圾回收對JVM的實現是可選的。
·???運行時常量池(Runtime?constant?pool):這個區域和class文件里的constant_pool是相對應的。這個區域是包含在方法區里的,不過,對于JVM的操作而言,它是一個核心的角色。因此在JVM規范里特別提到了它的重要性。除了包含每個類和接口的常量,它也包含了所有方法和變量的引用。簡而言之,當一個方法或者變量被引用時,JVM通過運行時常量區來查找方法或者變量在內存里的實際地址。
·?堆(Heap):用來保存實例或者對象的空間,而且它是垃圾回收的主要目標。當討論類似于JVM性能之類的問題時,它經常會被提及。JVM提供者可以決定怎么來配置堆空間,以及不對它進行垃圾回收。
現在我們再回過頭來看看之前反匯編的字節碼。
[java]?view?plaincopy
?
93?public?void?add(java.lang.String);??
94???Code:??
95????0:???aload_0??
96????1:???getfield????????#15;?//Field?admin:Lcom/nhn/user/UserAdmin;??
97????4:???aload_1??
98????5:???invokevirtual???#23;?//Method?com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;??
99????8:???pop??
100????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()語句的比較對象)是怎么實現的?
例如,下面的代碼:
[java]?view?plaincopy
?
101?//?SwitchTest??
102?public?class?SwitchTest?{??
103?????public?int?doSwitch(String?str)?{??
104?????????switch?(str)?{??
105?????????case?"abc":????????return?1;??
106?????????case?"123":????????return?2;??
107?????????default:?????????return?0;??
108?????????}??
109?????}??
110?}??
因為這是Java?SE?7的一個新特性,所以它不能在Java?SE?6或者更低版本的編譯器上來編譯。用Java?SE?7的javac來編譯。下面是通過javap?-c來反編譯后的結果。
[java]?view?plaincopy
?
111?C:Test>javap?-c?SwitchTest.classCompiled?from?"SwitchTest.java"??
112?public?class?SwitchTest?{??
113???public?SwitchTest();??
114?????Code:??
115????????0:?aload_0??
116????????1:?invokespecial?#1??????????????????//?Method?java/lang/Object."<init>":()V??
117????????4:?return??public?int?doSwitch(java.lang.String);??
118?????Code:??
119????????0:?aload_1??
120????????1:?astore_2??
121????????2:?iconst_m1??
122????????3:?istore_3??
123????????4:?aload_2??
124????????5:?invokevirtual?#2??????????????????//?Method?java/lang/String.hashCode:()I??
125????????8:?lookupswitch??{?//?2??
126??????????????????48690:?50??
127??????????????????96354:?36??
128????????????????default:?61??
129???????????}??
130???????36:?aload_2??
131???????37:?ldc???????????#3??????????????????//?String?abc??
132???????39:?invokevirtual?#4??????????????????//?Method?java/lang/String.equals:(Ljava/lang/Object;)Z??
133???????42:?ifeq??????????61??
134???????45:?iconst_0??
135???????46:?istore_3??
136???????47:?goto??????????61??
137???????50:?aload_2??
138???????51:?ldc???????????#5??????????????????//?String?123??
139???????53:?invokevirtual?#4??????????????????//?Method?java/lang/String.equals:(Ljava/lang/Object;)Z??
140???????56:?ifeq??????????61??
141???????59:?iconst_1??
142???????60:?istore_3??
143???????61:?iload_3??
144???????62:?lookupswitch??{?//?2??
145??????????????????????0:?88??
146??????????????????????1:?90??
147????????????????default:?92??
148???????????}??
149???????88:?iconst_1??
150???????89:?ireturn??
151???????90:?iconst_2??
152???????91:?ireturn??
153???????92:?iconst_0??
154???????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”進行比較。如果比較的結果是相等的話,'1’會被放入到局部變量數組的索引為#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性能的書籍。
By?Se?Hoon?Park,?Messaging?Platform?Development?Team,?NHN?Corporation
總結
- 上一篇: Hadoop常见错误解析
- 下一篇: volatile与synchronize