Java字節碼的介紹

即便對那些有經驗的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工具:

javap -v Test.class

我們可以得到如下結果:

我們可以看到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放入操作數棧。

iconst_1.png

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

istore_1.png
iconst_2:將整形常量2放入操作數棧。

iconst_2.png
istore_2:在索引爲2的位置將第一個操作數出棧並且將其存進本地變量,相當於變量b。

istore_2.png
iload_1:從索引1的本地變量中加載一個int值,放入操作數棧。

iload_1.png
iload_2:從索引2的本地變量中加載一個int值,放入操作數棧。

iload_2.png
iadd:把操作數棧中的前兩個int值出棧並相加,將相加的結果放入操作數棧。

iadd
istore_3:在索引爲3的位置將第一個操作數出棧並且將其存進本地變量,相當於變量c。

istore_3.png
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.png

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

math_pow2.png

下一條指令,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表明這是一個引用值).

init_store.png

通用的過程會被重複執行以創建並初始化第二個Point實例,此實例會被賦值給變量b。

init2.png

init_store2.png

最後一步是將本地變量中的兩個Point對象的引用加載到索引1和2中(分別使用aload_1和aload_2),並使用invokevirtual調用area方法,該方法會根據實際的類型來調用適當的方法來完成分發。例如,如果變量a包含一個擴展自Point類的SpecialPoint實例,並且該子類重寫了area方法,則重寫後的方法會被調用。在這種情況下,並不存在子類,因此僅有area方法是可用的。

area.png

請注意,即使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."<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/Stream執行forEach之前,首先觸發InvokeDynamic以引用Consumer。與此同時會發現大量調用Stream.close與Throwable.addSuppressed的字節碼,這是編譯器實現try-with-resources statement的基本代碼。

這是完整的原始代碼。

總結

還好字節碼指令集簡潔,生成指令時幾乎少有的編譯器優化,反編譯類文件可以在沒有源碼的情況下檢查代碼,當然如沒有源碼這也是一種需求!



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