即使對那些有經驗的Java開發人員來講,閱讀已編譯的Java字節碼也很乏味。爲何咱們首先須要瞭解這種底層的東西?這是上週發生在我身上的一個簡單故事:好久之前,我在機器上作了一些代碼更改,編譯了一個JAR,並將其部署到服務器上,以測試性能問題的一個潛在修復方案。不幸的是,代碼從未被檢入到版本控制系統中,而且出於某種緣由,本地更改被刪除了而沒有追蹤。幾個月後,我再次修改源代碼,可是我找不到上一次更改的版本!
html
幸運的是編譯後的代碼仍然存在於該遠程服務器上。我因而鬆了一口氣,我再次抓取JAR並使用反編譯器編輯器打開它......只有一個問題:反編譯器GUI不是一個完美的工具,而且出於某種緣由,在該JAR中的許多類中找到我想要反編譯的特定類並在我打開它時會在UI中致使了一個錯誤,而且反編譯器崩潰!java
絕望的時候須要採起背注一擲的措施。幸運的是,我對原始字節碼很熟悉,我寧願花些時間手動地對一些代碼進行反編譯,而不是經過不斷的更改和測試它們。由於我仍然記得在哪裏能夠查看代碼,因此閱讀字節碼幫助我精確地肯定了具體的變化,並以源代碼形式構建它們。(我必定要從個人錯誤中吸收教訓,此次要珍惜好這些教訓!)編程
字節碼的好處是,您能夠只用學習它的語法一次,而後它適用於全部Java支持的平臺——由於它是代碼的中間表示,而不是底層CPU的實際可執行代碼。此外,字節碼比本機代碼更簡單,由於JVM架構至關簡單,所以簡化了指令集,另外一件好事是,這個集合中的全部指令都是由Oracle提供完整的文檔。api
不過,在學習字節碼指令集以前,讓咱們熟悉一下JVM的一些事情,這是進行下一步的先決條件。數組
Java是靜態類型的,它會影響字節碼指令的設計,這樣指令就會指望本身對特定類型的值進行操做。例如,就會有好幾個add指令用於兩個數字相加:iadd、ladd、fadd、dadd。他們指望類型的操做數分別是int、long、float和double。大多數字節碼都有這樣的特性,它具備不一樣形式的相同功能,這取決於操做數類型。bash
JVM定義的數據類型包括:服務器
基本類型:架構
數值類型: byte (8位), short (16位), int (32位), long (64-bit位), char (16位無符號Unicode), float(32-bit IEEE 754 單精度浮點型), double (64-bit IEEE 754 雙精度浮點型)oracle
布爾類型jvm
指針類型: 指令指針。
引用類型:
類
數組
接口
在字節碼中布爾類型的支持是受限的。舉例來講,沒有結構能直接操做布爾值。布爾值被替換轉換成 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工具:
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放入操做數棧。
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."<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的基本代碼。
這是完整的原始代碼。
還好字節碼指令集簡潔,生成指令時幾乎少有的編譯器優化,反編譯類文件能夠在沒有源碼的狀況下檢查代碼,固然如沒有源碼這也是一種需求!
原文:Introduction to Java Bytecode
譯文:Java 字節碼的介紹
譯者:dreamanzhao, 雪落無痕xdj, kevinlinkai, Tocy, 邊城, 涼涼_, 無若, imqipan, Tot_ziens