1. 程式人生 > >Java位元組碼淺析(二)

Java位元組碼淺析(二)

英文原文連結譯文連結,原文作者:James Bloom,譯者:有孚

條件語句

像if-else, switch這樣的流程控制的條件語句,是通過用一條指令來進行兩個值的比較,然後根據結果跳轉到另一條位元組碼來實現的。

迴圈語句包括for迴圈,while迴圈,它們的實現方式也很類似,但有一點不同,它們通常都會包含一條goto指令,以便位元組碼實現迴圈執行。do-while迴圈不需要goto指令,因為它的條件分支是在位元組碼的末尾。更多細節請參考迴圈語句一節。

有一些指令可以用來比較兩個整型或者兩個引用,然後執行某個分支,這些操作都能在單條指令裡面完成。而像double,float,long這些值需要兩條指令。首先得去比較兩個值,然後根據結果,會把1,0或者-1壓到棧裡。最後根據棧頂的值是大於,等於或者小於0來判斷應該跳轉到哪個分支。

我們先來介紹下if-else語句,然後再詳細介紹下分支跳轉用到的幾種不同的指令。

if-else

下面的這個簡單的例子是用來比較兩個整數的:

public int greaterThen(int intOne, int intTwo) {
    if (intOne > intTwo) {
        return 0;
    } else {
        return 1;
    }
}

方法最後會編譯成如下的位元組碼:

0: iload_1
1: iload_2
2: if_icmple     7
5: iconst_0
6: ireturn
7: iconst_1
8: ireturn

首先,通過iload_1, iload_2兩條指令將兩個入參壓入運算元棧中。if_icmple會比較棧頂的兩個值的大小。如果intOne小於或者等於intTwo的話,會跳轉到第7行處的位元組碼來執行。可以看到這裡和Java程式碼裡的if語句的條件判斷正好相反,這是因為在位元組碼裡面,判斷條件為真的話會跑到else分支裡面去執行,而在Java程式碼裡,判斷為真會進入if塊裡面執行。換言之,if_icmple判斷的是如果if條件不為真,然後跳過if塊。if程式碼塊裡對應的程式碼是5,6處的位元組碼,而else塊對應的是7,8處的。

下面的程式碼則稍微複雜了一點,它需要進行兩次比較。

public int greaterThen(float floatOne, float floatTwo) {
    int result;
    if (floatOne > floatTwo) {
        result = 1;
    } else {
        result = 2;
    }
    return result;
}

編譯後會是這樣:

0: fload_1
 1: fload_2
 2: fcmpl
 3: ifle          11
 6: iconst_1
 7: istore_3
 8: goto          13
11: iconst_2
12: istore_3
13: iload_3
14: ireturn

在這個例子中,首先兩個引數會被fload_1和fload_2指令壓入棧中。和上面那個例子不同的是,這裡需要比較兩回。fcmple先用來比較棧頂的floatOne和floatTwo,然後把比較的結果壓入運算元棧中。

        * floatOne > floatTwo –> 1
        * floatOne = floatTwo –> 0
        * floatOne < floatTwo –> -1
        * floatOne or floatTwo = NaN –> 1

然後通過ifle進行判斷,如果前面fcmpl的結果是< =0的話,則跳轉到11行處的位元組碼去繼續執行。

這個例子還有一個地方和前面不同的是,它只在方法末有一個return語句,因此在if程式碼塊的最後,會有一個goto語句來跳過else塊。goto語句會跳轉到第13條位元組碼處,然後通過iload_3將儲存在區域性變數區第三個位置的結果壓入棧中,然後就可以通過return指令將結果返回了。

除了比較數值的指令外,還有比較引用是否相等的(==),以及引用是否等於null的(== null或者!=null),以及比較物件的型別的(instanceof)。

if_icmp<cond> 這組指令用來比較運算元棧頂的兩個整數,然後跳轉到新的位置去執行。<cond>可以是:eq-等於,ne-不等於,lt-小於,le-小於等於,gt-大於, ge-大於等於。
if_acmp<cond> 這兩個指令用來比較物件是否相等,然後根據運算元指定的位置進行跳轉。
ifnonnull ifnull 這兩個指令用來判斷物件是否為null,然後根據運算元指定的位置進行跳轉。
lcmp 這個指令用來比較棧頂的兩個長整型,然後將結果值壓入棧中: 如果value1>value2,壓入1,如果value1==value2,壓入0,如果value1<value2壓入-1.
fcmp<cond> l g dcomp<cond> 這組指令用來比較兩個float或者double型別的值,然後然後將結果值壓入棧中:如果value1>value2,壓入1,如果value1==value2,壓入0,如果value1<value2壓入-1. 指令可以以l或者g結尾,不同之處在於它們是如何處理NaN的。fcmpg和dcmpg指令把整數1壓入運算元棧,而fcmpl和dcmpl把-1壓入運算元棧。這確保了比較兩個值的時候,如果其中一個不是數字(Not A Number, NaN),比較的結果不會相等。比如判斷if x > y(x和y都是浮點數),就會用的fcmpl,如果其中一個值是NaN的話,-1會被壓入棧頂,下一條指令則是ifle,如果分支小於0則跳轉。因此如果有一個是NaN的話,ifle會跳過if塊,不讓它執行。
instanceof 如果棧頂物件的型別是指定的類的話,則將1壓入棧中。這個指令的運算元指定的是某個型別在常量池的序號。如果物件為空或者不是對應的型別,則將0壓入運算元棧中。
if<cond> 將棧頂值和0進行比較,如果條件為真,則跳轉到指定的分支繼續執行。這些指令通常用於較複雜的條件判斷中,在一些單條指令無法完成的情況。比如驗證方法呼叫的返回值。

switch語句

Java switch表示式的型別只能是char,byte,short,int,Character, Byte, Short,Integer,String或者enum。JVM為了支援switch語句,用了兩個特殊的指令,叫做tableSwitch和lookupswitch,它們都只能操作整型數值。只能使用整型並不影響,因為char,byte,short和enum都可以提升成int型別。Java7開始支援String型別,下面我們會介紹到。tableswitch操作會比較快一些,不過它消耗的記憶體會更多。tableswitch會列出case分支裡面最大值和最小值之間的所有值,如果判斷的值不在這個範圍內則直接跳轉到default塊執行,case中沒有的值也會被列出,不過它們同樣指向的是default塊。拿下面的這個switch語句作為例子:

public int simpleSwitch(int intOne) {
    switch (intOne) {
        case 0:
            return 3;
        case 1:
            return 2;
        case 4:
            return 1;
        default:
            return -1;
    }
}

編譯後會生成如下的位元組碼

0: iload_1
 1: tableswitch   {
         default: 42
             min: 0
             max: 4
               0: 36
               1: 38
               2: 42
               3: 42
               4: 40
    }
36: iconst_3
37: ireturn
38: iconst_2
39: ireturn
40: iconst_1
41: ireturn
42: iconst_m1
43: ireturn

tableswitch指令裡0,1,4的值和程式碼裡的case語句一一對應,它們指向的是對應程式碼塊的位元組碼。tableswitch指令同樣有2,3的值,但程式碼中並沒有對應的case語句,它們指向的是default程式碼塊。當這條指令執行的時候,會判斷運算元棧頂的值是否在最大值和最小值之間。如果不在的話,直接跳去default分支,也就是上面的42行處的位元組碼。為了確保能找到default分支,它都是出現在tableswitch指令的第一個位元組(如果需要記憶體對齊的話,則在補齊了之後的第一個位元組)。如果棧頂的值在最大最小值的範圍內,則用它作為tableswtich內部的索引,定位到應該跳轉的分支。比如1的話,就會跳轉至38行處繼續執行。下圖會演示這條指令是如何執行的:

如果case語句裡面的值取值範圍太廣了(也就是太分散了)這個方法就不太好了,因為它佔用的記憶體太多了。因此當switch的case條件裡面的值比較分散的時候,就會使用lookupswitch指令。這個指令會列出case語句裡的所有跳轉的分支,但它沒有列出所有可能的值。當執行這條指令的時候,棧頂的值會和lookupswitch裡的每個值進行比較,來確定要跳轉的分支。執行lookupswitch指令的時候,JVM會在列表中查詢匹配的元素,這和tableswitch比起來要慢一些,因為tableswitch直接用索引就定位到正確的位置了。當switch語句編譯的時候,編譯器必須去權衡記憶體的使用和效能的影響,來決定到底該使用哪條指令。下面的程式碼,編譯器會生成lookupswitch語句:

public int simpleSwitch(int intOne) {
    switch (intOne) {
        case 10:
            return 1;
        case 20:
            return 2;
        case 30:
            return 3;
        default:
            return -1;
    }
}

生成後的位元組碼如下:

0: iload_1
 1: lookupswitch  {
         default: 42
           count: 3
              10: 36
              20: 38
              30: 40
    }
36: iconst_1
37: ireturn
38: iconst_2
39: ireturn
40: iconst_3
41: ireturn
42: iconst_m1
43: ireturn

為了確保搜尋演算法的高效(得比線性查詢要快),這裡會提供列表的長度,同時匹配的元素也是排好序的。下圖演示了lookupswitch指令是如何執行的。

未完待續。