來自:開源中國 協作翻譯
原文: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定義的資料型別包括:
-
基本型別:
-
數值型別: 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堆疊是由幀組成的,當方法被呼叫時,每個幀都被推到堆疊上,當方法完成時從堆疊中彈出(透過正常傳回或丟擲異常)。每一幀還包括:
-
本地變數陣列,索引從0到它的長度-1。長度是由編譯器計算的。一個區域性變數可以儲存任何型別的值,long和double型別的值佔用兩個區域性變數。
-
用來儲存中間值的棧,它儲存指令的運算元,或者方法呼叫的引數。
位元組碼探索
關於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、資料庫、運維等。