與平臺無關性是建立在操作系統上,虛擬機器廠商提供了許多可以執行在各種不同平臺的虛擬機器,它們都可以載入和執行位元組碼,從而實現程式的「一次編寫,到處執行」。
各種不同平臺的虛擬機器與所有平臺都統一使用的程式儲存格式——位元組碼(ByteCode)是構成平臺無關性的基石,也是語言無關性的基礎。Java 虛擬機器不和包括 Java 在內的任何語言系結,它只與「Class 檔案」這種特定的二進制檔案格式所關聯,Class 檔案中包含了 Java 虛擬機器指令集和符號表以及若幹其他輔助資訊。
Java 技術能夠一直保持非常好的向後相容性,這點 Class 檔案結構的穩定性功不可沒。Java 已經發展到 14 版本,但是 class 檔案結構的內容,絕大部分在JDK1.2 時代就已經定義好了。雖然 JDK1.2 的內容比較古老,但是 java 發展經歷了十餘個大版本,但是每次基本上知識在原有結構基礎上新增內容、擴充功能,並未對定義的內容做修改。
任何一個 Class 檔案都對應着唯一一個類或介面的定義資訊,但反過來說,Class 檔案實際上它並不一定以磁碟檔案的形式存在(比如可以動態生成、或者直接送入類載入器中)。
Class 檔案是一組以 8 位位元組爲基礎單位的二進制流。
整個 class 檔案的格式就是一個二進制的位元組流。
各個數據專案嚴格按照順序緊湊地排列在 Class 檔案之中,中間沒有新增任何分隔符,這使得整個 Class 檔案中儲存的內容幾乎全部是程式執行的必要數據,沒有空隙存在。
Class 檔案格式採用一種類似於 C 語言結構體的僞結構來儲存數據,這種僞結構中只有兩種數據型別:無符號數和表。
無符號數屬於基本的數據型別,以 u1、u2、u4、u8 來分別代表 1 個位元組(一個位元組是由兩位 16 進位制陣列成)、2 個位元組、4 個位元組和 8 個位元組的無符號
數,無符號數可以用來描述數位、索引參照、數量值或者按照 UTF-8 編碼構成字串值。
表是由多個無符號數或者其他表作爲數據項構成的複合數據型別,所有表都習慣性地以「_info」結尾。表用於描述有層次關係的複合結構的數據,整個Class 檔案本質上就是一張表。
Class 的結構不像 XML 等描述語言,由於它沒有任何分隔符號,所以在其中的數據項,無論是順序還是數量,都是被嚴格限定的,哪個位元組代表什麼含義,長度是多少,先後順序如何,都不允許改變。 按順序包括:
每個 Class 檔案的頭 4 個位元組稱爲魔數(Magic Number),它的唯一作用是確定這個檔案是否爲一個能被虛擬機器接受的 Class 檔案。使用魔數而不是擴充套件名來進行識別主要是基於安全方面的考慮,因爲副檔名可以隨意地改動。檔案格式的制定者可以自由地選擇魔數值,只要這個魔數值還沒有被廣泛採用過同時又不會引起混淆即可。
緊接着魔數的 4 個位元組儲存的是 Class 檔案的版本號:第 5 和第 6 個位元組是次版本號(MinorVersion),第 7 和第 8 個位元組是主版本號(Major Version)。
Java 的版本號是從 45 開始的,JDK 1.1 之後的每個 JDK 大版本發佈主版本號向上加 1 高版本的 JDK 能向下相容以前版本的 Class 檔案,但不能執行以後版本的 Class 檔案,即使檔案格式並未發生任何變化,虛擬機器也必須拒絕執行超過其版本號的 Class 檔案。
代表 JDK1.8(16 進位制的 34,換成 10 進位制就是 52)
常數池中常數的數量是不固定的,所以在常數池的入口需要放置一項 u2 型別的數據,代表常數池容量計數值(constant_pool_count)。
與 Java 中語言習慣不一樣的是,這個容量計數是從 1 而不是 0 開始的
常數池中主要存放兩大類常數:字面量(Literal)和符號參照(Symbolic References)。
可以使用更加直觀的工具 jclasslib,來檢視位元組碼中的具體內容
用於識別一些類或者介面層次的存取資訊,包括:這個 Class 是類還是介面;是否定義爲 public 型別;是否定義爲 abstract 型別;如果是類的話,是否被宣告爲 final 等
這三項數據來確定這個類的繼承關係。類索參照於確定這個類的全限定名,父類別索參照於確定這個類的父類別的全限定名。由於 Java 語言不允許多重繼承,所以父類別索引只有一個,除了 java.lang.Object 之外,所有的 Java 類都有父類別,因此除了 java.lang.Object 外,所有 Java 類的父類別索引都不爲 0。介面索引集合就用來描述這個類實現了哪些介面,這些被實現的介面將按 implements 語句(如果這個類本身是一個介面,則應當是 extends 語句)後的介面順序從左到右排列在介面索引集閤中
描述介面或者類中宣告的變數。欄位(field)包括類級變數以及範例級變數。
而欄位叫什麼名字、欄位被定義爲什麼數據型別,這些都是無法固定的,只能參照常數池中的常數來描述。
欄位表集閤中不會列出從超類或者父介面中繼承而來的欄位,但有可能列出原本 Java 程式碼之中不存在的欄位,譬如在內部類中爲了保持對外部類的存取性,會自動新增指向外部類範例的欄位
描述了方法的定義,但是方法裡的 Java 程式碼,經過編譯器編譯成位元組碼指令後,存放在屬性表集閤中的方法屬性表集閤中一個名爲「Code」的屬性裏面。
與欄位表集合相類似的,如果父類別方法在子類中沒有被重寫(Override),方法表集閤中就不會出現來自父類別的方法資訊。但同樣的,有可能會出現由編譯器自動新增的方法,最典型的便是類構造器「<clinit>」方法和範例構造器「<init>」
儲存 Class 檔案、欄位表、方法表都自己的屬性表集合,以用於描述某些場景專有的資訊。如方法的程式碼就儲存在 Code 屬性表中。
位元組碼指令屬於方法表中的內容。
方法表,是一個表結構,表中每個成員必須是 method_info 數據結構,用於表示當前類或者介面的某個方法的完整描述:
Java 虛擬機器的指令由一個位元組長度的、代表着某種特定操作含義的數位(稱爲操作碼,Opcode)以及跟隨其後的零至多個代表此操作所需參數(稱爲運算元,Operands)而構成。
由於限制了 Java 虛擬機器操作碼的長度爲一個位元組(即 0~255),這意味着指令集的操作碼總數不可能超過 256 條。
大多數的指令都包含了其操作所對應的數據型別資訊。例如:
iload 指令用於從區域性變數表中載入 int 型的數據到運算元棧中,而 fload 指令載入的則是 float 型別的數據。
大部分的指令都沒有支援整數型別 byte、char 和 short,甚至沒有任何指令支援 boolean 型別。大多數對於 boolean、byte、short 和 char 型別數據的操作,實際上都是使用相應的 int 型別作爲運算型別。
閱讀位元組碼作爲了解 Java 虛擬機器的基礎技能,位元組碼指令可以參考這篇文章。
每個時刻正在執行的當前方法就是虛擬機器棧頂的棧楨。方法的執行就對應着棧幀在虛擬機器棧中入棧和出棧的過程。
當一個方法執行完,要返回,那麼有兩種情況,一種是正常,另外一種是異常。
完成出口(返回地址):
如果你熟悉 Java 語言,那麼對上面的異常繼承體系一定不會陌生,其中,Error 和 RuntimeException 是非檢查型異常(Unchecked Exception),也就是不需要 catch 語句去捕獲的異常;而其他異常,則需要程式設計師手動去處理。
範例程式碼
/**
- 在 synchronized 生成的位元組碼中,其實包含兩條 monitorexit 指令,是爲了保證所有的異常條件,都能夠退出
- 這就涉及到了 Java 位元組碼的例外處理機制 機製
*/
public class SynchronizedDemo {
synchronized void m1(){
System.out.println("m1");
}
static synchronized void m2(){
System.out.println("m2");
}
final Object lock=new Object();
void doLock(){
synchronized (lock){
System.out.println("lock");
}
}
}
javap -v SynchronizedDemo.class反彙編
在 synchronized 生成的位元組碼中,其實包含兩條 monitorexit 指令,是爲了保證所有的異常條件,都能夠退出。
可以看到,編譯後的位元組碼,帶有一個叫 Exception table 的異常表,裏面的每一行數據,都是一個例外處理器:
通常我們在做一些檔案讀取的時候,都會在 finally 程式碼塊中關閉流,以避免記憶體的溢位。關於這個場景,我們再分析一下下面 下麪這段程式碼的異常表。
/**
* finally位元組碼的處理
*/
public class StreamDemo {
public void read(){
InputStream in = null;
try {
in = new FileInputStream("A.java");
}catch(FileNotFoundException e){
e.printStackTrace();
} finally {
if (null != in) {
try {
in.close();
}catch(IOException e) {
e.printStackTrace();
}
}
}
}
}
上面的程式碼,捕獲了一個 FileNotFoundException 異常,然後在 finally 中捕獲了IOException 異常。當我們分析位元組碼的時候,卻發現了一個有意思的地方:IOException 足足出現了三次。
Java 編譯器使用了一種比較傻的方式來組織 finally 的位元組碼,它分別在 try、catch 的正常執行路徑上,複製一份 finally 程式碼,追加在正常執行邏輯的後面;同時,再複製一份到其他異常執行邏輯的出口處。
再看一個例子
這段程式碼不報錯的原因,都可以在位元組碼中找到答案
/**
* 加了finally爲啥不會異常
*/
public class NoError {
public static void main(String[] args) {
NoError noError =new NoError();
noError.read();
}
volatile int kk =0;
public int read(){
try {
int a = 13/0;
return a;
}finally {
return 1;
}
}
}
反彙編:
可以看到,異常之後,直接跳轉到序號 9 了。
Java 中有 8 種基本型別,但鑑於 Java 物件導向的特點,它們同樣有着對應的 8 個包裝型別,比如 int 和 Integer,包裝型別的值可以爲 null(基本型別沒有 null 值,而數據庫的表中普遍存在 null 值。 所以實體類中所有屬性均應採用封裝型別),很多時候,它們都能夠相互賦值。
/**
* 裝箱拆箱位元組碼層面分析
*/
public class Box {
public Integer cal() {
Integer a = 1000;
int b = a * 10;
return b;
}
}
反彙編:
通過觀察位元組碼,我們發現:
1、在進行乘法運算的時候,呼叫了 Integer.intValue 方法來獲取基本型別的值。
2、賦值操作使用的是 Integer.valueOf 方法。
3、在方法返回的時候,再次使用了 Integer.valueOf 方法對結果進行了包裝。
這就是 Java 中的自動裝箱拆箱的底層實現。
我們繼續跟蹤 Integer.valueOf 方法:
這個 IntegerCache,快取了 low 和 high 之間的 Integer 物件
一般情況下,快取是的-128 到 127 之間的值,但是可以通過 -XX:AutoBoxCacheMax 來修改上限。下面 下麪是一道經典的面試題,請考慮一下執行程式碼後,會輸出什麼結果?
/**
* IntegerCache及修改
* -XX:AutoBoxCacheMax=256
*/
public class BoxCache {
public static void main(String[] args) {
Integer n1 = 123; //new一東西
Integer n2 = 123;
Integer n3 = 128;
Integer n4 = 128;
System.out.println(n1 == n2);
System.out.println(n3 == n4);
}
}
不加任何VM參數,執行上述程式碼,如圖
執行結果:
可以看到結果是 true,false 因爲快取的原因。(在快取範圍內的值,返回的是同一個快取值,不在的話,每次都是 new 出來的)
加入 VM 參數 -XX:AutoBoxCacheMax=256 ,並執行程式碼
執行結果是:
可以看出加入參數-XX:AutoBoxCacheMax=256後,執行結果變成 true,ture,因爲擴大快取範圍(-128-256),使得128也在快取內,所以第二個爲 true 。
其實,陣列是 JVM 內建的一種物件型別,這個物件同樣是繼承的 Object 類。我們使用程式碼來理解一下
public class ArrayDemo {
int getValue() {
int[] arr = new int[]{1111, 2222, 3333, 4444};
return arr[2];
}
int getLength(int[] arr) {
return arr.length;
}
}
反彙編
可以看到,新建陣列的程式碼,被編譯成了 newarray 指令
數組裏的初始內容,被順序編譯成了一系列指令:
具體操作:
陣列元素的存取,是通過第 28 ~ 30 行程式碼來實現的:
獲取陣列的長度,是由位元組碼指令 arraylength 來完成的
無論是 Java 的陣列,還是 List,都可以使用 foreach 語句進行遍歷,雖然在語言層面它們的表現形式是一致的,但實際實現的方法並不同。
public class ForDemo {
void loop(int[] arr) {
for (int i : arr) {
System.out.println(i);
}
}
void loop(List<Integer> arr) {
for (int i : arr) {
System.out.println(i);
}
}
}
使用 jd-gui 反編譯工具,可以看到實際生成的程式碼:```
陣列:它將程式碼解釋成了傳統的變數方式,即 for(int i;i<length;i++) 的形式。
List :它實際是把 list 物件進行迭代並遍歷的,在回圈中,使用了 Iterator.next() 方法。
public @interface KingAnnotation {
}
@KingAnnotation
public class AnnotationDemo {
@KingAnnotation
public void test(@KingAnnotation int a){
}
}
javap -v AnnotationDemo.class 反彙編,如圖
無論是類的註解,還是方法註解,都是由一個叫做 RuntimeInvisibleAnnotations 的結構來儲存的,而參數的儲存,是由 RuntimeInvisibleParameterAnotations 來保證的。
Java 的特性非常多,這裏不再一一列出,但都可以使用這種簡單的方式,從位元組碼層面分析了它的原理,一窺究竟。
比如異常的處理、finally 塊的執行順序;以及隱藏的裝箱拆箱和 foreach 語法糖的底層實現。
還有位元組碼指令,可能有幾千行,看起來很嚇人,但執行速度幾乎都是納秒級別的。Java 的無數框架,包括 JDK,也不會爲了優化這種效能對程式碼進行限制。瞭解其原理,但不要捨本逐末,比如減少一次 Java 執行緒的上下文切換,就比你優化幾千個裝箱拆箱動作,速度來的更快一些。
本系 本係列文章皆是筆者對所學的歸納總結,由於本人學識有限,如有錯誤之處,望各位看官指正