Java ASM详解:MethodVisitor与Opcode(四)其他流程结构
上一篇专栏中,我们已经了解了基本的流程结构。这篇专栏将详细描述Java中其他的流程结构。
一.异常捕获结构
在平常我们使用流程结构时,除了选择结构和循环结构外,使用最多的大概就是异常捕获结构了。
异常捕获结构的写入都使用了visitTryCatchBlock方法(内部的实现是JSR和RET字节码),它需要早于其他所有字节码写入,也就是在方法写入一开始就要定义。它的定义如下:
其中,start是try块开始的标签;end是try结束后的标签(try的范围不包括这个标签);handler是try块内抛出Throwable对象后跳转到的标签,即相应的catch块标签;type是catch接受的异常类型,要求传入异常类的全限定名(例外是finally块)。
在讲述完整的try-catch-finally块之前,我们先来看看普通的try-catch块怎么写入。
普通的try-catch块类似这样:
对于一个指定的try块,可能有多个catch块和它对应。每一个catch块都需要用一次visitTryCatchBlock声明。对于catch块对应的跳转标签目标,它的栈帧信息应该和try块前的局部变量相同,但是操作栈上有一个对应的异常对象。下面给出了使用try-catch块的例子:
使用asm写入如下(注:javac编译时生成的字节码和这里不太一样——它会把已经在操作栈上的Throwable对象先存入局部变量,这是为了输出文件的行号。而这里我们选择直接忽视栈上的Throwable对象):
那么multi-catch语句呢?
multi-catch可以看做几个catch块被共用,这时栈帧信息上的操作栈压入的是multi-catch中所有异常类的共有超类。例如一个multi-catch块能捕获NumberFormatException和NullPointerException,它的字节码写入如下:
说完了try-catch,我们再看看try-finally语句。
finally其实类似catch,它们都会在操作栈上压入Throwable对象(如果产生了异常),但是它是无跳转条件(null)的,无论是否出现异常都会执行一次finally,即代码流必须经过finally。如果try块内包含return,也必须先执行finally的内容之后再执行return。如果finally中含有return,则try内的所有return将被忽略,通常IDE会对这种情况给出警告。
在finally执行之后,如果是没有发生异常进入finally,则正常向下运行;如果是因为异常进入了finally,那么在finally执行之后必须抛出异常——这就意味着你必须把finally的字节码重复两遍,一次没有异常进入finally,一次发生异常进入finally。
下面是一个例子:
asm写入(javac编译还是不是这样,但是运行结果是一样的。javac会让一行语句的执行前后操作栈是空,所以在labelReturn前会进行ISTORE,在IRETURN前ILOAD):
接下来,我们把try-catch和try-finally整合到一起。
finally块的意义是无论发生什么异常都要保证执行,所以catch块的异常也会被finally接受。也就是说,一个完整的try-catch-finally语句需要次visitTryCatchBlock,并且需要重复finally块字节码
次。(其中
是catch块的数量)
下面是个整合的例子:
asm写入:
除了try-catch、multi-catch、try-finally、try-catch-finally结构外,还有一种结构:try-with-resources。这种结构要求一个AutoClosable的对象在try后的语句中初始化:
它可以转化为普通的try-catch-finally块,类似于这样(javac编译之后内部不是这样,这里是将执行流程强制转换为可读Java源码):
使用try-with-resources结构写入字节码的时候,只要记住每一个出口都会进行一次带try-catch的close就可以。由于这种结构很复杂且代码量巨大,就不举例子了。
二.switch多分支结构
在一些情况下,if...else if...else结构非常的长,这时我们可以用switch替代。最简单的switch是键为整形数字常量(可以用int表示的)的,类似于这样:
在写入switch中,我们有两个方法可以选择:
它们的相同之处是:它们都需要操作栈顶上有一个int类型的值。它们的不同之处在于它们对于键值的存储方式和使用的字节码:
visitTableSwitchInsn写入的键值是一个连续的数组——一个
的一个整形数字数组。如果switch中没有中间的某些键值,那么这些键值会和dflt一致,即default的标签(如果没有default块,则dflt应该指向switch结束后的第一条语句)。它使用TABLESWITCH字节码。
visitLookupSwitchInsn要求传入一个switch键值的数组,数组内的数字要从小到大排序。labels数组的长度要与keys一致。dflt也是指向default或者switch结束后的第一条语句的标签。它使用LOOKUPSWITCH字节码。
回到最简单的switch上来。我们需要按照键值的特性选择我们的写入方式:
如果switch内的键值差异小,并且键值组成一个连续整数数组的空缺不超过6个,则使用visitTableSwitchInsn
如果switch内的键值差异大,则使用visitLookupSwitchInsn
先看个简单的小例子:
可以看到,键值组成了一个连续的整数数组,所以这里我们应该使用visitTableSwitchInsn。
这是对于switch最简单的一种清况之一。因为byte、short、char在JVM内解释为int,所以这些步骤基本相同。
switch语句还可以用于枚举类型,下面我们定义了一个枚举,并使用了它:
枚举类型不能直接作为两种switch字节码的参数,它必须先变为一个int才能传入字节码。为此,javac在编译的时候会自动创建一个内部类,用于保存这个类里面出现的所有使用枚举对象switch的一个映射表。对于我们定义的TestEnum,它对应的映射类应该像这样(假设我们方法定义的类是Test):
这个内部类中含有所有在这个类中出现的枚举对象,每个枚举类都会创建一个字段,命名为“$SwitchMap$+.替换成$的类型名”,它们的长度是对应枚举类枚举字段的数量,按照ordinal大小排序将1-n写入数组(n是本类使用了多少个这个类的枚举字段)。
接下来,switch的传入方式也发生了变化:
那么之前给出的例子我们可以用asm写入为:
如果是我们自己写入asm,推荐不要用这种方式写入——毕竟太麻烦了。最好的方案是使用Enum::ordinal获取序号对序号进行switch,而不是存一个新的表。
除了枚举和基本int之外,switch还允许字符串传入。下面就是一个例子:
很明显,String不能直接转换成为int。在String中,hashCode这个方法可以让我们将字符串映射到int上,这样就能把它作为键值。但是还有一个问题需要解决:String和int不能一一对应——不同的字符串可能有相同的hashCode,例如“ddnqavbj
”和“166lr735ka3q6
”的哈希码值都为0。因此,javac在编译时将这个switch块拆开为两个,并使用一个临时量保存字符串的映射。这样,上面的例子就变成了下面这样:
按照上面的Java代码,我们能用asm将它写入:
最后来看看Java 14新加的增强型switch。
首先,增强型switch可以返回一个值赋给变量或者进行操作:
这种操作的本质还是和上面的一样,下面是展开增强型switch但是不展开String转换的结果:
另一种增强型switch使用了yield关键字:
它的原理也和上面差不多:

这篇专栏到这里就结束了(最后不写例子主要是因为这两种结构需要的代码量太大了)。
这回一共讲了4个字节码,加上以前的一共190个。
这篇文章也同步到了博客上,也可以去那里阅读。(排版有些不同,但是内容一样)
有错误可以在评论区指出~
下一期 Java ASM详解:MethodVisitor与Opcode(五)invokedynamic、方法引用、BSM