深入理解 Java 虛擬機
文章太長了吵冒,拆成兩部分痹栖,這是第一部分揪阿。
每一個使用 Java 的開發(fā)者都知道 Java 字節(jié)碼在 JRE(Java 運行時環(huán)境)中運行咆畏。JRE 中最重要的部件就是分析并執(zhí)行 Java 字節(jié)碼的 Java 虛擬機(JVM)旧找。Java 開發(fā)者不需要知道 JVM 怎么工作钮蛛。有很多優(yōu)秀的應(yīng)用和第三方庫都是在開發(fā)者沒有深入理解 JVM 的情況下開發(fā)出來的。然而缚去,如果你理解 JVM易结,你會更好的理解 Java搞动,并且能夠解決一些看起來很簡單但是卻無法解決的問題鹦肿。
所以辅柴,在本文中,我將介紹 JVM 怎么工作涣旨,它的結(jié)構(gòu)霹陡,它如何執(zhí)行 Java 字節(jié)碼,執(zhí)行順序攒霹,通過示例介紹一些常見問題和它們的解決辦法催束,以及 Java SE 7版本的新功能辅髓。
虛擬機
JRE由 Java API 和 JVM 組成洛口。JVM 的職責(zé)就是通過類加載器讀取 Java 應(yīng)用并通過 Java API 執(zhí)行第焰。
虛擬機(VM)機器(即電腦)的軟件實現(xiàn)挺举,它像真實的計算機一樣執(zhí)行程序烘跺。為了實現(xiàn) WORA(編寫一次,任何地方都可以運行--Write Once Run Anywhere梧喷,盡管這一目標(biāo)基本已經(jīng)被忘記)這一目標(biāo)铺敌,Java 最初的設(shè)計是基于運行在與物理機器隔離的虛擬機中偿凭。所以派歌,運行在各種硬件上的 JVM 可以執(zhí)行 Java 字節(jié)碼胶果,而不需要修改 Java 執(zhí)行代碼。
JVM 的特征包括:
- JVM 是基于棧的虛擬機:最流行的計算機架構(gòu)比如 Intel x86 架構(gòu)和 ARM 架構(gòu)是基于 寄存器 運行奄毡。然而 JVM 是基于棧運行吼过。
- 符號引用:除了原始數(shù)據(jù)類型以外的所有類型(類和接口)都是通過符號引用,而不是通過直接的內(nèi)存地址引用酱床。
- 垃圾回收:類的實例是通過用戶代碼手動創(chuàng)建扇谣,但是通過垃圾回收自動銷毀闲昭。
- 通過清晰的定義原始數(shù)據(jù)類型來保證平臺獨立性:傳統(tǒng)的像 C/C++ 這樣的語言在不同的平臺上有不同的類型大小序矩。JVM 通過清晰的定義原始數(shù)據(jù)類型來維持其兼容性簸淀,并且保證平臺獨立。
- 網(wǎng)絡(luò)字節(jié)序:Java 類文件使用網(wǎng)絡(luò)字節(jié)序舷手。為了在Intel x86 架構(gòu)的小端和 RISC 架構(gòu)使用的大端之間維持平臺獨立聚霜,必須要使用一種混合的字節(jié)序珠叔。所以祷安,JVM 使用網(wǎng)絡(luò)字節(jié)序,網(wǎng)絡(luò)字節(jié)序用于網(wǎng)絡(luò)傳輸凉唐。網(wǎng)絡(luò)字節(jié)序使用的是大端台囱。
Sun Microsystems 開發(fā)了 Java读整。然而,任何廠商只要遵循 JVM 規(guī)范都可以開發(fā)并提供 JVM膘侮。由于這一原因琼了,現(xiàn)在存在各種 JVM夫晌,包括 Oracle Hotspot JVM 和 IBM JVM晓淀。Google 的 Android 操作系統(tǒng)中運行的 Dalvik 虛擬機也是一種 JVM要糊,盡管它沒有遵循 JVM 規(guī)范妆丘。和其他基于棧的虛擬機不一樣的是勺拣,Dalvik 虛擬機采用的是基于寄存器架構(gòu)药有。Java 字節(jié)碼也會轉(zhuǎn)換成基于寄存器的指令集供 Dalvik 虛擬機使用。
Java 字節(jié)碼
為了實現(xiàn) WORA苇经,JVM 使用 java 字節(jié)碼扇单,這是一種介于 Java(用戶語言)和機器語言的中間語言蜘澜。Java 字節(jié)碼是部署 Java 代碼的最小單元鄙信。
在了解 Java 字節(jié)碼之前装诡,我們一起來看一個問題,這個案例是發(fā)生在開發(fā)過程中的真實例子的總結(jié)蚓土。
問題表現(xiàn)
一個曾正常運行的應(yīng)用不再正常運行蜀漆。此外确丢,在庫文件更新以后會返回如下錯誤:
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)
應(yīng)用的代碼如下鲜侥,并且不曾做任何修改:
// 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() 方法別修改成了返回 User 實例的方法舀寓。然而互墓,應(yīng)用程序沒有做修改篡撵,因為它沒有使用 addUser() 方法的返回值育谬。
乍一看帮哈,com.nhn.user.UserAdmin.addUser() 方法仍存在但汞,但是如果是這樣僵缺, 為什么會出現(xiàn) NoSuchMethodError踩叭?
原因
原因是應(yīng)用代碼還沒有使用新的庫進(jìn)行編譯。換句話說之景,應(yīng)用代碼似乎調(diào)用了一個方法而不管它的返回類型锻狗。然而已經(jīng)編譯好的類認(rèn)為這個方法有一個返回值焕参。
通過以下錯誤信息你會發(fā)現(xiàn)這一點轻纪。
java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V
發(fā)生 * NoSuchMethodError* 是因為找不到 “com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V” 這個方法。來看一下 “Ljava/lang/String;” 和最后的 “V”叠纷。在 Java 字節(jié)碼表達(dá)式中刻帚,“L<classname>” 表示一個類實例。這意味著 addUser() 方法獲得一個 java/lang/String 對象作為一個參數(shù)涩嚣。在本例的庫中崇众,參數(shù)并沒有被修改,所以這點沒有問題航厚。錯誤信息最后的 “V” 代表這個方法的返回值顷歌。在 Java 字節(jié)碼表達(dá)式中互妓, “V” 代表沒有返回值冯勉。簡而言之,這條錯誤信息表明:以一個 java.lang.String 對象為參數(shù)份汗,不返回任何值的 com.nhn.user.UserAdmin.addUser 方法沒有找到。
因為應(yīng)用程序是和上一個庫一起編譯的旁钧,在那個版本中類文件定義了返回 “V” 的方法可以調(diào)用嚎幸。然而,在修改后的庫中车遂,返回 “V” 的方法不復(fù)存在彬呻,但是返回 “Lcom/nhn/user/User” 的方法被添加了剪况。所以,發(fā)生了 NoSuchMethodError孙咪。
注釋:
錯誤的發(fā)生時因為開發(fā)者沒有使用新的庫重新編譯男公。然而澄阳,在本例中,庫的提供者應(yīng)該負(fù)主要責(zé)任揩抡。沒有返回值的方法屬性是 public,但是隨后被修改為返回一個類實例替梨。這明顯修改了方法的簽名。這意味著庫的向下兼容性被破壞糠睡。所以,庫的提供者應(yīng)該告知用戶方法已經(jīng)被修改。
讓我們回到字節(jié)碼的討論。字節(jié)碼是 JVM 的基礎(chǔ)組件深寥。JVM 是一個仿真 Java 字節(jié)碼的仿真器持灰。Java 編譯器不會像 C/C++ 的編譯器那樣直接將高級語言直接轉(zhuǎn)換成機器語言( CPU 指令);它將開發(fā)者能夠理解的 Java 語言轉(zhuǎn)換成 JVM 能夠理解的字節(jié)碼椭微。由于字節(jié)碼與平臺無關(guān),只要安裝了 JVM (準(zhǔn)確的說排拷,是 JRE)的硬件就可以執(zhí)行藤违,即使 CPU 或者 操作系統(tǒng)都不相同(在 Windows PC 上開發(fā)編譯的類文件不用做任何修改就可以在一臺 Linux 機器上運行)。編譯后代碼的大小和源碼文件的大小是一致的傍睹,這使得通過網(wǎng)絡(luò)傳輸執(zhí)行編譯后的代碼變得很簡單。
類文件本身是人類無法理解的二進(jìn)制文件。為了管理類文件,JVM 廠商提供了反編譯器 javap搜骡。使用 javap 產(chǎn)生的結(jié)果稱為 Java 匯編团驱。在上面的案例中,下面的 Java 匯編是通過使用 javap -c 選項反編譯應(yīng)用代碼的 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() 方法在第4行被調(diào)用,“5: invokevirtual #23;”。這段代碼的含義是編號為 #23 的方法將被調(diào)用。編號為 #23 的方法是通過 javap 程序注明的抛蚁。invokevirtual 是 Java 字節(jié)碼中最基本命令(調(diào)用一個函數(shù))的操作碼肚逸。在 Java 字節(jié)碼中調(diào)用一個函數(shù)一共有4個操作碼:invokeinterface, invokespecial, invokestatic, and invokevirtual务冕。每一個操作碼的含義如下坯屿。
- invokeinterface:調(diào)用一個接口方法
- invokespecial:調(diào)用一個初始化方法吠昭、私有方法或者是父類的方法
- invokestatic:調(diào)用靜態(tài)方法
- invokevirtual:調(diào)用實例方法
Java 字節(jié)碼的指令集包含操作碼和操作數(shù)。例如 invokevirtual 操作碼需要兩個字節(jié)的操作數(shù)。
通過使用更新后的庫編譯應(yīng)用源碼,然后再反編譯,會得到以下結(jié)果。
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
你會發(fā)現(xiàn)編號為 #23的方法已經(jīng)被修改為一個返回 “Lcom/nhn/user/User;” 的方法雁竞。
在上邊的反編譯代碼中,代碼前面的數(shù)字是什么含義?
這代表字節(jié)數(shù)。也許這就是為什么 JVM 執(zhí)行的代碼成為 Java “字節(jié)”碼。簡而言之,字節(jié)碼命令操作碼(比如 aload_0,getfield 和 invokevirtual)用一個字節(jié)表示(aload_0 = 0x2a,getfield=0xb4, invokevirtual = 0xb6)赶熟。所以邑退,Java 字節(jié)碼命令操作碼的最大編號是256.
像 aload_0 和 aload_1 這樣的操作碼不需要任何操作數(shù)乓土。所以 aload_0 的下一個字節(jié)是下一條命令的操作碼食磕。然而伊诵, getfield 和 invokevirtual 需要兩個字節(jié)的操作數(shù)笛坦,所以,處于第一個字節(jié)的 getfield 的下一條命令位于第4個字節(jié),跳過了兩個字節(jié)窄刘。這一段字節(jié)碼使用十六進(jìn)制編輯器打開就是這樣的。
2a b4 00 0f 2b b6 00 17 57 b1
在 Java 字節(jié)碼中,類實例通過 “L;” 表示, void 以 “V” 表示。同樣,其他的類型也有各自的表達(dá)式。詳細(xì)的信息參考下表。
表1:Java 字節(jié)碼中的類型表達(dá)式
Java 字節(jié)碼 | 類型 | 描述 |
---|---|---|
B | byte | 字節(jié) |
C | char | Unicode 字符 |
D | double | 雙精度浮點數(shù) |
F | float | 單精度浮點數(shù) |
I | int | 整數(shù) |
J | long | 長整數(shù) |
L<classname> | 引用 | <classname>類的一個實例 |
S | short | 短整型 |
Z | boolean | true or false |
[ | reference | one array dimension |
下表展示了一組 Java 字節(jié)碼表達(dá)式的樣例。
表2:Java 字節(jié)碼表達(dá)式樣例
Java 代碼 | Java 字節(jié)碼表達(dá)式 |
---|---|
double d[][][]; | [[[D |
Object mymethod(int I, double d, Thread t) | (IDLjava/lang/Thread;)Ljava/lang/Object; |
更多詳細(xì)的信息切端,可以參考 "Java 虛擬機規(guī)范,第二版"的 “4.3節(jié)”。關(guān)于各種 Java 字節(jié)碼的指令集,可以參考“Java 虛擬機規(guī)范,第二版”的第6章“Java 虛擬機指令集”。
類文件格式
在了解 Java 類文件格式之前,我們一起來看一個在 java web 應(yīng)用中經(jīng)常發(fā)生的一個問題。
表現(xiàn)
當(dāng)在 Tomcat 上編寫、執(zhí)行 JSP時, JSP 沒有執(zhí)行,并發(fā)生如下錯誤。
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 應(yīng)用服務(wù)器上可能會略有不同婚脱,但是有一件事情是相同的,那就是因為65535字節(jié)限制。65535字節(jié)限制是 JVM 的限制之一锋叨,并且規(guī)定一個方法的大俠不能超過65535個字節(jié)。
我將詳細(xì)的說明65535字節(jié)限制的含義以及為什么會設(shè)定這樣一個限制。
Java 字節(jié)碼使用的分支/跳轉(zhuǎn)指令是 “goto” 和 “jsr”。
goto [branchbyte1] [branchbyte2]
jsr [branchbyte1] [branchbyte2]
兩者都接收兩個字節(jié)的有符號的跳轉(zhuǎn)偏移量作為它們的操作數(shù)仔蝌,所以它們最大的偏移量是65535。然而,為了支持更多的跳轉(zhuǎn), Java 字節(jié)碼又提供了 “goto_
w” 和 “jsr_w”丹皱,分別接收4個字節(jié)的偏移量呢簸。
goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
有了這兩條指令,分支的偏移量就可以遠(yuǎn)遠(yuǎn)超出65535。所以,Java 方法的 65535 字節(jié)限制是可以解決的。然而,由于 Java 類文件格式的各種其他限制,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];}
以上內(nèi)容在“Java 虛擬機規(guī)范,第二版”的 4.1節(jié)“類文件結(jié)構(gòu)”陈轿。
我們前面反編譯的 UserServices.class 的前16個字節(jié)內(nèi)容如下:
ca fe ba be 00 00 00 32 00 28 07 00 02 01 00 1b
以此為例蛔琅,我們來看看類文件的格式。
- magic:類文件的頭4個字節(jié)是魔法數(shù)字。這是一個用于區(qū)分類文件的預(yù)定義值。如你所見,這個值總是 0XCAFEBABE。簡而言之,如果一個文件的前四個字節(jié)是 0XCAFEBABE尾抑,它就會被認(rèn)為是 Java 類文件。這個有趣的魔法數(shù)和 “Java” 這個名字有關(guān)。
- minor_version,major_version:接下來的4個字節(jié)用于標(biāo)明類的版本么伯。UserServices.class 文件的這4個字節(jié)是 0x00000032,即類的版本是50。由 JDK 1.6編譯的類文件的版本就是50,由JDK 1.5編譯出來的類文件的版本是 49.0。Java 虛擬機需要向下兼容版本比自己低(使用低于自己的JDK 編譯)的類文件。反過來文判,如果一個高版本的類文件在一個低版本的 JVM 上運行時,會發(fā)生 java.lang.UnsupportedClassVersionError 錯誤。
- constant_pool_count, constant_pool[]:緊挨著版本號數(shù)據(jù)描述的信息是,類的常量池信息。這一部分信息包含運行時常量池信息壶硅,這個我們稍后再說明逮栅。當(dāng)加載這個類文件時,JVM 將這些常量池信息存放在方法區(qū)的運行時常量池區(qū)域。由于 UserServices.class 文件的 constant_pool_count 是 0x0028吗货,你會發(fā)現(xiàn) constant_pool 有(40-1)個索引,即39個索引。
- access_flags:這個標(biāo)志展示了類的修飾信息肥照,即:public亿蒸,final音半,abstract 或者是否是一個 interface彻桃。
- this_class,super_class:常量池中(constant_pool)的這兩個索引位置分別代表 this 和 父類。
- interfaces_count, interfaces[]:常量池 interfaces_count 索引位置存儲了當(dāng)前類實現(xiàn)的接口數(shù)目,interfaces[]中存儲每一個接口。
- fields_count, fields[]:分別表示當(dāng)前類 field 的數(shù)目和field 信息往湿。field 信息包括名稱棕孙,類型肢预,修飾符,以及在常量池中的索引。
- methods_count, methods[]:當(dāng)前類的方法數(shù)以及所有的方法信息导盅。方法信息包含:方法名稱岛琼,參數(shù)類型和數(shù)目,返回類型,修飾符甚牲,常量池中的索引院尔,執(zhí)行代碼以及異常信息。
- attributes_count, attributes[]:attribute_info 的結(jié)構(gòu)中包含各種屬性。供 field_info 和 method_info 使用怎茫。
javap 程序簡潔的將類文件格式以用戶能夠閱讀的形式展示圃验。當(dāng)使用 “javap -verbose” 選項來分析 UserServices.class 時颅湘,會打印出以下內(nèi)容:
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 …
}
由于篇幅限制,我從完整的打印中提取一部分姑隅。完整的打印中會顯示常量池中的各種信息和每一個方法的內(nèi)容冕房。
65535字節(jié)限制和method_info struct的內(nèi)容相關(guān)培漏。如你所見蹋宦,method_info 結(jié)構(gòu)體中包含 Code,行號表以及本地變量表屬性恭取。所有行號表课兄、本地變量表和代碼表里的異常表中的值的長度都是固定的個字節(jié)。所以方法的大小不能超過方法表腹缩、本地變量表以及異常表的長度楚殿,也就是65535字節(jié)限制规伐。
很多人都對這一方法限制抱有怨言,JVM 規(guī)格中也說明后續(xù)會擴展竖配,然而到目前為止沒有看到有什么改進(jìn)的跡象。考慮到 JVM 明確說明一次幾乎要將類的全部內(nèi)容加載到方法區(qū)的特點空郊,如果要擴展方法大小還要保持向下兼容將是一個極大的挑戰(zhàn)。
如果因為編譯器的問題生成了一個錯誤的類文件會發(fā)生什么事情巩步?或者說因為網(wǎng)絡(luò)傳輸或者文件復(fù)制的過程中類文件被損壞會怎樣?
為了應(yīng)對這種情況,Java 類加載器會進(jìn)行非常嚴(yán)謹(jǐn)?shù)尿炞C次舌。JVM 規(guī)范中明確的說明了這一過程。
注釋
我們怎么確認(rèn) JVM 成功的執(zhí)行了類文件的驗證過程务热?我們怎么確認(rèn)不同的 JVM 廠商提供的各種 JVM 符合 JVM 規(guī)范绩卤?對于驗證条辟,Oracle 提供了一個測試工具,TCK(Technology Compatibility Kit)、TCK 會通過數(shù)以萬計的測試來驗證 JVM 規(guī)范尔破,包括以各種錯誤的方式出錯的錯誤類文件。只有通過了 TCK 測試,JVM 才能被稱為 JVM矮燎。
像TCK一樣铝条,還存在一個 JCP(Java Community Process; http://jcp.org)馒索,JCP 提出新的技術(shù)規(guī)范(和已有的 Java 規(guī)范一樣)。對于JCP器一,為了完成 JSR(Java Specification Request--Java 規(guī)范申請)必須提供完整的規(guī)范文檔丑掺,參考實現(xiàn)以及為 JSR 準(zhǔn)備的 TCK面徽。用戶如果想使用 JSR 提出的新技術(shù),需要先從 RI 提供者那里獲得許可送爸,或者直接實現(xiàn)并使用 TCK 測試實現(xiàn)爽航。