讀古今文學網 > Java程序員修煉之道 > 5.4 字節碼 >

5.4 字節碼

到目前為止,在我們的討論中,字節碼一直有點幕後工作者的意思。我們先來回顧一下對它已經有了哪些瞭解,然後再對它進行詳細介紹:

  • 字節碼是程序的中間表示形式:介於人類可讀的源碼和機器碼之間。

  • 字節碼是通過javac處理源碼文件產生的。

  • 某些高層語言特性在編譯時已經從字節碼中去掉了。比如說Java的循環結構(forwhile等)在字節碼中就被轉換成了分支指令。

  • 每個操作碼都由一個字節表示(因此被叫做字節碼)。

  • 字節碼是一種抽像表示法,不是「某種虛擬CPU的機器碼」。

  • 字節碼可以進一步編譯成機器碼,通常是「即時編譯」。

字節碼解釋起來有點像先有雞還是先有蛋的問題。要徹底搞清楚狀況,你既要懂字節碼,又要明白執行它的運行時環境。

這是一個循環依賴,為了解決這個問題,我們先來探索一個相對簡單的例子。即使這一次你不太明白,也可以在後續章節讀到更多字節碼相關內容時再回來看看。

在例子之後,我們會給出一些與運行時環境相關的上下文和JVM操作碼的目錄(其中包括用於數學計算、調用、快捷形式之類的字節碼)。最後,我們會用另外一個基於字符串拼接的例子來結束。現在就先去看看如何檢查.class文件的字節碼吧。

5.4.1 示例:反編譯類

用帶有-c選項的javap可以對類進行反編譯。我們會以代碼清單5-5中的演算類為例,主要檢查方法之內的字節碼。我們還會加上-p選項,以便能見到私有方法內的字節碼。

我們一節一節的來——javap輸出的每一部分都有很多信息,很容易讓人不堪重負。首先,讓我們先看頭部。這裡沒什麼特別出人意料或讓人喜出望外的:

$ javap -c -p wgjd/ch04/ScratchImpl.class
Compiled from "ScratchImpl.java"
public class wgjd.ch04.ScratchImpl extends java.lang.Object {
  private static wgjd.ch04.ScratchImpl inst;
  

接下來是靜態塊。變量的初始化就放在這裡,所以這表示inst被初始化為null了。看起來putstatic可能是一個把值放到靜態域中的字節碼。

static {};
  Code:
     0: aconst_null
     1: putstatic #10 // Field inst:Lwgjd/ch04/ScratchImpl;
     4: return
  

代碼前面的數字表示從方法開始算起的字節碼偏移量。所以字節1是putstatic操作碼,字節2和3表示一個16位的常量池索引,這個16位索引在這裡的值是10,表示該值(此處為null)會存在常量池的條目#10所指明的域中。從字節碼流開始的第4個字節是return操作符,表明這個代碼塊結束了。

接下來是構造方法。

private wgjd.ch04.ScratchImpl;
  Code:
     0: aload_0
     1: invokespecial #15 // Method java/lang/Object."<init>":V
     4: return
  

在Java中,void構造方法總會隱式調用超類中的構造方法。這從上面的字節碼裡就能看出來invokespecial指令。一般來說,任何方法調用都會轉換成VM的某一調用指令。

run方法中沒有代碼,因為這只是一個空白的演算類。

private void run;
  Code:
     0: return
  

main方法中,你初始化了inst,還做了點對像創建。這說明了辨識通用字節碼的基本模式:

public static void main(java.lang.String);
  Code:
     0: new #1                // class wgjd/ch04/ScratchImpl
     3: dup
     4: invokespecial #21    // Method "<init>":V
  

這種3個字節碼指令的模式——newdup和一個<init>invokespecial——都表示創建新實例。

操作碼new只為新實例分配內存。dup複製棧頂上的元素。要完整創建該對象,你需要調用構造方法的代碼塊。<init>方法中包含構造方法的代碼,所以可以用invokespecial調用那段代碼。我們繼續看main方法中其餘的字節碼:

  7: putstatic      #10         // Field inst:Lwgjd/ch04/ScratchImpl;
  10: getstatic     #10         // Field inst:Lwgjd/ch04/ScratchImpl;
  13: invokespecial #22         // Method run:V
  16: return
}
  

指令7保存剛剛創建的單例實例。指令10把它放回到棧頂上,這樣指令13就可以調用它上面的方法了。注意,因為調用的run是私有方法,所以13是invokespecial。私有方法不能重寫,所以不能用Java的標準虛擬查詢。大多數方法調用都會轉換成invokevirtual指令。

注意 通常來說,javac產生的字節碼沒有經過特別優化,是非常簡單的表示形式。基本策略是由JIT編譯器來完成大部分的優化工作,所以簡單直白的起點對它們是很有幫助的。VM實現者表示,「字節碼就應該傻傻的」,這是他們對從源語言產生的字節碼的總體感覺。

接下來我們要討論字節碼所需的運行時環境,之後會介紹用來描述字節碼指令主要「家庭成員」的表格,其中包括加載/存儲,數學計算,執行控制,方法調用和平台操作。然後我們會討論操作碼可能的快捷形式,最後會再給出一個例子。

5.4.2 運行時環境

因為JVM使用堆棧機,所以理解堆棧機的操作對理解字節碼至關重要。

圖5-4 將棧用於數學運算

JVM與硬件CPU(比如x64或ARM芯片)最顯著的差別在於它沒有處理器寄存器,而是用棧完成所有的計算和操作。有時候也這也被稱為操作數棧(或計算堆棧)。圖5-4展示了如何用操作數棧完成兩個int數值的相加運算。

正如我們前面討論過的,當一個類被鏈接進運行時環境時,它的字節碼會受到檢查,並且其中很多驗證都可以歸結為對棧中類型模式的分析。

注意 棧中的值只有類型正確時對它的處理才能生效。比如,如果我們把對一個對象的引用壓入棧,然後試圖將其作為int型進行數學計算,就可能會發生未定義或糟糕的事情。類加載過程中的驗證階段會進行廣泛的檢查,以確保新加載的類中不會有濫用棧的方法。這樣做能夠防止系統接受了損壞(或惡意)的類並引發問題。

方法在運行時需要一塊內存區域作為計算堆棧來計算新值。另外,每個運行的線程都需要一個調用堆棧(棧跟蹤中會報告的那個棧)來記錄當前正在執行的方法。在某些情況下,這兩個棧會有交互。看下面這行代碼:

return 3 + petRecords.getNumberOfPets("Ben");
  

要計算出這行代碼的結果,需要把3壓入操作數棧。然後調用方法計算Ben有多少只寵物。為此,你需要把接收對像(方法屬主,即petRecords)壓入計算堆棧,要傳入的所有參數尾隨其後。

然後invoke操作符會調用方法getNumberOfPets,把控制權移交給被調用的方法,剛剛進入的方法會出現在調用堆棧中。但進入新方法後,需要啟用不同的操作數棧,所以已經在調用者的操作數棧中的值不可能影響被調用方法的計算結果。

getNumberOfPets完成時,返回結果會被放到調用者的操作數棧中,進程中與getNumberOfPets相關的部分也會從調用堆棧中移走。然後相加運算可以得到兩個值並把它們加在一起。

現在我們開始審視字節碼。這是個大課題,而且有很多特殊情況,所以我們即將呈現的只是主要特性的概覽,而不是完整的介紹。

5.4.3 操作碼介紹

JVM字節碼由操作碼(opcode)序列構成,每個指令後面可能會跟著一些參數。操作碼希望看到棧處於指定狀態中,然後它對棧進行轉換,把參數移走,放入結果。

每個操作碼都由一個單字節值表示,所有最多只能有255個操作碼。當前僅用了200個左右。對我們來說,把它們全列出來有點兒太多了,好在大多數操作碼都可以歸為幾大族系。我們會逐一對這些族系進行討論,幫助你理解它們。還有一些操作碼不好界定應該歸為哪一族系,但好在你不會經常遇見它們。

注意 JVM不是純粹的面向對像運行時環境——它支持原始類型。這在某些操作碼族系中有所體現——其中一些基本操作碼類型(比如存儲和相加)要有一些變體,在處理原始類型時會有所不同。

操作碼表有四列:

  • 名稱:這是操作碼類型的通用名稱。大多數情況下,都會有幾個相關的操作碼在做類似的事情。

  • 參數:操作碼的參數。以i打頭的參數是用來作為常量池或局部變量中的查詢索引的幾個字節。如果有更多的此類參數,它們會合併在一起,所以i1i2表示「從這兩個字節中生成一個16位的索引」。如果參數出現在括號裡,就表明不是所有形式的操作碼都會使用它。

  • 堆棧佈局:它展示了棧在操作碼執行前後的狀態。括號中的元素表明不是所有形式的操作碼都使用它們,或者這些元素是可選的(比如調用操作碼)。

  • 描述:操作碼的用處。

我們從表5-4中拿過來一行代碼做例子,檢查一下操作碼getfield的條目。這個操作碼用於從對象的域中讀出一個值。

getfield i1, i2 [obj] → [val]        從棧頂端對象的常量池中取出指定位置的域。
  

第一列給出了操作碼的名字:getfield。後面一列說明在字節碼流中有兩個參數跟在操作碼後面。這些參數合在一起構成一個16位的值,可以用來從常量池裡找到想要的域(記住常量池的索引總是16位的)。

堆棧佈局那一列表明在找到棧頂端對象的類的常量池中的索引位置之後,該對像被移除,它的位置被那個域的值所替代。

這種把移走對像作為操作一部分的模式是一種讓字節碼變得緊湊的辦法,沒有繁瑣的清理工作,也不用記著要挪走處理完的對象實例。

5.4.4 加載和儲存操作碼

加載和儲存操作碼這個族系負責將值加載到棧或檢索值。表5-4給出了加載/儲存族系的主要操作。

表5-4 加載和儲存操作碼

名稱 參數 堆棧佈局 描述 load (i1) → [val]從局部變量加載值(原始型或引用型)到棧上。有快捷形式,並且有針對不同類型的變體 ldc i1 → [val] 從池中加載常量到棧上,針對不同類型有不同的變體,並且範圍廣泛 store (i1) [val] → 把值(原始型或引用型)從進程的棧中移走,存到局部變量中。有快捷形式,有針對不同類型的變體 dup [val] → [val, val] 複製棧頂部的值,有不同形式的變體 getfield i1, i2 [obj] → [val] 從棧頂部對象的常量池中得到指定位置的域 putfieldi1, i2[obj,val] → 把值放入對像在常量池中指定位置的域上

前面提過,加載和儲存指令有很多不同形式的變體。比如用來把雙精度數從局部變量加載到棧上的dload操作碼,以及用來把對像引用從棧彈出到局部變量中的astore操作碼。

5.4.5 數學運算操作碼

這些操作符在棧上執行數學運算。它們從棧頂端取出參數並進行計算。這些參數(總是原始型)必須完全匹配,但平台提供了很多對原始型進行類型轉換的操作碼。表5-5給出了基本的數學運算操作碼。

類型轉換(cast)操作碼的名稱非常短,比如i2d是把int轉為double的操作碼。需要特別說明的是,類型轉換操作碼中並沒有cast,所以在表5-5中用括號把它括了起來。

表5-5 數學運算操作碼

名稱參數 堆棧佈局 描述 add [val1, val2] → [res] 把棧頂端的兩個值相加(必須是相同的原始類型),並把結果存在棧中。有快捷形式,有針對不同類型的變體 sub [val1, val2] → [res] 把棧頂端的兩個值相減(必須是相同的原始類型),並把結果存在棧中。有快捷形式,有針對不同類型的變體 p [val1, val2] → [res] 把棧頂端的兩個值相除(必須是相同的原始類型),並把結果存在棧中。有快捷形式,有針對不同類型的變體 mul [val1, val2] → [res] 把棧頂端的兩個值相乘(必須是相同的原始類型),並把結果存在棧中。有快捷形式,有針對不同類型的變體 (cast) [value] → [res] 把值從一種原始類型轉換為另外一種。每一種可能的類型轉換都有對應的形式

5.4.6 執行控制操作碼

如前所述,高級語言的控制結構在JVM字節碼中沒有出現。相反,流程控制是由很少的幾個原始指令完成的,如表5-6所示。

表5-6 流程控制操作碼

名稱 參數 堆棧佈局 描述 if b1, b2 [val1, val2] → [val1] → 如果符合特定條件,則跳轉到特定分支的偏移處 goto b1, b2無條件地跳轉到分支偏移處。有寬大形式 jsr b1, b2 → [ret] 跳到本地子流程中,並把返回地址(下一個操作碼的偏移地址)放到棧中。有寬大形式 ret 索引 返回到索引的局部變量所指向的偏移地址 tableswitch {依情況而定} [index] → 用於實現switch lookupswitch {依情況而定} [key] → 用於實現switch

就像用於查找常量的索引字節,參數b1b2用於構造方法內部的字節碼跳轉地址。jsr指令用於訪問主流程之外一個自成體系的字節碼區域(偏移地址可能在方法的主字節碼之外)。在某些情況下,比如在異常處理塊中,可能會用到它。

gotojsr指令的寬大形式要用4個字節的參數,並且所構造的偏移量大於64 KB。但這並不常用。

5.4.7 調用操作碼

調用操作碼中有四個操作碼可以處理普通的方法調用,還有一個Java 7中新出的特別操作碼invokedynamic(5.5節有更多細節)。這五個方法調用操作碼如表5-7所示。

表5-7 調用操作碼

名稱參數 堆棧佈局 描述 invokestatici1, i2[(val1, ...)] → 調用一個靜態方法 invokevirtuali1, i2[obj, (val1, ...)] → 調用一個「常規」的實例方法 invokeinterface i1, i2,count, 0 [obj, (val1, ...)] → 調用一個接口方法 invokespecial i1, i2 [obj, (val1, ...)] → 調用一個「特殊」的實例方法 invokedynamic i1, i2, 0,0 [val1, ...] → 動態調用,見5.5節

在調用操作碼中,有兩個地方需要注意。第一個是invokeinterface中多出來的參數。這些參數基於歷史原因和向後兼容而產生,但現在已經用不到了。在invokedynamic的參數中多出來的兩個0是基於前向兼容而產生的。

另外一個是常規和特別實例方法調用之間的差別。常規調用是虛擬的。這就是說被調用的方法是在運行時按照標準的Java方法重寫規則查找的。特殊調用不考慮重寫。在兩種情況下這很重要,即私有方法和超類方法的調用。在這兩種情況下,你不想觸發重寫規則,所以需要不同的調用操作碼處理這種情況。

5.4.8 平台操作操作碼

平台操作族系的操作碼包括new,用於分配新的對象實例,還有與線程相關的操作碼,比如monitorentermonitorexit。詳細內容請參見表5-8。

平台操作碼用來控制對像生命週期,比如創建新對象並鎖住它們。一定要注意,new操作碼只分配存儲空間。對像構建的高層概念還包括運行構造方法內的代碼。

表5-8 平台操作碼

名稱 參數 堆棧佈局 描述 new i1, i2 → [obj] 為新對像分配內存,類型由指定位置的常量確定 monitorenter [obj] → 鎖住對像 monitorexit [obj] → 解鎖對像

在字節碼這一級,構造方法被轉換成帶有特殊名稱<init>的方法。這不能由用戶代碼調用,但可以由字節碼調用。這便形成了一個與對像創建直接相關的不同字節碼模式:new之後跟著一個dup,然後是一個調用<init>方法的invokespecial

5.4.9 操作碼的快捷形式

為了節省字節,很多字節碼都有快捷形式。通常對某些局部變量的訪問要比其他的訪問更加頻繁,所以用特殊的操作碼來表示「在局部變量上直接執行常見操作」便很有價值。因此加載/存儲族系中出現了aload_0dstore_2這種操作碼。

我們來檢查一下其中的理論,再來看一個例子。

5.4.10 示例:字符串拼接

我們給演算類中加點料,來闡明幾個稍微高級點的字節碼,下面的例子會涉及字節碼主要族系中的大多數。

別忘了,Java中的字符串是不可變的。那在用+運算符把兩個字符串拼在一起時發生了什麼?你必須創建一個新字符串,但實際上可能不止這麼簡單。

看一下修改了run方法之後的演算類:

private void run(String args) {
  String str = "foo";
  if (args.length > 0) str = args[0];
    System.out.println("this is my string: " + str);
}
  

這個簡單方法對應的字節碼為:

$ javap -c -p wgjd/ch04/ScratchImpl.class
  Compiled from "ScratchImpl.java"

private void run(java.lang.String);
  Code:
     0: ldc                 #17                  // String foo
     2: astore_2
     3: aload_1
     4: arraylength
     5: ifle                 12 #A
  

如果傳入數組尺寸小於等於0,跳到指令12。

8: aload_1
9: iconst_0
10: aaload
11: astore_2
12: getstatic #19 // Field java/lang/System.out:Ljava/io/PrintStream;
  

上面這行是訪問System.out的字節碼。

15: new           #25    // class java/lang/StringBuilder
18: dup
19: ldc           #27    // String this is my string:
21: invokespecial #29    // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
24: aload_2
25: invokevirtual #32    // Method java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lang/StringBuilder;
28: invokevirtual #36    // Method java/lang/StringBuilder.toString:Ljava/lang/String;
  

這些指令展示了拼接字符串的創建過程。特別是15~23表示對像創建(newdupinvokespecial)的指令,但在這個例子中dup之後還有一個ldc(加載常量)。這種模式表明字節碼調用的是一個非空構造方法,在此是StringBuilder(String)

這個結果一開始可能有些出乎你的意料。你只是想把一些字符串拼在一起,但到了底層突然變成了創建額外的StringBuilder對象,並調用append,然後又調用toString。這是因為java中的字符串是不可變的。你不能通過拼接修改字符串對象,所以必須創建新的對象。StringBuilder是完成這個任務的便捷方法。

最後是調用相應的方法輸出結果:

31: invokevirtual #40     // Method java/io/PrintStream.println:(Ljava/lang/String;)V
34: return
  

最終,輸出字符串拼好了,你可以調用println方法。因為此時棧頂部的兩個元素是[System.out,<output string>],所以這是在System.out之上調用的。就跟你在看表5-7(定義了有效的invokevirtual的堆棧佈局)時所預期的一樣。

要成為一名真正優秀的Java開發人員,你應該找幾個自己寫的類用javap運行一下,並學會識別通用的字節碼模式。現在,讓我們帶著對字節碼的簡單瞭解,進入下一主題——Java 7中重要的新特性invokedynamic