JVM 內部原理(二)— 基本概念之位元組碼
介紹
版本:Java SE 7
每位使用 Java 的程式設計師都知道 Java 位元組碼在 Java 執行時(JRE - Java Runtime Environment)裡執行。Java 虛擬機器(JVM - Java Virtual Machine)是 Java 執行時(JRE)的重要組成部分,它可以分析和執行 Java 位元組碼。Java 程式設計師不需要知道 JVM 是如何工作的。有很多應用程式和應用程式庫都已開發完成,但是它們並不需要開發者對 JVM 有深入的理解。但是,如果你理解 JVM ,那麼就可以對 Java 更有了解,這也使得那些看似簡單而又難以解決的問題得以解決。
在本篇文章中,我會解釋 JVM 是如何工作的,它的結構如何,位元組碼是如何執行的及其執行順序,與一些常見的錯誤及其解決方案,還有 Java 7 的新特性。
目錄
虛擬機器(Virtual Machine)
Java 位元組碼
症狀
原因
類檔案格式(Class File Format)
症狀
原因
JVM 結構
類裝載器(Class Loader)
執行時資料區
執行引擎
Java 虛擬機器官方規範文件,第 7 版
- 分支語句中的字串
總結
內容
虛擬機器(Virtual Machine)
Java 執行時環境包括 Java API 和 JVM 。JVM 負責通過裝載器(Class Loader)讀取 Java 應用程式並結合 Java API 一起執行。
虛擬機器(VM) 是機器的軟體實現(如,計算機),它可以像物理機一樣執行程式。Java 設計的初衷是讓執行時基於虛擬機器與物理機器隔離,即一次編寫隨處執行(WORA - Write Once Run Anywhere),儘管這個目標幾乎已經被人遺忘。因此,JVM 可以在各種硬體上執行,並執行 Java 位元組碼(Java Bytecode) 無須改變 Java 的執行程式碼。
JVM 有如下特性:
基於棧的虛擬機器(Stack-based virtual machine): 大多數流行的計算機架構如 Intel x86 架構和 ARM 架構都是基於暫存器執行的。但是,JVM 是基於棧執行的 。
識別符號引用(Symbolic reference): 所有型別(類和介面)除了基本型別(又稱原始型別)都是通過識別符號引用的,而不是通過顯式的基於記憶體地址的引用。
垃圾收集(Garbage collection): 一個類例項是由使用者程式碼顯式建立的並通過垃圾收集自動銷燬。
通過清楚的定義基本資料型別(primitive data type)保證平臺的獨立: 傳統的語言如 C/C++ 在不同平臺下的 int 型別的大小是不一樣的。JVM 清楚地定義了原始資料型別以維持相容性和保證跨平臺的能力。
網路位元組順序(Network byte order): Java 類檔案使用網路位元組順序。要在 Intel x86 架構採用的 little endian 與 RISC 系列架構採用的 big endian 之間維持平臺獨立,就必須保證固定的位元組序。因此,JVM 使用網路位元組序,它是一種網路傳輸的順序。網路位元組序是 big endian 的。
Sun 公司(Sun Microsystems)開發了 Java 。不過,任何廠商都可以開發並提供 JVM ,只要遵守 Java 虛擬機器官方規範文件即可。因此,JVM 有很多種類,包括 Oracle 公司的 Hotspot JVM 和 IBM 公司的 JVM 。Google 安卓作業系統使用的 Dalvik VM 也是一種 JVM ,儘管它並不遵守 Java 虛擬機器規範。與 Java 虛擬機器不同(基於棧的虛擬機器),Dalvik VM 採用基於暫存器的架構。
Java 位元組碼
要想實現 一次編寫到處執行(WORA),JVM 使用 Java 位元組碼,它是一種介於 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);
5 }
更新的庫原始碼如下:
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);
13 }
簡而言之,addUser() 方法沒有返回值變更成為返回 User 類例項的的方法。但是應用程式程式碼並沒有發生任何改變,因為它沒有使用 addUser() 方法的返回值
乍一看,com.nhn.user.UserAdmin.addUser() 方法看似仍然存在,但如果這樣,為什麼還會出現 NoSuchMethodError 呢?
原因
原因是應用程式程式碼已經被編譯到一個新的庫。換句話說,應用程式程式碼似乎會呼叫方法而不管返回值。但是,編譯的類檔案中方法是帶有返回值的。
可以看到以下的錯誤訊息:
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
因為應用程式程式碼被編譯到了之前的類庫,所以類檔案定義呼叫方法的返回值應該是 “V” 。然而,在已經改變的類庫中,返回 “V” 的方法並不存在,而是一個返回 “Lcom/nhn/user/User” 的方法。因此,會出現錯誤NoSuchMethodError 。
注意
錯誤出現是因為開發者並沒有重新編譯新的類。但在這個例子中,類庫提供者有很大的責任。這個公有方法沒有返回值,但被修改成了返回使用者類例項的方法。這顯然是因為方法簽名改變所導致的,這也意味著類的向後相容被破壞了。因此類庫提供方必須告知使用者方法已經發生了改變。
讓我們回到 Java 位元組碼。Java 位元組碼 是 JVM 的重要元素。JVM 是一個模擬器,它執行 Java 位元組碼。Java 編譯器並不像 C/C++ 那樣直接將高階語言轉換成為機器語言(直接的 CPU 指令);它將 Java 語言轉換成為 JVM 可以理解的 Java 位元組碼。因為 Java 位元組碼沒有任何依賴於平臺的程式碼,它可以在任意安裝好 JVM (準確的說是 JRE)的硬體上執行,即使當 CPU 或 OS 不同時也是如此(在 Windows PC 上開發和編譯出的類檔案也可以在 Linux 機器上執行,無須任何改變)。編譯好的檔案的大小與原始碼的大小几乎一樣,這讓通過網路來傳輸和執行編譯的程式碼變得簡單。
類檔案本身是一個二進位制檔案,人們無法理解。為了管理類檔案,JVM 提供商提供了 javap ,反編譯工具。javap 生成的結果稱為 Java 組合語言。在上面的例子中,通過 javap -c 命令反編譯應用程式程式碼中的 UserService.add() 方法得到的 Java 組合語言如下:
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
在 Java 組合語言中,addUser() 方法在第四行被呼叫,"5: invokevirtual #23;" 。這表示索引 23 對應的方法會被呼叫。** invokevirtual ** 是操作碼,它是 Java 位元組碼中呼叫方法的的最基本命令。參考 Java 位元組碼中的以下四種操作碼: invokeinterface 、 invokespecial 、 invokestatic 和 invokevirtual 。每個操作碼的意義如下:
- invokeinterface :呼叫介面方法
- 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 裡執行的程式碼被稱為“位元組”碼的原因。簡而言之,操作碼的位元組碼指令如 aload_0 、 getfield 和 invokevirtual 用 1 個位元組的位元組數來表示(aload_0 = 0x2a、getfield = 0xb4、invokevirtual = 0xb6)。因此,Java 位元組碼指令的操作碼的最大值是 256 。
操作碼如 aload_0 和 aload_1 不需要任何運算元。因此,aload_0 的下一個位元組是操作碼的下一個指令。但是,getfield 和 invokevirtual 需要 2 個位元組的運算元。因此,getfield 的下一指令的第一個位元組通過跳過 2 個位元組寫到第四個位元組。位元組碼通過十六進位制的編輯器顯示如下:
1 2a b4 00 0f 2b b6 00 17 57 b1
在 Java 位元組碼中,類例項用 “L” 表示;void 用 “V” 來表示。以這種方式,其他的型別也都有他們自己的表示式。下面列表總結了這些表示式:
表 1:Java 位元組碼型別表示式
Java 位元組碼 | 型別 | 描述 |
---|---|---|
B | byte | 有符號位元組 |
C | char | Unicode 字元 |
D | double | 雙精度浮點數值 |
F | float | 單精度浮點數值 |
I | int | 整型 |
J | long | 長整型 |
L |
reference | |
S | short | 有符號的短型 |
Z | boolean | 真或假 |
[ | reference | 一維陣列 |
表 2:Java 位元組碼錶達式示例
Java 程式碼 | Java 位元組碼錶達式 |
---|---|
double d[][][]; | [[[D |
Object mymethod(int I, double d, Thread t) | (IDLjava/lang/Thread;)Ljava/lang/Object; |
參考
參考來源:
JVM Specification SE 7 - Run-Time Data Areas
2011.01 Java Bytecode Fundamentals
2012.02 Understanding JVM Internals
2013.04 JVM Run-Time Data Areas
Chapter 5 of Inside the Java Virtual Machine
2012.10 Understanding JVM Internals, from Basic Structure to Java SE 7 Features