JVM學習總結(五):JVM執行子系統——類檔案結構及位元組碼指令

2020-08-09 12:35:23

前言

在这里插入图片描述

正文

一、JVM 的無關性

與平臺無關性是建立在操作系統上,虛擬機器廠商提供了許多可以執行在各種不同平臺的虛擬機器,它們都可以載入和執行位元組碼,從而實現程式的「一次編寫,到處執行」。
各種不同平臺的虛擬機器與所有平臺都統一使用的程式儲存格式——位元組碼(ByteCode)是構成平臺無關性的基石,也是語言無關性的基礎。Java 虛擬機器不和包括 Java 在內的任何語言系結,它只與「Class 檔案」這種特定的二進制檔案格式所關聯,Class 檔案中包含了 Java 虛擬機器指令集和符號表以及若幹其他輔助資訊。
在这里插入图片描述

二、Class 類檔案(瞭解即可)

Java 技術能夠一直保持非常好的向後相容性,這點 Class 檔案結構的穩定性功不可沒。Java 已經發展到 14 版本,但是 class 檔案結構的內容,絕大部分在JDK1.2 時代就已經定義好了。雖然 JDK1.2 的內容比較古老,但是 java 發展經歷了十餘個大版本,但是每次基本上知識在原有結構基礎上新增內容、擴充功能,並未對定義的內容做修改。
任何一個 Class 檔案都對應着唯一一個類或介面的定義資訊,但反過來說,Class 檔案實際上它並不一定以磁碟檔案的形式存在(比如可以動態生成、或者直接送入類載入器中)。
Class 檔案是一組以 8 位位元組爲基礎單位的二進制流。

1、工具介紹

  • Sublime
    檢視 16 進位制的編輯器
  • javap
    javap 是 JDK 自帶的反解析工具。它的作用是將 .class 位元組碼檔案解析成可讀的檔案格式。
    在使用 javap 時我一般會新增 -v 參數,儘量多列印一些資訊。同時,我也會使用 -p 參數,列印一些私有的欄位和方法。
  • jclasslib
    如果你不太習慣使用命令列的操作,還可以使用 jclasslib,jclasslib 是一個圖形化的工具,能夠更加直觀的檢視位元組碼中的內容。它還分門別類的對類中的各個部分進行了整理,非常的人性化。同時,它還提供了 Idea 的外掛,你可以從 plugins 中搜尋到它。
    jclasslib 的下載地址

2、Class 檔案格式

在这里插入图片描述
整個 class 檔案的格式就是一個二進制的位元組流。
各個數據專案嚴格按照順序緊湊地排列在 Class 檔案之中,中間沒有新增任何分隔符,這使得整個 Class 檔案中儲存的內容幾乎全部是程式執行的必要數據,沒有空隙存在。
Class 檔案格式採用一種類似於 C 語言結構體的僞結構來儲存數據,這種僞結構中只有兩種數據型別:無符號數和表。
無符號數屬於基本的數據型別,以 u1、u2、u4、u8 來分別代表 1 個位元組(一個位元組是由兩位 16 進位制陣列成)、2 個位元組、4 個位元組和 8 個位元組的無符號
數,無符號數可以用來描述數位、索引參照、數量值或者按照 UTF-8 編碼構成字串值。
表是由多個無符號數或者其他表作爲數據項構成的複合數據型別,所有表都習慣性地以「_info」結尾。表用於描述有層次關係的複合結構的數據,整個Class 檔案本質上就是一張表。

3、Class 檔案格式詳解

在这里插入图片描述
Class 的結構不像 XML 等描述語言,由於它沒有任何分隔符號,所以在其中的數據項,無論是順序還是數量,都是被嚴格限定的,哪個位元組代表什麼含義,長度是多少,先後順序如何,都不允許改變。 按順序包括:

3.1 魔數與 Class 檔案的版本

每個 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)

3.2 常數池

常數池中常數的數量是不固定的,所以在常數池的入口需要放置一項 u2 型別的數據,代表常數池容量計數值(constant_pool_count)。
與 Java 中語言習慣不一樣的是,這個容量計數是從 1 而不是 0 開始的
在这里插入图片描述
常數池中主要存放兩大類常數:字面量(Literal)符號參照(Symbolic References)

  • 字面量
    比較接近於 Java 語言層面的常數概念,如文字字串、宣告爲 final 的常數值等。
  • 符號參照
    屬於編譯原理方面的概念,包括三類常數: 類和介面的全限定名(Fully Qualified Name)、欄位的名稱和描述符(Descriptor)、方法的名稱和描述符

可以使用更加直觀的工具 jclasslib,來檢視位元組碼中的具體內容
在这里插入图片描述

3.3 存取標誌

用於識別一些類或者介面層次的存取資訊,包括:這個 Class 是類還是介面;是否定義爲 public 型別;是否定義爲 abstract 型別;如果是類的話,是否被宣告爲 final 等

3.4 類索引、父類別索引與介面索引集合

這三項數據來確定這個類的繼承關係。類索參照於確定這個類的全限定名,父類別索參照於確定這個類的父類別的全限定名。由於 Java 語言不允許多重繼承,所以父類別索引只有一個,除了 java.lang.Object 之外,所有的 Java 類都有父類別,因此除了 java.lang.Object 外,所有 Java 類的父類別索引都不爲 0。介面索引集合就用來描述這個類實現了哪些介面,這些被實現的介面將按 implements 語句(如果這個類本身是一個介面,則應當是 extends 語句)後的介面順序從左到右排列在介面索引集閤中

3.5 欄位表集合

描述介面或者類中宣告的變數。欄位(field)包括類級變數以及範例級變數。
而欄位叫什麼名字、欄位被定義爲什麼數據型別,這些都是無法固定的,只能參照常數池中的常數來描述。
欄位表集閤中不會列出從超類或者父介面中繼承而來的欄位,但有可能列出原本 Java 程式碼之中不存在的欄位,譬如在內部類中爲了保持對外部類的存取性,會自動新增指向外部類範例的欄位

3.6 方法表集合

描述了方法的定義,但是方法裡的 Java 程式碼,經過編譯器編譯成位元組碼指令後,存放在屬性表集閤中的方法屬性表集閤中一個名爲「Code」的屬性裏面。
與欄位表集合相類似的,如果父類別方法在子類中沒有被重寫(Override),方法表集閤中就不會出現來自父類別的方法資訊。但同樣的,有可能會出現由編譯器自動新增的方法,最典型的便是類構造器「<clinit>」方法和範例構造器「<init>」

3.7 屬性表集合

儲存 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 虛擬機器的基礎技能,位元組碼指令可以參考這篇文章

  • 載入和儲存指令
    用於將數據在棧幀中的區域性變數表和運算元棧之間來回傳輸,這類指令包括如下內容。
    將一個區域性變數載入到操作棧:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
    將一個數值從運算元棧儲存到區域性變數表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n
    >。
    將一個常數載入到運算元棧:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
    擴充區域性變數表的存取索引的指令:wide。
  • 運算或算術指令
    用於對兩個運算元棧上的值進行某種特定運算,並把結果重新存入到操作棧頂。
    加法指令:iadd、ladd、fadd、dadd。
    減法指令:isub、lsub、fsub、dsub。
    乘法指令:imul、lmul、fmul、dmul 等等
  • 型別轉換指令
    可以將兩種不同的數值型別進行相互轉換,
    Java 虛擬機器直接支援以下數值型別的寬化型別轉換(即小範圍型別向大範圍型別的安全轉換):
    int 型別到 long、float 或者 double 型別。
    long 型別到 float、double 型別。
    float 型別到 double 型別。
    處理窄化型別轉換(Narrowing Numeric Conversions)時,必須顯式地使用轉換指令來完成,這些轉換指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和d2f。
  • 建立類範例的指令
    new。
  • 建立陣列的指令
    newarray、anewarray、multianewarray。
  • 存取欄位指令
    getfield、putfield、getstatic、putstatic。
  • 陣列存取相關指令
    把一個數組元素載入到運算元棧的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
    將一個運算元棧的值儲存到陣列元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
    取陣列長度的指令:arraylength。
    檢查類範例型別的指令
  • 檢查類範例型別的指令
    instanceof、checkcast。
  • 運算元棧管理指令
    如同操作一個普通數據結構中的堆疊那樣,Java 虛擬機器提供了一些用於直接操作運算元棧的指令,包括:將運算元棧的棧頂一個或兩個元素出棧:pop、
    pop2。
    複製棧頂一個或兩個數值並將複製值或雙份的複製值重新壓入棧頂:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
    將棧最頂端的兩個數值互換:swap。
  • 控制轉移指令
    控制轉移指令可以讓 Java 虛擬機器有條件或無條件地從指定的位置指令而不是控制轉移指令的下一條指令繼續執行程式,從概念模型上理解,可以認爲控
    制轉移指令就是在有條件或無條件地修改 PC 暫存器的值。控制轉移指令如下。
    條件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。
    複合條件分支:tableswitch、lookupswitch。
    無條件分支:goto、goto_w、jsr、jsr_w、ret。
  • 方法呼叫指令
    invokevirtual 指令用於呼叫物件的實體方法,根據物件的實際型別進行分派(虛方法分派),這也是 Java 語言中最常見的方法分派方式。
    invokeinterface 指令用於呼叫介面方法,它會在執行時搜尋一個實現了這個介面方法的物件,找出適合的方法進行呼叫。
    invokespecial 指令用於呼叫一些需要特殊處理的實體方法,包括範例初始化方法、私有方法和父類別方法。
    invokestatic 指令用於呼叫類方法(static 方法)。
    invokedynamic 指令用於在執行時動態解析出調用點限定符所參照的方法,並執行該方法,前面 4 條呼叫指令的分派邏輯都固化在 Java 虛擬機器內部,而
    invokedynamic 指令的分派邏輯是由使用者所設定的引導方法決定的。
    方法呼叫指令與數據型別無關。
  • 方法返回指令
    是根據返回值的型別區分的,包括 ireturn(當返回值是 boolean、byte、char、short 和 int 型別時使用)、lreturn、freturn、dreturn 和 areturn,另外還有
    一條 return 指令供宣告爲 void 的方法、範例初始化方法以及類和介面的類初始化方法使用。
  • 例外處理指令
    在 Java 程式中顯式拋出異常的操作(throw 語句)都由 athrow 指令來實現
  • 同步指令
    有 monitorenter 和 monitorexit 兩條指令來支援 synchronized 關鍵字的語意

四、位元組碼指令——例外處理

每個時刻正在執行的當前方法就是虛擬機器棧頂的棧楨。方法的執行就對應着棧幀在虛擬機器棧中入棧和出棧的過程。
當一個方法執行完,要返回,那麼有兩種情況,一種是正常,另外一種是異常。
完成出口(返回地址):

  • 正常返回:(呼叫程式計數器中的地址作爲返回)
    三步曲:
    (1)恢復上層方法的區域性變數表和運算元棧、
    (2)把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中、
    (3)調整程式計數器的值以指向方法呼叫指令後面的一條指令、
  • 異常的話:(通過異常處理表<非棧幀中的>來確定)

1、異常機制 機製

在这里插入图片描述
如果你熟悉 Java 語言,那麼對上面的異常繼承體系一定不會陌生,其中,Error 和 RuntimeException 是非檢查型異常(Unchecked Exception),也就是不需要 catch 語句去捕獲的異常;而其他異常,則需要程式設計師手動去處理。

2、異常表

範例程式碼

/**
 - 在 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 的異常表,裏面的每一行數據,都是一個例外處理器:

  • from 指定位元組碼索引的開始位置
  • to 指定位元組碼索引的結束位置
  • target 例外處理的起始位置
  • type 異常型別
    也就是說,只要在 from 和 to 之間發生了異常,就會跳轉到 target 所指定的位置。
    我可以看到,第一條 monitorexit(16)在異常表第一條的範圍中,如果異常,能夠跳轉到第 20 行
    第二條 monitorexit(22)在異常表第二條的範圍中,如果異常,能夠跳轉到第 20 行

3、Finally

通常我們在做一些檔案讀取的時候,都會在 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 了。

五、位元組碼指令——裝箱拆箱

1、裝箱拆箱

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 中的自動裝箱拆箱的底層實現。

2、IntegerCache

我們繼續跟蹤 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;
    }
}

反彙編
在这里插入图片描述

6.1 陣列建立

可以看到,新建陣列的程式碼,被編譯成了 newarray 指令
數組裏的初始內容,被順序編譯成了一系列指令:

  • sipush
    將一個短整型常數值推播至棧頂;
  • iastore
    將棧頂 int 型數值存入指定陣列的指定索引位置

具體操作:

  • 1) iconst_0
  • 常數 0,入運算元棧
  • 2) sipush 1111
    將一個常數 1111 載入到運算元棧
  • 3)iastore
    將棧頂 int 型數值存入陣列的 0 索引位置
    爲了支援多種型別,從運算元棧儲存到陣列,有更多的指令:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore。

6.2 陣列存取

在这里插入图片描述
陣列元素的存取,是通過第 28 ~ 30 行程式碼來實現的:

  • aload_ 1
    將第二個參照型別本地變數推播至棧頂,這裏是生成的陣列;
  • iconst_2
    將 int 型 2 推播至棧頂;
  • iaload
    將 int 型陣列指定索引的值推播至棧頂。

獲取陣列的長度,是由位元組碼指令 arraylength 來完成的
在这里插入图片描述

七、位元組碼指令——foreach

無論是 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 執行緒的上下文切換,就比你優化幾千個裝箱拆箱動作,速度來的更快一些。

後語

本系 本係列文章皆是筆者對所學的歸納總結,由於本人學識有限,如有錯誤之處,望各位看官指正