Java字節簡介
即使對於有經驗的Java開發人員來說,讀取編譯的Java字節碼也很繁瑣。爲什麼我們首先需要了解這種低級別的東西?這是上週發生在我身上的一個簡單場景:我很久以前在我的機器上進行了一些代碼更改,編譯了一個Jar並將其部署在服務器上以測試針對性能問題的潛在修復。遺憾的是,代碼從未簽入版本控制系統,無論出於何種原因,本地更改都被刪除而沒有跟蹤。幾個月後,我再次需要源代碼形式的更改(這需要付出相當大的努力),但找不到它們!
幸運的是,編譯後的代碼仍然存在於該遠程服務器上。於是鬆了一口氣,我再次取出Jar並使用反編譯器編輯器打開它。只有一個問題,反編譯器GUI不是一個完美的工具,並且在那個Jar中的許多類中,由於某種原因,只有我想要反編譯的特定類導致UI中的錯誤在我打開它時被執行並且反編譯器崩潰了!
絕望的時候需要絕望的措施...幸運的是我熟悉原始字節碼,我寧願花一些時間手動反編譯一些代碼,而不是通過更改並再次測試它們。因爲我至少還記得在代碼中查找的地方,閱讀字節碼幫助我查明確切的更改並以源代碼形式重新構建它們。(我確保從錯誤中吸取教訓並保留它們!)
關於字節碼的好處是你學習它的語法一次並且它適用於所有Java支持的平臺,因爲它是代碼的中間表示,而不是底層CPU的實際可執行代碼。此外,字節碼比本機機器碼簡單,因爲JVM架構相當簡單,因此簡化了指令集。另一個好處是Oracle 指令集中完整記錄了該指令集中的所有指令。
在學習字節碼指令集之前,讓我們先熟悉一下JVM的一些必要條件。
JVM數據類型
Java是靜態類型的,它影響字節碼指令的設計,使得指令期望自己對特定類型的值進行操作。例如,有一些附加說明添加兩個數字:iadd
,ladd
,fadd
,dadd
。他們期望類型的操作數分別爲int,long,float和double。大多數字節碼具有這樣的特徵:具有相同功能的不同形式但是根據操作數類型而不同。
JVM定義的數據類型是:
- 原始類型:
- 數字類型:
byte
(8位2的補碼),short
(16位2的補碼),int
(32位2的補碼),long
(64位2的補碼),char
(16位無符號Unicode),float
(32位IEEE 754單)精度FP),double
(64位IEEE 754雙精度FP) boolean
類型returnAddress
:指令指針
- 數字類型:
- 參考類型:
- 類類型
- 數組類型
- 接口類型
該boolean
類型在字節碼中的支持有限。例如,沒有直接操作boolean
值的指令。而是int
由編譯器轉換爲布爾值,並使用相應的int
指令。
Java開發人員應該熟悉所有上述類型,除了returnAddress
沒有等效的編程語言類型。
基於堆棧的架構
字節碼指令集的簡單性很大程度上歸功於Sun設計了基於堆棧的VM架構,而不是基於寄存器的架構。JVM進程使用各種內存組件,但只需要詳細檢查JVM堆棧,以便能夠遵循字節碼指令:
PC寄存器:對於在Java程序中運行的每個線程,PC寄存器存儲當前指令的地址。
JVM堆棧:對於每個線程,分配堆棧,其中存儲局部變量,方法參數和返回值。這是一個顯示3個線程的堆棧的插圖。
堆:所有線程共享的內存,以及存儲對象(類實例和數組)。對象釋放由垃圾收集器管理。
方法區域:對於每個加載的類,存儲方法的代碼和符號表(例如對字段或方法的引用)和稱爲常量池的常量。
JVM堆棧由框架組成 ,每個框架在調用方法時被壓入堆棧,並在方法完成時從堆棧彈出(通過正常返回或拋出異常)。每個框架還包括:
- 一個局部變量數組,索引從0到其長度減1.長度由編譯器計算。局部變量可以保存任何類型的值,除了
long
和double
它們佔據兩個局部變量的值。 - 一個操作數堆棧,用於存儲中間值,這些中間值將充當指令的操作數,或者將參數推送到方法調用。
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個局部變量對應於變量a
,b
並且c
在源代碼中。
地址0到8的指令將執行以下操作:
iconst_1
:將整數常量1推入操作數堆棧。
istore_1
:彈出頂部操作數(一個int值)並將其存儲在索引1的局部變量中,該變量對應於變量a
。
iconst_2
:將整數常量2推入操作數堆棧。
istore_2
:彈出頂部操作數int值並將其存儲在索引2的局部變量中,該變量對應於變量b
。
iload_1
:從索引爲1的局部變量加載int值並將其推送到操作數堆棧。
iload_2
:從索引爲1的局部變量加載int值並將其推送到操作數堆棧。
iadd
:從操作數堆棧中彈出前兩個int值,添加它們並將結果推回操作數堆棧。
istore_3
:彈出頂部操作數int值並將其存儲在索引3的局部變量中,該變量對應於變量c
。
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(b, 2)
:
下一條指令dadd彈出前兩個中間結果,添加它們並將總和推回到頂部。最後,invokestatic調用Math.sqrt
結果總和,並使用narrowing conversion(d2i
)將結果從double轉換爲int 。結果int返回給main方法,後者將其存儲回c
(istore_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 |
這裏遇到的新指令是new
,dup
和invokespecial
。與編程語言中的new運算符類似,該new
指令創建一個傳遞給它的操作數中指定類型的對象(它是對類的符號引用Point
)。對象的內存在堆上分配,對象的引用在操作數堆棧上被推送。
該dup
指令複製了頂部操作數堆棧值,這意味着現在我們有兩個引用Point
堆棧頂部的對象。接下來的三條指令將構造函數的參數(用於初始化對象)推入操作數堆棧,然後調用一個特殊的初始化方法 ,該方法對應於構造函數。該
方法是字段
x
和y
初始化的地方。後方法結束,三甲操作數堆棧值被消耗,剩下的就是原始參考創建的對象(其是由現在已經成功地初始化)。
接下來astore_1
彈出Point
引用並分配給索引1處的局部變量(a
in astore_1
表示這是一個參考值)。
重複相同的過程以創建和初始化第二個Point
實例,該第二個實例被分配給變量b
。
最後一步從索引1和2的局部變量(分別使用aload_1
和aload_2
)加載對兩個Point對象的引用,並調用area
using方法invokevirtual
,該方法根據對象的實際類型處理調用適當方法的調用。例如,如果變量a
包含SpecialPoint
擴展類型的實例Point
,並且子類型覆蓋該area
方法,則調用overriden方法。在這種情況下,沒有子類,因此只有一種area
方法可用。
請注意,即使該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/Stream
where的出現forEach
,之前是對InvokeDynamic
a的引用的調用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()); } |
結論
由於字節碼指令集的簡單性以及在生成指令時幾乎沒有編譯器優化,反彙編類文件可能是檢查應用程序代碼更改的一種方法,而不需要源代碼,如果需要的話。