Java ASM详解:MethodVisitor和Opcode(二)类型、数组、字段、方法、异常与同步

上次讲过了操作栈与数值运算操作,这篇专栏主要讲ASM中有关于类型、数组与方法调用的字节码。
P.S.ASM库已经更新到了9.2版本,可以试试解析Java 18的类了。.
一.有关于类型的字节码
有关于类型的字节码都是用visitTypeInsn进行写入的。这类字节码共有4个:NEW,ANEWARRAY,INSTANCEOF和CHECKCAST。ANEWARRAY在之后的数组字节码里面会仔细去讲。
[1. new]
NEW只进行创建对象,不负责调用构造函数,所以内部字段的值都为默认值。调用构造函数必须用invokespecial字节码进行调用(下文)。
在调用这个字节码时,如果指向的类没有初始化,就它的调用静态初始化函数<clinit>。如果在初始化中发生异常就会抛出错误。如果目标类的类格式有误,则抛出异常。如果目标类时抽象的,则抛出InstantiationError
。
[2. instanceof]
instanceof用于检查对象是否为这个类型的实例,如果是则返回boolean值true,即操作栈上的一个int数据1;如果不是就返回0。
对于null对象,该字节码永远返回0。
[3. checkcast]
checkcast用于检查对象的类型,类似于instanceof。但不同的是,如果无法将对象转换为指定类型,该字节码会抛出ClassCastException。这个字节码经常见于泛型中。
加入这个字节码通常是为了指定对象是某个类型好让验证器验证,在局部变量无法得知确切类型时必须加入此字节码保证验证通过(运行时报错就是另一回事了)。
下面是这三个字节码组合的例子:
要生成的Java代码如下:
对应的生成这段代码的字节码程序如下:
二.数组操作的字节码
数组操作的字节码一共有20个,其中加载指令8个,存储指令8个,三个创建还有一个获取数组长度的字节码。
[1. newarray]
和newarray字节码用于创建基本类型的数组,它的参数代表了它的类型,在Opcodes类中一共有8个:T_BOOLEAN(boolean),T_CHAR(char),T_FLOAT(float),T_DOUBLE(double),T_BYTE(byte),T_SHORT(short),T_INT(int)和T_LONG(long)。
如果数组长度小于0,这个字节码会抛出NegativeArraySizeException
。
[2. anewarray]
基本类型的数组由newarray创建,而不是基本类型的数组由anewarray创建。
和newarray一样,如果数组长度小于0,这个字节码会抛出NegativeArraySizeException
。
[3. multianewarray]
创建一个多维数组,多维数组的描述符要与第二个参数维度相匹配。和另两个字节码相同,如果多维数组任意一维的长度小于0,这个字节码就会抛出NegativeArraySizeException
。
下面是使用这三个字节码的例子:
Java代码:
生成这些代码的字节码程序:
在创建数组时,如果是一维数组就用newarray或anewarray。multianewarray也能创建一维数组,但是使用上面的两个更加高效。
[4. arraylength]
获取数组的长度,返回int。如果数组输入为null,抛出空指针异常。
[5. xaload]
x=a,b,c,d,f,i,l,s, 其中b同时负责了byte和boolean
xaload的作用是从数组指定下标取元素。如果下标超过数组长度,抛出ArrayIndexOutOfBoundsException
。对于多维数组的提取元素方式类似下面:
[6. xastore]
x=a,b,c,d,f,i,l,s, 其中b同时负责了byte和boolean
将对象存入数组指定下标。如果下标超过数组长度,抛出ArrayIndexOutOfBoundsException
。对于多维数组,存储对象需要和xaload一起配合。
三.操作字段的字节码
在代码中我们经常会调用类中的字段,例如System.out。Java提供了四个字节码用于访问和修改字段。
[1. getfield]
getfield用于获取非静态字段的值。如果它作用目标是一个静态字段,则在类连接验证时抛出IncompatibleClassChangeError
。
如果输入的对象是null,这个字节码会在运行时抛出空指针异常。
这个字节码不能调用数组的length字段,在编译的时候length字段会自行转变成arraylength字节码。
[2. getstatic]
getstatic用于获取静态字段的值。如果它作用目标是一个非静态字段,则在类连接验证时抛出IncompatibleClassChangeError
。
[3. putfield]
putfield用于修改非静态字段的值。如果它作用目标是一个静态字段,则在类连接验证时抛出IncompatibleClassChangeError
。
如果输入的对象是null,这个字节码会在运行时抛出空指针异常。
对于final字段,如果不是在初始化对象时修改(构造函数中),那么就会抛出IllegalAccessError
。
[4. putstatic]
putstatic用于修改静态字段的值。如果它作用目标是一个非静态字段,则在类连接验证时抛出IncompatibleClassChangeError
。
对于final字段,如果不是在类初始化时修改(<clinit>中),那么就会抛出IllegalAccessError
。
四.调用方法的字节码
调用方法的字节码共有五个:invokevirtual,invokespecial,invokestatic,invokeinterface和invokedynamic。invokedynamic使用了BSM(BootStrap Method),讲解起来很复杂,所以这个要单独分出来一篇文章去讲。这篇文章主要讨论前四个。
这些字节码都使用visitMethodInsn方法,其中最后一个参数代表这个方法是不是在接口内定义,而不是代表是不是抽象方法。
[1. invokevirtual]
这个字节码用于调用实例方法:如果对象是子类的对象且子类复写了这个方法,则调用子类的方法;如果对象就是该类的直接对象或者对象所属子类没有复写这个方法,就调用现在类的方法。
在编译时,如果子类调用了父类的方法且子类没有实现此方法,那么方法所在的类要写为父类。如果使用super,要用invokespecial调用(下文)。
如果方法调用目标是静态的,在连接验证时会抛出IncompatibleClassChangeError
。
如果方法调用目标是抽象的,并且在继承树上没有任何实现此方法的类,在调用时会抛出AbstractMethodError
。
如果方法调用目标是抽象的,而继承树上由多个实现此方法的类,且这些方法都是可被选中成为调用目标的方法(比如一个类继承于一个抽象类,又实现了两个接口,两个接口中都有一个同样的default方法可作为抽象类中抽象方法的实现目标),这时此字节码会抛出IncompatibleClassChangeError
。
如果方法调用目标是native的,且没有任何JNI连接查询到这个方法和哪个C函数相连接,这时这个字节码抛出UnsatisfiedLinkError
。
[2. invokespecial]
invokespecial类似于invokevirtual,但不同的是,它和调用方法的对象的类型无关:它的方法调用对象就是字节码内部标定的方法,如果这个类找不到就寻找直接超类的方法,而不是像invokevirtual要考虑继承树所有的方法。
这个方法经常在构造函数中看到,因为无论什么类都需要有一个构造函数,而构造函数内部必须自动调用父类构造函数。
一个默认的构造函数类似于下面:
在生成类时,如果没有自定义其他构造函数,就要加上这个默认构造函数:
[3. invokestatic]
invokestatic用于调用静态方法,如果调用目标不是个静态方法,抛出IncompatibleClassChangeError
。
和invokevirtual一样,如果目标是个native方法而JNI找不到连接的C函数,该字节码抛出UnsatisfiedLinkError
。
[4. invokeinterface]
这个字节码类似于invokevirtual,异常情况的处理也和它类似。它用于调用接口实例方法,而不是像invokevirtual的实例方法。
五.抛出异常的字节码:athrow
athrow负责将一个Throwable对象抛出。如果对象是null,那么就不会抛出这个null,而是抛出NullPointerException。
通常情况下,我们都是直接new一个Throwable对象然后直接抛出,就像这样:
翻译为字节码如下:
六.同步字节码
同步操作共有两个字节码,monitorenter和monitorexit,成套使用。
输入的对象必须是引用类型对象,不能是基本类型的值。
使用同步块时,代码类似这样:
对应的字节码:
monitorenter就是尝试加锁的操作。如果这个对象的监视器条目计数为0,此线程会把这个计数设置为1,这时此线程就是这个对象的监视器;如果不为0且线程不是该对象的监视器,线程会阻塞直到计数为0时重新尝试加锁;如果线程已经是这个对象的监视器,计数递增。
monitorexit就是释放锁的操作。如果线程是这个对象的监视器,计数递减,当计数减为0时该线程就不是这个对象的监视器了。如果线程不是这个对象的监视器,这个字节码会抛出IllegalMonitorStateException
。
monitorenter可以和很多monitorexit一起出现,在一个方法的所有可能流程中的加锁次数和释放次数必须相同,否则在调用时会发生IllegalMonitorStateException
。
对于同步方法(访问标志含有ACC_SYNCHRONIZED),不需要手动对自身对象或类加锁。JVM在调用方法前隐式加锁,在调用之后隐式释放。
七.应用:计算两数之积
学到了这些字节码,接下来我们要试试用纯字节码解决这道简单的问题。
在Java代码下,我们可以这样写:
下面是用ASM生成的步骤:
首先还是创建类和方法,不再多说。
第一行,创建Scanner对象,这里用到的就是new。
第二行和第三行都是读取double,这里是调用了Scanner的nextDouble方法,这里只给第二行的例子:
接下来是个重头戏。首先来看看PrintStream::printf的定义:
可以看到,args是个不定长参数,这怎么表示呢?
在Java中,不定长参数都被解析为数组,也就是说,它在字节码中的表示其实是这样的:
现在我们需要传递的参数就是一个字符串和一个Object数组。可是double不是引用类型,这又要怎么办呢?
在Java中,基本类型都有它们的“包装类”。double的包装类是java.lang.Double,通过Double::valueOf方法就可以把double值转变为Double对象,也就是装箱操作。在平常编写时,Java编译器会自动为我们添加装箱操作,也就是自动装箱。
经过这样的解析,最后这句话的Java代码表示就像这样:
其中Object[]是一个长度为1的数组,也就是先创建它然后将Double对象用aastore字节码放入就行。
最后写入return和visitMaxs,局部变量一共5个槽位,最大的操作栈大小是9:
下面就可以实验了!
测试结果和预测一样!
全部代码:https://paste.ubuntu.com/p/NXDfFpQ4y6/

这篇专栏的内容结束了,下一篇:Java ASM详解:MethodVisitor与Opcode(三)标签,选择结构,循环结构,栈帧
这篇文章一共讲了34个字节码,从开始到现在一共讲了164个。
有错误在评论中指出。
这篇文章稍后也同步到https://nickid2018.github.io上。