硬核萬字長文,深入理解 Java 位元組碼指令(建議收藏)

沉默王二發表於2021-08-02

Java 位元組碼指令是 JVM 體系中非常難啃的一塊硬骨頭,我估計有些讀者會有這樣的疑惑,“Java 位元組碼難學嗎?我能不能學會啊?”

講良心話,不是我謙虛,一開始學 Java 位元組碼和 Java 虛擬機器方面的知識我也感覺頭大!但硬著頭皮學了一陣子之後,突然就開竅了,覺得好有意思,尤其是明白了 Java 程式碼在底層竟然是這樣執行的時候,感覺既膨脹又飄飄然,渾身上下散發著自信的光芒!

我在 部落格園 共輸出了 100 多篇 Java 方面的文章,總字數超過 30 萬字, 內容風趣幽默、通俗易懂,收穫了很多初學者的認可和支援,內容包括 Java 語法、Java 集合框架、Java 併發程式設計、Java 虛擬機器等核心內容


為了幫助更多的 Java 初學者,我“一怒之下”就把這些文章重新整理並開源到了 GitHub,起名《教妹學 Java》,聽起來是不是就很有趣?

GitHub 開源地址(歡迎 star):https://github.com/itwanger/jmx-java

Java 官方的虛擬機器 Hotspot 是基於棧的,而不是基於暫存器的。

基於棧的優點是可移植性更好、指令更短、實現起來簡單,但不能隨機訪問棧中的元素,完成相同功能所需要的指令數也比暫存器的要多,需要頻繁的入棧和出棧。

基於暫存器的優點是速度快,有利於程式執行速度的優化,但運算元需要顯式指定,指令也比較長。

Java 位元組碼由操作碼和運算元組成。

  • 操作碼(Opcode):一個位元組長度(0-255,意味著指令集的操作碼總數不可能超過 256 條),代表著某種特定的操作含義。
  • 運算元(Operands):零個或者多個,緊跟在操作碼之後,代表此操作需要的引數。

由於 Java 虛擬機器是基於棧而不是暫存器的結構,所以大多數指令都只有一個操作碼。比如 aload_0(將區域性變數表中下標為 0 的資料壓入運算元棧中)就只有操作碼沒有運算元,而 invokespecial #1(呼叫成員方法或者構造方法,並傳遞常量池中下標為 1 的常量)就是由操作碼和運算元組成的。

01、載入與儲存指令

載入(load)和儲存(store)相關的指令是使用最頻繁的指令,用於將資料從棧幀的區域性變數表和運算元棧之間來回傳遞。

1)將區域性變數表中的變數壓入運算元棧中

  • xload_(x 為 i、l、f、d、a,n 預設為 0 到 3),表示將第 n 個區域性變數壓入運算元棧中。
  • xload(x 為 i、l、f、d、a),通過指定引數的形式,將區域性變數壓入運算元棧中,當使用這個指令時,表示區域性變數的數量可能超過了 4 個

解釋一下。

x 為操作碼助記符,表明是哪一種資料型別。見下表所示。

像 arraylength 指令,沒有操作碼助記符,它沒有代表資料型別的特殊字元,但運算元只能是一個陣列型別的物件。

大部分的指令都不支援 byte、short 和 char,甚至沒有任何指令支援 boolean 型別。編譯器會將 byte 和 short 型別的資料帶符號擴充套件(Sign-Extend)為 int 型別,將 boolean 和 char 零位擴充套件(Zero-Extend)為 int 型別。

舉例來說。

private void load(int age, String name, long birthday, boolean sex) {
    System.out.println(age + name + birthday + sex);
}

通過 jclasslib 看一下 load() 方法(4 個引數)的位元組碼指令。

  • iload_1:將區域性變數表中下標為 1 的 int 變數壓入運算元棧中。
  • aload_2:將區域性變數表中下標為 2 的引用資料型別變數(此時為 String)壓入運算元棧中。
  • lload_3:將區域性變數表中下標為 3 的 long 型變數壓入運算元棧中。
  • iload 5:將區域性變數表中下標為 5 的 int 變數(實際為 boolean)壓入運算元棧中。

通過檢視區域性變數表就能關聯上了。

2)將常量池中的常量壓入運算元棧中

根據資料型別和入棧內容的不同,此類又可以細分為 const 系列、push 系列和 Idc 指令。

const 系列,用於特殊的常量入棧,要入棧的常量隱含在指令本身。

push 系列,主要包括 bipush 和 sipush,前者接收 8 位整數作為引數,後者接收 16 位整數。

Idc 指令,當 const 和 push 不能滿足的時候,萬能的 Idc 指令就上場了,它接收一個 8 位的引數,指向常量池中的索引。

  • Idc_w:接收兩個 8 位數,索引範圍更大。
  • 如果引數是 long 或者 double,使用 Idc2_w 指令。

舉例來說。

public void pushConstLdc() {
    // 範圍 [-1,5]
    int iconst = -1;
    // 範圍 [-128,127]
    int bipush = 127;
    // 範圍 [-32768,32767]
    int sipush= 32767;
    // 其他 int
    int ldc = 32768;
    String aconst = null;
    String IdcString = "沉默王二";
}

通過 jclasslib 看一下 pushConstLdc() 方法的位元組碼指令。

  • iconst_m1:將 -1 入棧。範圍 [-1,5]。
  • bipush 127:將 127 入棧。範圍 [-128,127]。
  • sipush 32767:將 32767 入棧。範圍 [-32768,32767]。
  • ldc #6 <32768>:將常量池中下標為 6 的常量 32768 入棧。
  • aconst_null:將 null 入棧。
  • ldc #7 <沉默王二>:將常量池中下標為 7 的常量“沉默王二”入棧。

3)將棧頂的資料出棧並裝入區域性變數表中

主要是用來給區域性變數賦值,這類指令主要以 store 的形式存在。

  • xstore_(x 為 i、l、f、d、a,n 預設為 0 到 3)
  • xstore(x 為 i、l、f、d、a)

明白了 xload_ 和 xload,再看 xstore_ 和 xstore 就會輕鬆得多,作用反了一下而已。

大家來想一個問題,為什麼要有 xstore_ 和 xload_ 呢?它們的作用和 xstore n、xload n 不是一樣的嗎?

xstore_ 和 xstore n 的區別在於,前者相當於只有操作碼,佔用 1 個位元組;後者相當於由操作碼和運算元組成,操作碼佔 1 個位元組,運算元佔 2 個位元組,一共佔 3 個位元組。

由於區域性變數表中前幾個位置總是非常常用,雖然 xstore_<n>xload_<n> 增加了指令數量,但位元組碼的體積變小了!

舉例來說。

public void store(int age, String name) {
    int temp = age + 2;
    String str = name;
}

通過 jclasslib 看一下 store() 方法的位元組碼指令。

  • istore_3:從運算元中彈出一個整數,並把它賦值給區域性變數表中索引為 3 的變數。
  • astore 4:從運算元中彈出一個引用資料型別,並把它賦值給區域性變數表中索引為 4 的變數。

通過檢視區域性變數表就能關聯上了。

02、算術指令

算術指令用於對兩個運算元棧上的值進行某種特定運算,並把結果重新壓入運算元棧。可以分為兩類:整型資料的運算指令和浮點資料的運算指令。

需要注意的是,資料運算可能會導致溢位,比如兩個很大的正整數相加,很可能會得到一個負數。但 Java 虛擬機器規範中並沒有對這種情況給出具體結果,因此程式是不會顯式報錯的。所以,大家在開發過程中,如果涉及到較大的資料進行加法、乘法運算的時候,一定要注意!

當發生溢位時,將會使用有符號的無窮大 Infinity 來表示;如果某個操作結果沒有明確的數學定義的話,將會使用 NaN 值來表示。而且所有使用 NaN 作為運算元的算術操作,結果都會返回 NaN。

舉例來說。

public void infinityNaN() {
    int i = 10;
    double j = i / 0.0;
    System.out.println(j); // Infinity

    double d1 = 0.0;
    double d2 = d1 / 0.0;
    System.out.println(d2); // NaN
}
  • 任何一個非零的數除以浮點數 0(注意不是 int 型別),可以想象結果是無窮大 Infinity 的。
  • 把這個非零的數換成 0 的時候,結果又不太好定義,就用 NaN 值來表示。

Java 虛擬機器提供了兩種運算模式

  • 向最接近數舍入:在進行浮點數運算時,所有的結果都必須舍入到一個適當的精度,不是特別精確的結果必須舍入為可被表示的最接近的精確值,如果有兩種可表示的形式與該值接近,將優先選擇最低有效位為零的(類似四捨五入)。
  • 向零舍入:將浮點數轉換為整數時,採用該模式,該模式將在目標數值型別中選擇一個最接近但是不大於原值的數字作為最精確的舍入結果(類似取整)。

我把所有的算術指令列一下:

  • 加法指令:iadd、ladd、fadd、dadd
  • 減法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求餘指令:irem、lrem、frem、drem
  • 自增指令:iinc

舉例來說。

public void calculate(int age) {
    int add = age + 1;
    int sub = age - 1;
    int mul = age * 2;
    int div = age / 3;
    int rem = age % 4;
    age++;
    age--;
}

通過 jclasslib 看一下 calculate() 方法的位元組碼指令。

  • iadd,加法
  • isub,減法
  • imul,乘法
  • idiv,除法
  • irem,取餘
  • iinc,自增的時候 +1,自減的時候 -1

03、型別轉換指令

可以分為兩種:

1)寬化,小型別向大型別轉換,比如 int–>long–>float–>double,對應的指令有:i2l、i2f、i2d、l2f、l2d、f2d。

  • 從 int 到 long,或者從 int 到 double,是不會有精度丟失的;
  • 從 int、long 到 float,或者 long 到 double 時,可能會發生精度丟失;
  • 從 byte、char 和 short 到 int 的寬化型別轉換實際上是隱式發生的,這樣可以減少位元組碼指令,畢竟位元組碼指令只有 256 個,佔一個位元組。

2)窄化,大型別向小型別轉換,比如從 int 型別到 byte、short 或者 char,對應的指令有:i2b、i2s、i2c;從 long 到 int,對應的指令有:l2i;從 float 到 int 或者 long,對應的指令有:f2i、f2l;從 double 到 int、long 或者 float,對應的指令有:d2i、d2l、d2f。

  • 窄化很可能會發生精度丟失,畢竟是不同的數量級;
  • 但 Java 虛擬機器並不會因此丟擲執行時異常。

舉例來說。

public void updown() {
    int i = 10;
    double d = i;

    float f = 10f;
    long ong = (long)f;
}

通過 jclasslib 看一下 updown() 方法的位元組碼指令。

  • i2d,int 寬化為 double
  • f2l, float 窄化為 long

04、物件的建立和訪問指令

Java 是一門物件導向的程式語言,那麼 Java 虛擬機器是如何從位元組碼層面進行支援的呢?

1)建立指令

陣列也是一種物件,但它建立的位元組碼指令和普通的物件不同。建立陣列的指令有三種:

  • newarray:建立基本資料型別的陣列
  • anewarray:建立引用型別的陣列
  • multianewarray:建立多維陣列

普通物件的建立指令只有一個,就是 new,它會接收一個運算元,指向常量池中的一個索引,表示要建立的型別。

舉例來說。

public void newObject() {
    String name = new String("沉默王二");
    File file = new File("無愁河的浪蕩漢子.book");
    int [] ages = {};
}

通過 jclasslib 看一下 newObject() 方法的位元組碼指令。

  • new #13 ,建立一個 String 物件。
  • new #15 ,建立一個 File 物件。
  • newarray 10 (int),建立一個 int 型別的陣列。

2)欄位訪問指令

欄位可以分為兩類,一類是成員變數,一類是靜態變數(static 關鍵字修飾的),所以欄位訪問指令可以分為兩類:

  • 訪問靜態變數:getstatic、putstatic。
  • 訪問成員變數:getfield、putfield,需要建立物件後才能訪問。

舉例來說。

public class Writer {
    private String name;
    static String mark = "作者";

    public static void main(String[] args) {
        print(mark);
        Writer w = new Writer();
        print(w.name);
    }

    public static void print(String arg) {
        System.out.println(arg);
    }
}

通過 jclasslib 看一下 main() 方法的位元組碼指令。

  • getstatic #2 ,訪問靜態變數 mark
  • getfield #6 ,訪問成員變數 name

05、方法呼叫和返回指令

方法呼叫指令有 5 個,分別用於不同的場景:

  • invokevirtual:用於呼叫物件的成員方法,根據物件的實際型別進行分派,支援多型。
  • invokeinterface:用於呼叫介面方法,會在執行時搜尋由特定物件實現的介面方法進行呼叫。
  • invokespecial:用於呼叫一些需要特殊處理的方法,包括構造方法、私有方法和父類方法。
  • invokestatic:用於呼叫靜態方法。
  • invokedynamic:用於在執行時動態解析出呼叫點限定符所引用的方法,並執行。

舉例來說。

public class InvokeExamples {
    private void run() {
        List ls = new ArrayList();
        ls.add("難頂");

        ArrayList als = new ArrayList();
        als.add("學不動了");
    }

    public static void print() {
        System.out.println("invokestatic");
    }

    public static void main(String[] args) {
        print();
        InvokeExamples invoke = new InvokeExamples();
        invoke.run();
    }
}

我們用 javap -c InvokeExamples.class 來反編譯一下。

Compiled from "InvokeExamples.java"
public class com.itwanger.jvm.InvokeExamples {
  public com.itwanger.jvm.InvokeExamples();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4return

  private void run();
    Code:
       0new           #2                  // class java/util/ArrayList
       3: dup
       4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
       7: astore_1
       8: aload_1
       9: ldc           #4                  // String 難頂
      11: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      16: pop
      17new           #2                  // class java/util/ArrayList
      20: dup
      21: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
      24: astore_2
      25: aload_2
      26: ldc           #6                  // String 學不動了
      28: invokevirtual #7                  // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
      31: pop
      32return

  public static void print();
    Code:
       0: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #9                  // String invokestatic
       5: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #11                 // Method print:()V
       3new           #12                 // class com/itwanger/jvm/InvokeExamples
       6: dup
       7: invokespecial #13                 // Method "<init>":()V
      10: astore_1
      11: aload_1
      12: invokevirtual #14                 // Method run:()V
      15return
}

InvokeExamples 類有 4 個方法,包括預設的構造方法在內。

1)InvokeExamples() 構造方法中

預設的構造方法內部會呼叫超類 Object 的初始化構造方法:

`invokespecial #1 // Method java/lang/Object."<init>":()V`

2)成員方法 run()

invokeinterface #5,  2  // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z

由於 ls 變數的引用型別為介面 List,所以 ls.add() 呼叫的是 invokeinterface 指令,等執行時再確定是不是介面 List 的實現物件 ArrayList 的 add() 方法。

invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z

由於 als 變數的引用型別已經確定為 ArrayList,所以 als.add() 方法呼叫的是 invokevirtual 指令。

3)main() 方法中

invokestatic  #11 // Method print:()V

print() 方法是靜態的,所以呼叫的是 invokestatic 指令。

方法返回指令根據方法的返回值型別進行區分,常見的返回指令見下圖。

06、運算元棧管理指令

常見的運算元棧管理指令有 pop、dup 和 swap。

  • 將一個或兩個元素從棧頂彈出,並且直接廢棄,比如 pop,pop2;
  • 複製棧頂的一個或兩個數值並將其重新壓入棧頂,比如 dup,dup2,dup_×1,dup2_×1,dup_×2,dup2_×2;
  • 將棧最頂端的兩個槽中的數值交換位置,比如 swap。

這些指令不需要指明資料型別,因為是按照位置壓入和彈出的。

舉例來說。

public class Dup {
    int age;
    public int incAndGet() {
        return ++age;
    }
}

通過 jclasslib 看一下 incAndGet() 方法的位元組碼指令。

  • aload_0:將 this 入棧。
  • dup:複製棧頂的 this。
  • getfield #2:將常量池中下標為 2 的常量載入到棧上,同時將一個 this 出棧。
  • iconst_1:將常量 1 入棧。
  • iadd:將棧頂的兩個值相加後出棧,並將結果放回棧上。
  • dup_x1:複製棧頂的元素,並將其插入 this 下面。
  • putfield #2: 將棧頂的兩個元素出棧,並將其賦值給欄位 age。
  • ireturn:將棧頂的元素出棧返回。

07、控制轉移指令

控制轉移指令包括:

  • 比較指令,比較棧頂的兩個元素的大小,並將比較結果入棧。
  • 條件跳轉指令,通常和比較指令一塊使用,在條件跳轉指令執行前,一般先用比較指令進行棧頂元素的比較,然後進行條件跳轉。
  • 比較條件轉指令,類似於比較指令和條件跳轉指令的結合體,它將比較和跳轉兩個步驟合二為一。
  • 多條件分支跳轉指令,專為 switch-case 語句設計的。
  • 無條件跳轉指令,目前主要是 goto 指令。

1)比較指令

比較指令有:dcmpg,dcmpl、fcmpg、fcmpl、lcmp,指令的第一個字母代表的含義分別是 double、float、long。注意,沒有 int 型別。

對於 double 和 float 來說,由於 NaN 的存在,有兩個版本的比較指令。拿 float 來說,有 fcmpg 和 fcmpl,區別在於,如果遇到 NaN,fcmpg 會將 1 壓入棧,fcmpl 會將 -1 壓入棧。

舉例來說。

public void lcmp(long a, long b) {
    if(a > b){}
}

通過 jclasslib 看一下 lcmp() 方法的位元組碼指令。

lcmp 用於兩個 long 型的資料進行比較。

2)條件跳轉指令

這些指令都會接收兩個位元組的運算元,它們的統一含義是,彈出棧頂元素,測試它是否滿足某一條件,滿足的話,跳轉到對應位置。

對於 long、float 和 double 型別的條件分支比較,會先執行比較指令返回一個整形值到運算元棧中後再執行 int 型別的條件跳轉指令。

對於 boolean、byte、char、short,以及 int,則直接使用條件跳轉指令來完成。

舉例來說。

public void fi() {
    int a = 0;
    if (a == 0) {
        a = 10;
    } else {
        a = 20;
    }
}

通過 jclasslib 看一下 fi() 方法的位元組碼指令。

3 ifne 12 (+9) 的意思是,如果棧頂的元素不等於 0,跳轉到第 12(3+9)行 12 bipush 20

3)比較條件轉指令

字首“if_”後,以字元“i”開頭的指令針對 int 型整數進行操作,以字元“a”開頭的指令表示物件的比較。

舉例來說。

public void compare() {
    int i = 10;
    int j = 20;
    System.out.println(i > j);
}

通過 jclasslib 看一下 compare() 方法的位元組碼指令。

11 if_icmple 18 (+7) 的意思是,如果棧頂的兩個 int 型別的數值比較的話,如果前者小於後者時跳轉到第 18 行(11+7)。

4)多條件分支跳轉指令

主要有 tableswitch 和 lookupswitch,前者要求多個條件分支值是連續的,它內部只存放起始值和終止值,以及若干個跳轉偏移量,通過給定的運算元 index,可以立即定位到跳轉偏移量位置,因此效率比較高;後者內部存放著各個離散的 case-offset 對,每次執行都要搜尋全部的 case-offset 對,找到匹配的 case 值,並根據對應的 offset 計算跳轉地址,因此效率較低。

舉例來說。

public void switchTest(int select) {
    int num;
    switch (select) {
        case 1:
            num = 10;
            break;
        case 2:
        case 3:
            num = 30;
            break;
        default:
            num = 40;
    }
}

通過 jclasslib 看一下 switchTest() 方法的位元組碼指令。

case 2 的時候沒有 break,所以 case 2 和 case 3 是連續的,用的是 tableswitch。如果等於 1,跳轉到 28 行;如果等於 2 和 3,跳轉到 34 行,如果是 default,跳轉到 40 行。

5)無條件跳轉指令

goto 指令接收兩個位元組的運算元,共同組成一個帶符號的整數,用於指定指令的偏移量,指令執行的目的就是跳轉到偏移量給定的位置處。

前面的例子裡都出現了 goto 的身影,也很好理解。如果指令的偏移量特別大,超出了兩個位元組的範圍,可以使用指令 goto_w,接收 4 個位元組的運算元。


巨人的肩膀:

https://segmentfault.com/a/1190000037628881

除了以上這些指令,還有異常處理指令和同步控制指令,我打算吊一吊大家的胃口,大家可以期待一波~~

(騷操作)

路漫漫其修遠兮,吾將上下而求索

想要走得更遠,Java 位元組碼這塊就必須得硬碰硬地吃透,希望二哥的這些分享可以幫助到大家~

叨逼叨

二哥在 部落格園 上寫了很多 Java 方面的系列文章,有 Java 核心語法、Java 集合框架、Java IO、Java 併發程式設計、Java 虛擬機器等,也算是體系完整了。

為了能幫助到更多的 Java 初學者,二哥把自己連載的《教妹學Java》開源到了 GitHub,儘管只整理了 50 篇,發現字數已經來到了 10 萬+,內容更是沒得說,通俗易懂、風趣幽默、圖文並茂

GitHub 開源地址(歡迎 star):https://github.com/itwanger/jmx-java

如果有幫助的話,還請給二哥點個贊,這將是我繼續分享下去的最強動力!

相關文章