Java ASM詳解:MethodVisitor與Opcode(四)其他流程結(jié)構(gòu)
上一篇專欄中,我們已經(jīng)了解了基本的流程結(jié)構(gòu)。這篇專欄將詳細(xì)描述Java中其他的流程結(jié)構(gòu)。
一.異常捕獲結(jié)構(gòu)
在平常我們使用流程結(jié)構(gòu)時(shí),除了選擇結(jié)構(gòu)和循環(huán)結(jié)構(gòu)外,使用最多的大概就是異常捕獲結(jié)構(gòu)了。
異常捕獲結(jié)構(gòu)的寫入都使用了visitTryCatchBlock方法(內(nèi)部的實(shí)現(xiàn)是JSR和RET字節(jié)碼),它需要早于其他所有字節(jié)碼寫入,也就是在方法寫入一開始就要定義。它的定義如下:
其中,start是try塊開始的標(biāo)簽;end是try結(jié)束后的標(biāo)簽(try的范圍不包括這個(gè)標(biāo)簽);handler是try塊內(nèi)拋出Throwable對(duì)象后跳轉(zhuǎn)到的標(biāo)簽,即相應(yīng)的catch塊標(biāo)簽;type是catch接受的異常類型,要求傳入異常類的全限定名(例外是finally塊)。
在講述完整的try-catch-finally塊之前,我們先來看看普通的try-catch塊怎么寫入。
普通的try-catch塊類似這樣:
對(duì)于一個(gè)指定的try塊,可能有多個(gè)catch塊和它對(duì)應(yīng)。每一個(gè)catch塊都需要用一次visitTryCatchBlock聲明。對(duì)于catch塊對(duì)應(yīng)的跳轉(zhuǎn)標(biāo)簽?zāi)繕?biāo),它的棧幀信息應(yīng)該和try塊前的局部變量相同,但是操作棧上有一個(gè)對(duì)應(yīng)的異常對(duì)象。下面給出了使用try-catch塊的例子:
使用asm寫入如下(注:javac編譯時(shí)生成的字節(jié)碼和這里不太一樣——它會(huì)把已經(jīng)在操作棧上的Throwable對(duì)象先存入局部變量,這是為了輸出文件的行號(hào)。而這里我們選擇直接忽視棧上的Throwable對(duì)象):
那么multi-catch語句呢?
multi-catch可以看做幾個(gè)catch塊被共用,這時(shí)棧幀信息上的操作棧壓入的是multi-catch中所有異常類的共有超類。例如一個(gè)multi-catch塊能捕獲NumberFormatException和NullPointerException,它的字節(jié)碼寫入如下:
說完了try-catch,我們再看看try-finally語句。
finally其實(shí)類似catch,它們都會(huì)在操作棧上壓入Throwable對(duì)象(如果產(chǎn)生了異常),但是它是無跳轉(zhuǎn)條件(null)的,無論是否出現(xiàn)異常都會(huì)執(zhí)行一次finally,即代碼流必須經(jīng)過finally。如果try塊內(nèi)包含return,也必須先執(zhí)行finally的內(nèi)容之后再執(zhí)行return。如果finally中含有return,則try內(nèi)的所有return將被忽略,通常IDE會(huì)對(duì)這種情況給出警告。
在finally執(zhí)行之后,如果是沒有發(fā)生異常進(jìn)入finally,則正常向下運(yùn)行;如果是因?yàn)楫惓_M(jìn)入了finally,那么在finally執(zhí)行之后必須拋出異?!@就意味著你必須把finally的字節(jié)碼重復(fù)兩遍,一次沒有異常進(jìn)入finally,一次發(fā)生異常進(jìn)入finally。
下面是一個(gè)例子:
asm寫入(javac編譯還是不是這樣,但是運(yùn)行結(jié)果是一樣的。javac會(huì)讓一行語句的執(zhí)行前后操作棧是空,所以在labelReturn前會(huì)進(jìn)行ISTORE,在IRETURN前ILOAD):
接下來,我們把try-catch和try-finally整合到一起。
finally塊的意義是無論發(fā)生什么異常都要保證執(zhí)行,所以catch塊的異常也會(huì)被finally接受。也就是說,一個(gè)完整的try-catch-finally語句需要次visitTryCatchBlock,并且需要重復(fù)finally塊字節(jié)碼
次。(其中
是catch塊的數(shù)量)
下面是個(gè)整合的例子:
asm寫入:
除了try-catch、multi-catch、try-finally、try-catch-finally結(jié)構(gòu)外,還有一種結(jié)構(gòu):try-with-resources。這種結(jié)構(gòu)要求一個(gè)AutoClosable的對(duì)象在try后的語句中初始化:
它可以轉(zhuǎn)化為普通的try-catch-finally塊,類似于這樣(javac編譯之后內(nèi)部不是這樣,這里是將執(zhí)行流程強(qiáng)制轉(zhuǎn)換為可讀Java源碼):
使用try-with-resources結(jié)構(gòu)寫入字節(jié)碼的時(shí)候,只要記住每一個(gè)出口都會(huì)進(jìn)行一次帶try-catch的close就可以。由于這種結(jié)構(gòu)很復(fù)雜且代碼量巨大,就不舉例子了。
二.switch多分支結(jié)構(gòu)
在一些情況下,if...else if...else結(jié)構(gòu)非常的長,這時(shí)我們可以用switch替代。最簡單的switch是鍵為整形數(shù)字常量(可以用int表示的)的,類似于這樣:
在寫入switch中,我們有兩個(gè)方法可以選擇:
它們的相同之處是:它們都需要操作棧頂上有一個(gè)int類型的值。它們的不同之處在于它們對(duì)于鍵值的存儲(chǔ)方式和使用的字節(jié)碼:
visitTableSwitchInsn寫入的鍵值是一個(gè)連續(xù)的數(shù)組——一個(gè)
的一個(gè)整形數(shù)字?jǐn)?shù)組。如果switch中沒有中間的某些鍵值,那么這些鍵值會(huì)和dflt一致,即default的標(biāo)簽(如果沒有default塊,則dflt應(yīng)該指向switch結(jié)束后的第一條語句)。它使用TABLESWITCH字節(jié)碼。
visitLookupSwitchInsn要求傳入一個(gè)switch鍵值的數(shù)組,數(shù)組內(nèi)的數(shù)字要從小到大排序。labels數(shù)組的長度要與keys一致。dflt也是指向default或者switch結(jié)束后的第一條語句的標(biāo)簽。它使用LOOKUPSWITCH字節(jié)碼。
回到最簡單的switch上來。我們需要按照鍵值的特性選擇我們的寫入方式:
如果switch內(nèi)的鍵值差異小,并且鍵值組成一個(gè)連續(xù)整數(shù)數(shù)組的空缺不超過6個(gè),則使用visitTableSwitchInsn
如果switch內(nèi)的鍵值差異大,則使用visitLookupSwitchInsn
先看個(gè)簡單的小例子:
可以看到,鍵值組成了一個(gè)連續(xù)的整數(shù)數(shù)組,所以這里我們應(yīng)該使用visitTableSwitchInsn。
這是對(duì)于switch最簡單的一種清況之一。因?yàn)閎yte、short、char在JVM內(nèi)解釋為int,所以這些步驟基本相同。
switch語句還可以用于枚舉類型,下面我們定義了一個(gè)枚舉,并使用了它:
枚舉類型不能直接作為兩種switch字節(jié)碼的參數(shù),它必須先變?yōu)橐粋€(gè)int才能傳入字節(jié)碼。為此,javac在編譯的時(shí)候會(huì)自動(dòng)創(chuàng)建一個(gè)內(nèi)部類,用于保存這個(gè)類里面出現(xiàn)的所有使用枚舉對(duì)象switch的一個(gè)映射表。對(duì)于我們定義的TestEnum,它對(duì)應(yīng)的映射類應(yīng)該像這樣(假設(shè)我們方法定義的類是Test):
這個(gè)內(nèi)部類中含有所有在這個(gè)類中出現(xiàn)的枚舉對(duì)象,每個(gè)枚舉類都會(huì)創(chuàng)建一個(gè)字段,命名為“$SwitchMap$+.替換成$的類型名”,它們的長度是對(duì)應(yīng)枚舉類枚舉字段的數(shù)量,按照ordinal大小排序?qū)?-n寫入數(shù)組(n是本類使用了多少個(gè)這個(gè)類的枚舉字段)。
接下來,switch的傳入方式也發(fā)生了變化:
那么之前給出的例子我們可以用asm寫入為:
如果是我們自己寫入asm,推薦不要用這種方式寫入——畢竟太麻煩了。最好的方案是使用Enum::ordinal獲取序號(hào)對(duì)序號(hào)進(jìn)行switch,而不是存一個(gè)新的表。
除了枚舉和基本int之外,switch還允許字符串傳入。下面就是一個(gè)例子:
很明顯,String不能直接轉(zhuǎn)換成為int。在String中,hashCode這個(gè)方法可以讓我們將字符串映射到int上,這樣就能把它作為鍵值。但是還有一個(gè)問題需要解決:String和int不能一一對(duì)應(yīng)——不同的字符串可能有相同的hashCode,例如“ddnqavbj
”和“166lr735ka3q6
”的哈希碼值都為0。因此,javac在編譯時(shí)將這個(gè)switch塊拆開為兩個(gè),并使用一個(gè)臨時(shí)量保存字符串的映射。這樣,上面的例子就變成了下面這樣:
按照上面的Java代碼,我們能用asm將它寫入:
最后來看看Java 14新加的增強(qiáng)型switch。
首先,增強(qiáng)型switch可以返回一個(gè)值賦給變量或者進(jìn)行操作:
這種操作的本質(zhì)還是和上面的一樣,下面是展開增強(qiáng)型switch但是不展開String轉(zhuǎn)換的結(jié)果:
另一種增強(qiáng)型switch使用了yield關(guān)鍵字:
它的原理也和上面差不多:

這篇專欄到這里就結(jié)束了(最后不寫例子主要是因?yàn)檫@兩種結(jié)構(gòu)需要的代碼量太大了)。
這回一共講了4個(gè)字節(jié)碼,加上以前的一共190個(gè)。
這篇文章也同步到了博客上,也可以去那里閱讀。(排版有些不同,但是內(nèi)容一樣)
有錯(cuò)誤可以在評(píng)論區(qū)指出~
下一期 Java ASM詳解:MethodVisitor與Opcode(五)invokedynamic、方法引用、BSM