Java ASM详解:MethodVisitor与Opcode(三)标签,条件结构,循环结构,栈帧
在之前的文章中,我们已经知道了基础的字节码。但是,这些字节码只能构建起一个简单的结构,不能实现循环条件等高级结构。这篇文章将讨论关于程序流程结构的字节码。
一.标签
标签(Label)是用来划明一部分字节码的标识(通常意义上标签就是一个标记点,但是为了接下来的讲述就用它代表一块字节码)。一个标签下的字节码块,应该从操作栈空开始到操作栈被清空结束——也就是说,一个标签代表的字节码块反编译之后应该是完整的一或多条语句。
在通常情况下,javac编译器会把每条单独语句都分配一个标签,这么做的目的是为了输出行号和局部变量名称。
标签也可以在我们使用Java时自己定义,下面的LABEL就是一个标签:
在字节码中,标签代表的字节码块是从这个标签写入开始到下一个标签写入或该方法的字节码读取完毕的一部分字节码。
在ASM库中,标签用org.objectweb.asm.Label进行表示,构造方法如下:
写入一个Label,需要用到MethodVisitor的方法,方法如下:
正如前面所说,两个标签的写入之间的字节码可以看作这个标签代表的一块字节码块。因此,两个visitLabel之间的语句也可以被看作前一个Label代表的一部分字节码区域。
那么标签有什么用呢?
首先它可以保存代码的行号,这就用到了MethodVisitor::visitLineNumber这个方法了。
第一个参数代表了这条语句的行号,第二个参数就是这条语句的标签。标签必须先于行号被写入,否则就会抛出IllegalArgumentException。
其次,它可以保存局部变量的名称。局部变量有作用域,而作用域可以用两个标签指定。在这两个标签之内且在指定局部变量槽位上的变量就是我们要命名的局部变量。写入局部变量的名称使用MethodVisitor::visitLocalVariable。
参数的意义分别是:名称、描述符、泛型签名、开始的标签、结束的标签、局部变量槽位。在javac编译生成的类文件中,局部变量名称的写入都要在最后写入。
最后,标签的最重要意义就是它可以用于跳转字节码上。
二.跳转字节码
用于跳转的字节码都使用了visitJumpInsn方法:
第一个就是字节码,第二个是跳转的目标。字节码决定了是否进行跳转,标签决定了跳转的目的地。
跳转字节码分为两种——比较跳转和无条件跳转。
无条件跳转,也就是goto字节码:
goto字节码是当程序运行到这里时,就直接跳转到对应的标签继续执行,通常都是用在循环内部的。
比较跳转,也是大多数条件结构和循环结构使用的字节码,它有四套字节码,分别对应了int比较、int与0比较、对象比较和对象空检测:
1. if_icmp<cond>
<cond> = eq/ne/lt/ge/gt/le
这六个字节码分别对应了两个int数据进行相等、不相等、小于、大于等于、大于、小于等于的比较测试。如果比较成功,就跳转到指定的标签处运行。如果比较不成功,就沿着当前的流程继续运行。
2. if<cond>
<cond> = eq/ne/lt/ge/gt/le
这六个字节码分别对应了一个int数据进行等于0、不等于0、小于0、大于等于0、大于0、小于等于0的比较测试。测试结果和跳转方式和上文相同。
3. if_acmp<cond>
<cond> = eq/ne
这两个字节码分别对应两个对象相等和不相等。这个字节码比较的是对象的引用,而不是内部的值——也就是说,即使两个String对象内部存储字符串一样,也不能保证它们的检测结果为真!(例外是使用了String::intern,它会把字符串放进常量池,并返回一个固定的引用)所以判断字符串相等必须使用equals方法而不是==。
4. ifnull/ifnonnull
这两个字节码分别测试对象是空还是非空。执行流程和之前3个一样。
可以看到,这几个字节码指针对了int和对象引用的情况,而没有考虑long、float、double的情况。于是Java加入了下面几个字节码用于比较它们,获取到值后就可以传递给各个IF字节码判断:
1. lcmp
它用于比较两个long的大小:如果第一个数字比第二个小,返回-1;如果第二个数字比第一个小,返回1;如果相等,返回0。
2. xcmp<op>
x=f/d, <op>=l/g
这套字节码和lcmp的逻辑差不多:如果第一个数小于第二个数,返回-1;如果第二个数小于第一个数,返回1;如果相等,返回0。但是,如果其中一方是NaN,<op>就决定了它们返回的值:l版本返回-1,而g版本返回1。
接下来,我们将用这22个字节码实现程序的复杂流程结构。
三.条件结构
在Java中,条件结构类似于下面:
在编译期间,这种代码可以被看为:
也就是说,这种结构就是由一个一个的if...else结构组合形成的。一个简单的if...else结构用字节码写入后可以表示为这样的流程:

if判断条件通常都使用了返回boolean的表达式(除了特殊字节码指定的比较方式外都需要这样传入),而boolean值的true是1,false是0,使用IFEQ字节码相当于被反向判断。相类似的,javac在编译时经常将字节码操作反转来保证if块先于else块写入。
返回boolean值传入if中的选择结构类似于这样:
在跳转之后,我们的操作栈和局部变量表会和跳转之前相等。同时,visitMaxs的参数变成了所有分支下最大的局部变量表大小和最大的操作栈深度。
下面举一个例子。要生成这样的Java代码:
我们的字节码应该像下面这样写:
条件结构可以被简化为三元运算符,三元运算符的字节码也类似于if...else。
下面是一个使用三元运算符的例子:
字节码写入:
四.循环结构
循环结构都比较类似,都是流程返回到之前的代码部分。先从while语句开始,它写入类似于下面这样:

接下来用一段Java代码写一个例子:
这是用字节码的方式写入:
类似于while,do...while结构也用了和它基本一致的思路,只不过是循环条件写到了后面,并且循环条件不用反转:

do...while循环就不再举例子了,下面来看看for循环。
for循环有两种:普通的for语句和for-each语句。普通的for循环语句在定义时包括了三条语句:一条初始化、一条条件和一条循环结束执行语句。它的流程类似于while多加了一些部分:

接下来就是一个例子,使用for循环:
在字节码里面要这样写:
另一种for循环,即for-each循环,它的实现和for循环很不一样。
for-each需要一个Iterable的对象才能使用,它的原理就是通过iterator进行迭代。下面这两种形式是等价的:
也就是说,for-each本质是while循环。由于没有讲泛型,所以就不细讲此处。
五.栈帧
我相信你已经把上面的例子都跑了一遍(没跑也没事,我默认已经跑了),可是这些东西在你尝试运行它们的时候都会报错。它们报的错无一例外都是VerifyError,这是出了什么毛病?这就有关于栈帧了。
Java中执行方法时,JVM会分配给当前线程一个栈帧,栈帧和方法绑定,它的内部就是现在的局部变量表和操作栈数据(这在第三篇文章说过)。栈帧内的局部变量表大小和操作栈大小来自visitMaxs。栈帧在方法开始执行时创建,在方法返回时(包括抛出异常)销毁。
但是在类文件中,我们不能保证一个类它的数据是不是异常的——有可能它规定的栈帧局部变量表或者操作栈小于真正运行时的大小。所以Java引入了类的验证阶段,检查类内部数据。其中有一项就是检查方法栈帧——检查方法字节码是否正确排序、变量类型是不是一致等。但是这种验证很耗费时间,所以JVM验证器引入了StackMapTable进行辅助,这样就能在线性的运行下检查。但是每一行都加入栈帧映射(stack map frame)实在是太浪费空间了,所以JVM做了优化,规定每个跳转目标之后都必须有一个映射用于表示栈帧变化。
栈帧映射中并不是一个真的局部变量表和操作栈类型表,它是以一种和前面的映射比较的方式保存——比如这个映射要比前面的映射少两个元素等。第一个映射前面并没有别的映射,所以它和空的操作栈与参数列表组成的局部变量表的栈帧比较。
(可以看看https://stackoverflow.com/questions/25109942/what-is-a-stack-map-frame下面的评论)
所以引发异常的真正原因我们找到了——看来验证器没有检查到方法内部跳转指令后的栈帧映射,导致了验证失败抛出异常。
那么怎么写入栈帧映射呢?
MethodVisitor提供了一个方法,叫visitFrame。它就是用于写入当前栈帧数据变化的方法。这个方法需要在每个跳转目标的visitLabel后面去写,不是用于跳转的标签不需要visitFrame。
visitFrame的方法原型如下:
它有5个参数,指明了这个映射和前面的映射的比较方式和数据。先讲后面的参数,最后再讲第一个参数。
第三个参数是一个代表局部变量变化的一个数组,长度应该为第二个参数。数组内的取值分为这几种:
如果变量是一个没有初始化的对象,那么这个值是指向这个对象NEW字节码的标签对象。
如果变量是this并且在调用父类构造函数之前被调用,这个值是UNINITIALIZED_THIS。
如果变量类型不是基本类型,值就应该是它的类的全限定名/描述符字符串。
如果是基本类型,那么取值是固定的:int用INTEGER代替,float用FLOAT代替,long用LONG代替,double用DOUBLE代替,空用NULL代替。long和double即使需要占两个槽位也不需要写两遍,byte、short、char、boolean要用INTEGER代替。
如果这个局部变量槽位上暂时是空位(注意不是空对象),用TOP代替。
第五个参数类似,是表示操作栈变化的一个数组,长度是第四个参数。
下面是重点——第一个参数的意义。它的不同取值和意义如下:
F_NEW,只能在Java 6使用(或者ClassWriter被指定扩展栈帧映射),它的写入和之后的版本不一样(其实是类似F_FULL,写入和之前的栈帧信息无关)。这篇文章不会介绍Java 6的栈帧映射写入。
F_SAME,代表这里的局部变量表和之前的栈帧信息相比没有变化,numLocal和numStack为0,两个数组都为null。(即使不是null也不会写入)
F_SAME1,代表这里的局部变量表和之前的栈帧信息一样,而操作栈上有一个变量。numLocal是0,numStack是1,local是null,stack是一个数组,内部只有一个元素,代表现在栈上对象的类型。
F_APPEND,代表现在的局部变量表和之前的栈帧信息一样,但是会多出1-3个新的局部变量。numLocal是新增加的局部变量的数量,local是一个长度为numLocal的数组,存储新增加的局部变量的类型。numStack是0,stack为null。
F_CHOP,代表现在的局部变量表要比之前的栈帧信息少1-3个局部变量。numLocal就是局部变量缺少的数量,numStack是0,local和stack都是null。
F_FULL,这代表现在的栈帧和之前的栈帧没有关系,相当于复写了栈帧的信息。numLocal是局部变量数量,local是局部变量类型数组,numStack是操作栈深度,stack是操作栈类型数组。当现在的栈帧比之前的栈帧多/少3个以上的局部变量,或者操作栈上有变量(除非局部变量表不变且栈深度为1可以使用F_SAME1对应),都需要用这个标志重新写入。
在编译时,编译器会尽量减少F_FULL的出现次数,保证类文件不会因为额外栈帧信息变得臃肿。在我们自己生成字节码时,也尽量不要用F_FULL。
接下来,我们来回顾我们报错的代码:
按照之前的代码,我们要在每个跳转目标上加上栈帧信息:
为了方便用户操作,asm自己加了一个计算栈帧信息的标识:COMPUTE_FRAMES。在ClassWriter构造函数中使用。
使用这个后,所有的visitFrame和visitMaxs都不需要我们自己写。ClassWriter会根据字节码推断栈帧信息等并写入,代价是增加近一倍的运行时间。
六.实战
下面,我们将用字节码写出一个简单的阶乘程序,使用for循环计算阶乘并且用if判断是否溢出。对应的Java代码如下:
首先计划一下程序标签的位置:
下面我们用不开启COMPUTE_FRAMES的ClassWriter进行写入:
然后我们对生成的类进行测试:
得到下面的输出:
这就代表成功了!
全部源代码:https://paste.ubuntu.com/p/Gyhn3wHMQ3/

这篇专栏到这里就结束了,下一期:Java ASM详解:MethodVisitor与Opcode(四)其他流程结构
这篇文章一共讲了22个字节码,加上以前讲过的一共186个。
这篇文章也同步到了博客上,也可以去那里阅读。(排版有些不同,但是内容一样)
有错误可以在评论区指出~