Java字節簡介

Java字節簡介

即使對於有經驗的Java開發人員來說,讀取編譯的Java字節碼也很繁瑣。爲什麼我們首先需要了解這種低級別的東西?這是上週發生在我身上的一個簡單場景:我很久以前在我的機器上進行了一些代碼更改,編譯了一個Jar並將其部署在服務器上以測試針對性能問題的潛在修復。遺憾的是,代碼從未簽入版本控制系統,無論出於何種原因,本地更改都被刪除而沒有跟蹤。幾個月後,我再次需要源代碼形式的更改(這需要付出相當大的努力),但找不到它們!

幸運的是,編譯後的代碼仍然存在於該遠程服務器上。於是鬆了一口氣,我再次取出Jar並使用反編譯器編輯器打開它。只有一個問題,反編譯器GUI不是一個完美的工具,並且在那個Jar中的許多類中,由於某種原因,只有我想要反編譯的特定類導致UI中的錯誤在我打開它時被執行並且反編譯器崩潰了!

絕望的時候需要絕望的措施...幸運的是我熟悉原始字節碼,我寧願花一些時間手動反編譯一些代碼,而不是通過更改並再次測試它們。因爲我至少還記得在代碼中查找的地方,閱讀字節碼幫助我查明確切的更改並以源代碼形式重新構建它們。(我確保從錯誤中吸取教訓並保留它們!)

關於字節碼的好處是你學習它的語法一次並且它適用於所有Java支持的平臺,因爲它是代碼中間表示,而不是底層CPU的實際可執行代碼。此外,字節碼比本機機器碼簡單,因爲JVM架構相當簡單,因此簡化了指令集。另一個好處是Oracle 指令集中完整記錄該指令集中的所有指令

在學習字節碼指令集之前,讓我們先熟悉一下JVM的一些必要條件。

JVM數據類型

Java是靜態類型的,它影響字節碼指令的設計,使得指令期望自己對特定類型的值進行操作。例如,有一些附加說明添加兩個數字:iaddladdfadddadd他們期望類型的操作數分別爲int,long,float和double。大多數字節碼具有這樣的特徵:具有相同功能的不同形式但是根據操作數類型而不同。

JVM定義的數據類型是:

  1. 原始類型:
    • 數字類型:byte(8位2的補碼),short(16位2的補碼),int(32位2的補碼),long(64位2的補碼),char(16位無符號Unicode),float(32位IEEE 754單)精度FP),double(64位IEEE 754雙精度F​​P)
    • boolean 類型
    • returnAddress:指令指針
  2. 參考類型:
    • 類類型
    • 數組類型
    • 接口類型

boolean類型在字節碼中的支持有限。例如,沒有直接操作boolean值的指令而是int由編譯器轉換爲布爾值,並使用相應的int指令。

Java開發人員應該熟悉所有上述類型,除了returnAddress沒有等效的編程語言類型。

基於堆棧的架構

字節碼指令集的簡單性很大程度上歸功於Sun設計了基於堆棧的VM架構,而不是基於寄存器的架構。JVM進程使用各種內存組件,但只需要詳細檢查JVM堆棧,以便能夠遵循字節碼指令:

PC寄存器:對於在Java程序中運行的每個線程,PC寄存器存儲當前指令的地址。

JVM堆棧:對於每個線程,分配堆棧,其中存儲局部變量,方法參數和返回值。這是一個顯示3個線程的堆棧的插圖。

jvm_stacks

堆:所有線程共享的內存,以及存儲對象(類實例和數組)。對象釋放由垃圾收集器管理。

heap.png

方法區域:對於每個加載的類,存儲方法的代碼和符號表(例如對字段或方法的引用)和稱爲常量池的常量。

method_area.png

JVM堆棧由框架組成  ,每個框架在調用方法時被壓入堆棧,並在方法完成時從堆棧彈出(通過正常返回或拋出異常)。每個框架還包括:

  1. 一個局部變量數組,索引從0到其長度減1.長度由編譯器計算。局部變量可以保存任何類型的值,除了longdouble它們佔據兩個局部變量的值。
  2. 一個操作數堆棧,用於存儲中間值,這些中間值將充當指令的操作數,或者將參數推送到方法調用。

stack_frame_zoom.png

Bytecode進行了探索

有了JVM內部的概念,我們可以看一下從示例代碼生成的一些基本字節碼示例。Java類文件中的每個方法都有一個代碼段,該代碼段由一系列指令組成,每個指令都具有以下格式:

opcode (1 byte)      operand1 (optional)      operand2 (optional)      ...

這是一個由一個字節的操作碼和零個或多個操作數組成的指令,這些操作數包含要操作的數據。

在當前正在執行的方法的堆棧幀內,指令可以將值推送或彈出到操作數堆棧上,並且它可以潛在地加載或存儲數組局部變量中的值。我們來看一個簡單的例子:

public static void main(String[] args) {
    int a = 1;
    int b = 2;
    int c = a + b;
}

爲了在編譯的類中打印生成的字節碼(假設它在文件中Test.class),我們可以運行該javap工具:

javap -v Test.class

我們得到:

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
...

我們可以看到該方法的方法簽名main,該描述符指示該方法採用Strings([Ljava/lang/String;數組並具有void返回類型(V)。接下來是一組標誌,將方法描述爲public(ACC_PUBLIC)和static(ACC_STATIC)。

最重要的部分是Code屬性,它包含方法的指令以及諸如操作數堆棧的最大深度(在本例中爲2)的信息,以及在此方法的框架中分配的局部變量的數量(4 in這個案例)。除了第一個(在索引0處)保存對args參數的引用之外,所有局部變量都在上面的指令中引用其他3個局部變量對應於變量ab並且c在源代碼中。

地址0到8的指令將執行以下操作:

iconst_1:將整數常量1推入操作數堆棧。

iconst_1.png

istore_1:彈出頂部操作數(一個int值)並將其存儲在索引1的局部變量中,該變量對應於變量a

istore_1.png

iconst_2:將整數常量2推入操作數堆棧。

iconst_2.png

istore_2:彈出頂部操作數int值並將其存儲在索引2的局部變量中,該變量對應於變量b

istore_2.png

iload_1:從索引爲1的局部變量加載int值並將其推送到操作數堆棧。

iload_1.png

iload_2:從索引爲1的局部變量加載int值並將其推送到操作數堆棧。

iload_2.png

iadd:從操作數堆棧中彈出前兩個int值,添加它們並將結果推回操作數堆棧。

我加

istore_3:彈出頂部操作數int值並將其存儲在索引3的局部變量中,該變量對應於變量c

istore_3.png

return:從void方法返回。

上述每條指令都只包含一個操作碼,它完全決定了JVM要執行的操作。

方法調用

在上面的例子中,只有一種方法,主要方法。讓我們假設我們需要對變量的值進行更精細的計算c,並且我們決定將它放在一個名爲的新方法中calc

public static void main(String[] args) {
    int a = 1;
    int b = 2;
    int c = calc(a, b);
}
 
static int calc(int a, int b) {
    return (int) Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}

讓我們看看生成的字節碼:

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: (0x0009) ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=4, args_size=1
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: invokestatic  #2         // Method calc:(II)I
       9: istore_3
      10: return
 
static int calc(int, int);
  descriptor: (II)I
  flags: (0x0008) ACC_STATIC
  Code:
    stack=6, locals=2, args_size=2
       0: iload_0
       1: i2d
       2: ldc2_w        #3         // double 2.0d
       5: invokestatic  #5         // Method java/lang/Math.pow:(DD)D
       8: iload_1
       9: i2d
      10: ldc2_w        #3         // double 2.0d
      13: invokestatic  #5         // Method java/lang/Math.pow:(DD)D
      16: dadd
      17: invokestatic  #6         // Method java/lang/Math.sqrt:(D)D
      20: d2i
      21: ireturn

主方法代碼的唯一區別是iadd,我們現在invokestatic只是調用靜態方法而不是使用指令calc需要注意的關鍵是操作數堆棧包含傳遞給方法的兩個參數calc換句話說,調用方法通過以正確的順序將它們推送到操作數堆棧來準備要調用的方法的所有參數。invokestatic(或稍後將看到的類似的invoke *指令)隨後會彈出這些參數,併爲調用的方法創建一個新的框架,其中參數放在其局部變量數組中。

我們還注意到該invokestatic指令通過查看從6跳到9的地址佔用3個字節。這是因爲與目前爲止看到的所有指令不同,invokestatic包括兩個額外的字節來構造對要調用的方法的引用(除了操作碼)。該引用由javap顯示,因爲#2它是calc從前面描述的常量池中解析方法的符號引用

其他新信息顯然是calc方法本身的代碼它首先將第一個整數參數加載到操作數堆棧(iload_0)上。下一條指令i2d通過應用擴展轉換將其轉換爲double。生成的double替換操作數堆棧的頂部。

下一條指令將double常量2.0d  (取自常量池)推送到操作數堆棧。然後使用Math.pow到目前爲止準備的兩個操作數值(第一個參數calc和常量2.0d調用靜態方法Math.pow方法返回時,其結果將存儲在其調用者的操作數堆棧中。這可以在下面說明。

math_pow.png

應用相同的過程來計算Math.pow(b, 2)

math_pow2.png

下一條指令dadd彈出前兩個中間結果,添加它們並將總和推回到頂部。最後,invokestatic調用Math.sqrt結果總和,並使用narrowing conversion(d2i將結果從double轉換爲int 結果int返回給main方法,後者將其存儲回cistore_3)。

實例創作

讓我們修改示例並引入一個類Point來封裝XY座標。

public class Test {
    public static void main(String[] args) {
        Point a = new Point(1, 1);
        Point b = new Point(5, 3);
        int c = a.area(b);
    }
}
 
class Point {
    int x, y;
 
    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
 
    public int area(Point b) {
        int length = Math.abs(b.y - this.y);
        int width = Math.abs(b.x - this.x);
        return length * width;
    }
}

main方法的編譯字節碼如下所示:

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: (0x0009) ACC_PUBLIC, ACC_STATIC
  Code:
    stack=4, locals=4, args_size=1
       0: new           #2       // class test/Point
       3: dup
       4: iconst_1
       5: iconst_1
       6: invokespecial #3       // Method test/Point."<init>":(II)V
       9: astore_1
      10: new           #2       // class test/Point
      13: dup
      14: iconst_5
      15: iconst_3
      16: invokespecial #3       // Method test/Point."<init>":(II)V
      19: astore_2
      20: aload_1
      21: aload_2
      22: invokevirtual #4       // Method test/Point.area:(Ltest/Point;)I
      25: istore_3
      26: return

這裏遇到的新指令是newdupinvokespecial與編程語言中的new運算符類似,該new指令創建一個傳遞給它的操作數中指定類型的對象(它是對類的符號引用Point)。對象的內存在堆上分配,對象的引用在操作數堆棧上被推送。

dup指令複製了頂部操作數堆棧值,這意味着現在我們有兩個引用Point堆棧頂部對象。接下來的三條指令將構造函數的參數(用於初始化對象)推入操作數堆棧,然後調用一個特殊的初始化方法  ,該方法對應於構造函數該  方法是字段xy初始化的地方。方法結束,三甲操作數堆棧值被消耗,剩下的就是原始參考創建的對象(其是由現在已經成功地初始化)。

init.png

接下來astore_1彈出Point引用並分配給索引1處的局部變量(ain astore_1表示這是一個參考值)。

init_store.png

重複相同的過程以創建和初始化第二個Point實例,該第二個實例被分配給變量b

init2.png

init_store2.png

最後一步從索引1和2的局部變量(分別使用aload_1aload_2加載對兩個Point對象的引用,並調用areausing方法invokevirtual,該方法根據對象的實際類型處理調用適當方法的調用。例如,如果變量a包含SpecialPoint擴展類型的實例Point,並且子類型覆蓋該area方法,則調用overriden方法。在這種情況下,沒有子類,因此只有一種area方法可用。

area.png

請注意,即使該area方法接受一個參數,堆棧頂部也有兩個 Point引用。第一個(pointA  來自變量a)實際上是調用該方法的實例(this在編程語言中也稱爲),它將在該area方法的新幀的第一個局部變量中傳遞另一個操作數value(pointB)是area方法的參數

另一種方式

您不需要掌握對每條指令的理解以及確切的執行流程,以便根據手頭的字節碼瞭解程序的功能。例如,在我的情況下,我想檢查代碼是否使用Java 來讀取文件,以及流是否已正確關閉。現在給出下面的字節碼,可以相對容易地確定確實使用了流,並且很可能它是作爲try-with-resources語句的一部分被關閉的。

public static void main(java.lang.String[]) throws java.lang.Exception;
 descriptor: ([Ljava/lang/String;)V
 flags: (0x0009) ACC_PUBLIC, ACC_STATIC
 Code:
   stack=2, locals=8, args_size=1
      0: ldc           #2                  // class test/Test
      2: ldc           #3                  // String input.txt
      4: invokevirtual #4                  // Method java/lang/Class.getResource:(Ljava/lang/String;)Ljava/net/URL;
      7: invokevirtual #5                  // Method java/net/URL.toURI:()Ljava/net/URI;
     10: invokestatic  #6                  // Method java/nio/file/Paths.get:(Ljava/net/URI;)Ljava/nio/file/Path;
     13: astore_1
     14: new           #7                  // class java/lang/StringBuilder
     17: dup
     18: invokespecial #8                  // Method java/lang/StringBuilder."<init>":()V
     21: astore_2
     22: aload_1
     23: invokestatic  #9                  // Method java/nio/file/Files.lines:(Ljava/nio/file/Path;)Ljava/util/stream/Stream;
     26: astore_3
     27: aconst_null
     28: astore        4
     30: aload_3
     31: aload_2
     32: invokedynamic #10,  0             // InvokeDynamic #0:accept:(Ljava/lang/StringBuilder;)Ljava/util/function/Consumer;
     37: invokeinterface #11,  2           // InterfaceMethod java/util/stream/Stream.forEach:(Ljava/util/function/Consumer;)V
     42: aload_3
     43: ifnull        131
     46: aload         4
     48: ifnull        72
     51: aload_3
     52: invokeinterface #12,  1           // InterfaceMethod java/util/stream/Stream.close:()V
     57: goto          131
     60: astore        5
     62: aload         4
     64: aload         5
     66: invokevirtual #14                 // Method java/lang/Throwable.addSuppressed:(Ljava/lang/Throwable;)V
     69: goto          131
     72: aload_3
     73: invokeinterface #12,  1           // InterfaceMethod java/util/stream/Stream.close:()V
     78: goto          131
     81: astore        5
     83: aload         5
     85: astore        4
     87: aload         5
     89: athrow
     90: astore        6
     92: aload_3
     93: ifnull        128
     96: aload         4
     98: ifnull        122
    101: aload_3
    102: invokeinterface #12,  1           // InterfaceMethod java/util/stream/Stream.close:()V
    107: goto          128
    110: astore        7
    112: aload         4
    114: aload         7
    116: invokevirtual #14                 // Method java/lang/Throwable.addSuppressed:(Ljava/lang/Throwable;)V
    119: goto          128
    122: aload_3
    123: invokeinterface #12,  1           // InterfaceMethod java/util/stream/Stream.close:()V
    128: aload         6
    130: athrow
    131: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;
    134: aload_2
    135: invokevirtual #16                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
    138: invokevirtual #17                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    141: return
   ...

我們看到調用java/util/stream/Streamwhere的出現forEach,之前是對InvokeDynamica的引用的調用Consumer然後我們看到一大塊字節碼調用Stream.close以及調用的分支Throwable.addSuppressed這是編譯器爲try-with-resources語句生成的基本代碼

以下是完整性的原始來源:

public static void main(String[] args) throws Exception {
    Path path = Paths.get(Test.class.getResource("input.txt").toURI());
    StringBuilder data = new StringBuilder();
    try(Stream lines = Files.lines(path)) {
        lines.forEach(line -> data.append(line).append("\n"));
    }
 
    System.out.println(data.toString());
}

結論

由於字節碼指令集的簡單性以及在生成指令時幾乎沒有編譯器優化,反彙編類文件可能是檢查應用程序代碼更改的一種方法,而不需要源代碼,如果需要的話。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章