歡迎光臨
每天分享高質量文章

圖文詳解 Java 位元組碼,想不懂都難!

來自:開源中國 協作翻譯

原文:Introduction to Java Bytecode

連結:https://dzone.com/articles/introduction-to-java-bytecode

譯者:dreamanzhao, 雪落無痕xdj, kevinlinkai, Tocy, 邊城, 涼涼_, 無若, imqipan, Tot_ziens

即便對那些有經驗的Java開發人員來說,閱讀已編譯的Java位元組碼也很乏味。為什麼我們首先需要瞭解這種底層的東西?這是上週發生在我身上的一個簡單故事:很久以前,我在機器上做了一些程式碼更改,編譯了一個JAR,並將其部署到伺服器上,以測試效能問題的一個潛在修複方案。不幸的是,程式碼從未被檢入到版本控制系統中,並且出於某種原因,本地更改被刪除了而沒有追蹤。幾個月後,我再次修改原始碼,但是我找不到上一次更改的版本!


幸運的是編譯後的程式碼仍然存在於該遠端伺服器上。我於是鬆了一口氣,我再次抓取JAR並使用反編譯器編輯器開啟它……只有一個問題:反編譯器GUI不是一個完美的工具,並且出於某種原因,在該JAR中的許多類中找到我想要反編譯的特定類併在我開啟它時會在UI中導致了一個錯誤,並且反編譯器崩潰!


絕望的時候需要採取孤註一擲的措施。幸運的是,我對原始位元組碼很熟悉,我寧願花些時間手動地對一些程式碼進行反編譯,而不是透過不斷的更改和測試它們。因為我仍然記得在哪裡可以檢視程式碼,所以閱讀位元組碼幫助我精確地確定了具體的變化,並以原始碼形式構建它們。(我一定要從我的錯誤中吸取教訓,這次要珍惜好這些教訓!)

位元組碼的好處是,您可以只用學習它的語法一次,然後它適用於所有Java支援的平臺——因為它是程式碼的中間表示,而不是底層CPU的實際可執行程式碼。此外,位元組碼比本機程式碼更簡單,因為JVM架構相當簡單,因此簡化了指令集,另一件好事是,這個集合中的所有指令都是由Oracle提供完整的檔案。


不過,在學習位元組碼指令集之前,讓我們熟悉一下JVM的一些事情,這是進行下一步的先決條件。


JVM 資料型別


Java是靜態型別的,它會影響位元組碼指令的設計,這樣指令就會期望自己對特定型別的值進行操作。例如,就會有好幾個add指令用於兩個數字相加:iadd、ladd、fadd、dadd。他們期望型別的運算元分別是int、long、float和double。大多數位元組碼都有這樣的特性,它具有不同形式的相同功能,這取決於運算元型別。


JVM定義的資料型別包括:


  1. 基本型別:

  • 數值型別: byte (8位), short (16位), int (32位), long (64-bit位), char (16位無符號Unicode), float(32-bit IEEE 754 單精度浮點型), double (64-bit IEEE 754 雙精度浮點型)

  • 布林型別

  • 指標型別: 指令指標。

  • 取用型別:

    • 陣列

    • 介面


    在位元組碼中布林型別的支援是受限的。舉例來說,沒有結構能直接操作布林值。布林值被替換轉換成 int 是透過編譯器來進行的,並且最終還是被轉換成 int 結構。


    Java 開發者應該熟悉所有上面的型別,除了 returnAddress,它沒有等價的程式語言型別。


    基於棧的架構


    位元組碼指令集的簡單性很大程度上是由於 Sun 設計了基於堆疊的 VM 架構,而不是基於暫存器架構。有各種各樣的行程使用基於JVM 的記憶體元件, 但基本上只有 JVM 堆需要詳細檢查位元組碼指令:


    PC暫存器:對於Java程式中每個正在執行的執行緒,都有一個PC暫存器儲存著當前執行的指令地址。


    JVM 棧:對於每個執行緒,都會分配一個棧,其中存放本地變數、方法引數和傳回值。下麵是一個顯示3個執行緒的堆疊示例。


    :所有執行緒共享的記憶體和儲存物件(類實體和陣列)。物件回收是由垃圾收集器管理的。


    方法區:對於每個已載入的類,它儲存方法的程式碼和一個符號表(例如對欄位或方法的取用)和常量池。


    JVM堆疊是由幀組成的,當方法被呼叫時,每個幀都被推到堆疊上,當方法完成時從堆疊中彈出(透過正常傳回或丟擲異常)。每一幀還包括:


    1. 本地變數陣列,索引從0到它的長度-1。長度是由編譯器計算的。一個區域性變數可以儲存任何型別的值,long和double型別的值佔用兩個區域性變數。

    2. 用來儲存中間值的棧,它儲存指令的運算元,或者方法呼叫的引數。


    位元組碼探索


    關於JVM內部的看法,我們能夠從示例程式碼中看到一些被生成的基本位元組碼例子。Java類檔案中的每個方法都有程式碼段,這些程式碼段包含了一系列的指令,格式如下:


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


    這個指令是由一個一位元組的opcode和零個或若干個operand組成的,這個operand包含了要被操作的資料。


    在當前執行方法的棧幀裡,一條指令可以將值在操作棧中入棧或出棧,可以在本地變數陣列中悄悄地載入或者儲存值。讓我們來看一個例子:




    為了列印被編譯的類中的結果位元組碼(假設在Test.class檔案中),我們執行javap工具:


    我們可以得到如下結果:



    我們可以看到main方法的方法宣告,descriptor說明這個方法的引數是一個字串陣列([Ljava/lang/String; ),而且傳回型別是void(V)。下麵的flags這行說明該方法是公開的(ACC_PUBLIC)和靜態的 (ACC_STATIC)。


    Code屬性是最重要的部分,它包含了這個方法的一系列指令和資訊,這些資訊包含了操作棧的最大深度(本例中是2)和在這個方法的這一幀中被分配的本地變數的數量(本例中是4)。所有的本地變數在上面的指令中都提到了,除了第一個變數(索引為0),這個變數儲存的是args引數。其他三個本地變數就相當於原始碼中的a,b和c。


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


    iconst_1:將整形常量1放入運算元棧。


    istore_1:在索引為1的位置將第一個運算元出棧(一個int值)並且將其存進本地變數,相當於變數a。




    iconst_2:將整形常量2放入運算元棧。



    istore_2:在索引為2的位置將第一個運算元出棧並且將其存進本地變數,相當於變數b。




    iload_1:從索引1的本地變數中載入一個int值,放入運算元棧。




    iload_2:從索引2的本地變數中載入一個int值,放入運算元棧。



    iadd:把運算元棧中的前兩個int值出棧並相加,將相加的結果放入運算元棧。




    istore_3:在索引為3的位置將第一個運算元出棧並且將其存進本地變數,相當於變數c。




    return:從這個void方法中傳回。


    上述指令只包含操作碼,由JVM來精確執行。


    方法呼叫


    上面的示例只有一個方法,即 main 方法。假如我們需要對變數 c 進行更複雜的計算,這些複雜的計算寫在新方法 calc 中:



    看看生成的位元組碼:



    main 方法程式碼唯一的不同在於用 invokestatic 指令代替了 iadd 指令,invokestatic 指令用於呼叫靜態方法 calc。註意,關鍵在於運算元棧中傳遞給 calc 方法的兩個引數。也就是說,呼叫方法需要按正確的順序為被呼叫方法準備好所有引數,交依次推入運算元棧。iinvokestatic(還有後面提到的其它類似的呼叫指令)隨後會從棧中取出這些引數,然後為被呼叫方法建立一個新的環境,將引數作為局域變數置於其中。


    我們也註意到invokestatic指令在地址上看佔據了3位元組,由6跳轉到9。不像其餘指令那樣那麼遠,這是因為invokestatic指令包含了兩個額外的位元組來構造要呼叫的方法的取用(除了opcode外)。這取用由javap顯示為#2,是一個取用calc方法的符號,解析於從前面描述的常量池中。


    其它的新資訊顯然是calc方法本身的程式碼。它首先將第一個整數引數載入到運算元堆疊上(iload_0)。下一條指令,i2d,透過應用擴充套件轉換將其轉換為double型別。由此產生的double型別取代了運算元堆疊的頂部。


    再下一條指令將一個double型別常量2.0d(從常量池中取出)推到運算元堆疊上。然後靜態方法Math.pow呼叫目前為止準備好的兩個運算元值(第一個引數是calc和常量2.0d)。當Math.pow方法傳回時,他的結果將會被儲存在其呼叫程式的運算元堆疊上。在下麵說明。



    同樣的程式應用於計算Math.pow(b,2):



    下一條指令,dadd,會將棧頂的兩個中間結果出棧,將它們相加,並將所得之和推入棧頂。最後,invokestatic 對這個和值呼叫 Math.sqrt,將結果從 double(雙精度浮點型) 窄化轉換(d2i)成 int(整型)。整型結果會傳回到 main 方法中, 併在這裡儲存到 c(istore_3)。


    建立實體


    現在修改這個示例,加入 Point 類來封裝 XY 坐標。



    編譯後的 main 方法的字型碼如下:



    這裡引入了 new、dup 和 invokespecial 幾個新指令。new 指令與程式語言中的 new 運運算元類似,它根據傳入的運算元所指定型別來建立物件(這是對 Point 類的符號取用)。物件的記憶體是在堆上分配,物件取用則是被推入到運算元棧上。


    dup指令會複製頂部運算元的棧值,這意味著現在我們在棧頂部有兩個指向Point物件的取用。接下來的三條指令將建構式的引數(用於初始化物件)壓入運算元堆疊中,然後呼叫與建構式對應的特殊初始化方法。下一個方法中x和y欄位將被初始化。該方法完成之後,前三個運算元的棧值將被銷毀,剩下的就是已建立物件的原始取用(到目前為止,已成功完成初始化了)。


    接下來,astore_1將該Point取用出棧,並將其賦值到索引1所儲存的本地變數(astore_1中的a表明這是一個取用值).



    通用的過程會被重覆執行以建立並初始化第二個Point實體,此實體會被賦值給變數b。



    最後一步是將本地變數中的兩個Point物件的取用載入到索引1和2中(分別使用aload_1和aload_2),並使用invokevirtual呼叫area方法,該方法會根據實際的型別來呼叫適當的方法來完成分發。


    例如,如果變數a包含一個擴充套件自Point類的SpecialPoint實體,並且該子類重寫了area方法,則重寫後的方法會被呼叫。在這種情況下,並不存在子類,因此僅有area方法是可用的。



    請註意,即使area方法接受單引數,堆疊頂部也有兩個Point的取用。第一個(pointA,來自變數a)實際上是呼叫該方法的實體(在程式語言中被稱為this),對area方法來說,它將被傳遞到新棧幀的第一個區域性變數中。另一個運算元(pointB)是area方法的引數。


    另一種方式


    你無需對每條指令的理解和執行的準確流程完全掌握,以根據手頭的位元組碼瞭解程式的功能。例如,就我而言,我想檢查程式碼是否驅動Java stream來讀取檔案,以及流是否被正確地關閉。現在以下麵的位元組碼為例,確認以下情況是很簡單的:一個流是否被使用並且很有可能是作為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.”“:()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/Stream執行forEach之前,首先觸發InvokeDynamic以取用Consumer。與此同時會發現大量呼叫Stream.close與Throwable.addSuppressed的位元組碼,這是編譯器實現try-with-resources statement的基本程式碼。


    這是完整的原始程式碼。


    總結


    還好位元組碼指令集簡潔,生成指令時幾乎少有的編譯器最佳化,反編譯類檔案可以在沒有原始碼的情況下檢查程式碼,當然如沒有原始碼這也是一種需求!


    編號664,輸入編號直達本文

    ●輸入m獲取文章目錄

    推薦↓↓↓

    Web開發

    更多推薦18個技術類微信公眾號

    涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

    贊(0)

    分享創造快樂