硬核萬字長文,深刻理解 Java 字節碼指令(建議收藏)

Java 字節碼指令是 JVM 體系中很是難啃的一塊硬骨頭,我估計有些讀者會有這樣的疑惑,「Java 字節碼難學嗎?我能不能學會啊?」java

講良心話,不是我謙虛,一開始學 Java 字節碼和 Java 虛擬機方面的知識我也感受頭大!但硬着頭皮學了一陣子以後,忽然就開竅了,以爲好有意思,尤爲是明白了 Java 代碼在底層居然是這樣執行的時候,感受既膨脹又飄飄然,渾身上下散發着自信的光芒!git

我在 掘金 共輸出了 100 多篇 Java 方面的文章,總字數超過 30 萬字, 內容風趣幽默、通俗易懂,收穫了不少初學者的承認和支持,內容包括 Java 語法、Java 集合框架、Java 併發編程、Java 虛擬機等核心內容 爲了幫助更多的 Java 初學者,我「一怒之下」就把這些文章從新整理並開源到了 GitHub,起名《教妹學 Java》,聽起來是否是就頗有趣?github

GitHub 開源地址(歡迎 star):github.com/itwanger/jm…編程

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

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

基於寄存器的優勢是速度快,有利於程序運行速度的優化,但操做數須要顯式指定,指令也比較長。markdown

Java 字節碼由操做碼和操做數組成。併發

  • 操做碼(Opcode):一個字節長度(0-255,意味着指令集的操做碼總數不可能超過 256 條),表明着某種特定的操做含義。
  • 操做數(Operands):零個或者多個,緊跟在操做碼以後,表明此操做須要的參數。

因爲 Java 虛擬機是基於棧而不是寄存器的結構,因此大多數指令都只有一個操做碼。好比 aload_0(將局部變量表中下標爲 0 的數據壓入操做數棧中)就只有操做碼沒有操做數,而 invokespecial #1(調用成員方法或者構造方法,並傳遞常量池中下標爲 1 的常量)就是由操做碼和操做數組成的。框架

0一、加載與存儲指令

加載(load)和存儲(store)相關的指令是使用最頻繁的指令,用於將數據從棧幀的局部變量表和操做數棧之間來回傳遞。jvm

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 的變量。

經過查看局部變量表就能關聯上了。

0二、算術指令

算術指令用於對兩個操做數棧上的值進行某種特定運算,並把結果從新壓入操做數棧。能夠分爲兩類:整型數據的運算指令和浮點數據的運算指令。

須要注意的是,數據運算可能會致使溢出,好比兩個很大的正整數相加,極可能會獲得一個負數。但 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

0三、類型轉換指令

能夠分爲兩種:

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

0四、對象的建立和訪問指令

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 <java/lang/String>,建立一個 String 對象。
  • new #15 <java/io/File>,建立一個 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 <com/itwanger/jvm/Writer.mark>,訪問靜態變量 mark
  • getfield #6 <com/itwanger/jvm/Writer.name>,訪問成員變量 name

0五、方法調用和返回指令

方法調用指令有 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
       4: return

  private void run();
    Code:
       0: new           #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
      17: new           #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
      32: return

  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
       8: return

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #11                 // Method print:()V
       3: new           #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
      15: return
}
複製代碼

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 指令。

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

0六、操做數棧管理指令

常見的操做數棧管理指令有 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:將棧頂的元素出棧返回。

0七、控制轉移指令

控制轉移指令包括:

  • 比較指令,比較棧頂的兩個元素的大小,並將比較結果入棧。
  • 條件跳轉指令,一般和比較指令一塊使用,在條件跳轉指令執行前,通常先用比較指令進行棧頂元素的比較,而後進行條件跳轉。
  • 比較條件轉指令,相似於比較指令和條件跳轉指令的結合體,它將比較和跳轉兩個步驟合二爲一。
  • 多條件分支跳轉指令,專爲 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 個字節的操做數。


巨人的肩膀:

segmentfault.com/a/119000003…

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

(騷操做)

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

想要走得更遠,Java 字節碼這塊就必須得硬碰硬地吃透,但願二哥的這些分享能夠幫助到你們~

叨逼叨

二哥在 掘金 上寫了不少 Java 方面的系列文章,有 Java 核心語法、Java 集合框架、Java IO、Java 併發編程、Java 虛擬機等,也算是體系完整了。

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

GitHub 開源地址(歡迎 star):github.com/itwanger/jm…

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

相關文章
相關標籤/搜索