3.1 ASM-方法-结构
本文可转载演绎,但需要注明原作者和本文链接。
ASM-方法-结构
本章将会介绍如果使用ASM core API生成或者转换Java编译后的method。
本将开始会展示编译后的method,然后使用很多说明示例,展示相应的ASM接口、组件和工具类,生成或者转换编译后的method。
3.1 结构
编译类中的方法代码会存储成一系列的字节码指令集。
为了生成和转换class,需要知道这些指令集的基础原理,并且了解它们是如何工作的。
本节会给出这些指令的概述,足够生成和转换一些简单的class。
如果需要完整的指令集介绍,可以阅读Java虚拟机规范。JVM 8 指令集
在介绍字节码指令集前,有必要介绍一下Java虚拟机执行模型。
众所周知,Java代码在线程中执行。每一个线程有它独立的执行栈,由栈帧组成。
每个栈帧都代表一个方法的调用:每次方法被调用,都会在当前的线程执行栈中压入一个新的栈帧。
当方法返回时,不论是正常结束还是抛出异常,这个栈帧都会从线程栈中弹出,然后执行线程栈栈顶(在线程执行栈最上面的栈帧)的方法调用。
每个栈帧包含两部分:局部变量部分和操作数栈部分。
本地变量部分包括可以通过索引随机访问的变量。
顾名思义,操作数栈是一个由操组字节码指令集时,需要的值所组成的栈。这意味着,这些值只能通过后进先出(LIFO, Last In First Out)的顺序访问。
不要将操作数栈和线程执行栈混淆了:每个栈帧在线程执行栈中都要自己的操作数栈。
本地变量区和操作数栈的大小取决于方法的代码。
它们的大小会在编译阶段计算得出,并且和字节码指令集一起存储在编译后的class中。
因此,对于一个相同的方法,所有调用所产生的栈帧大小是相同的。
但不同方法所对应栈帧中的本地变量区和操作数栈大小,可能是不同的。
图表 3.1展示了一个由三个栈帧组成的执行栈。
第一个栈帧有用3个本地变量,操作数栈最大size是4,但当前只存放了2个值。
第二个栈帧有用2个本地变量,操作数栈最大size是3,但当前只存放了2个值。
第三个栈帧,在执行栈的栈顶,有用4个本地变量,操作数栈最大size是2,当前存放了2个值。
当栈帧被创建的时候,和初始化一个空的操作数栈,但本地变量区会存放该对象本身this(对于非静态方法)和方法的参数。
例如,调用方法‘a.equals(b)’会创建一个空的操作数栈,和一个按照‘a’、‘b’顺序组成的本地变量区(其他本地变量都是未初始化的)。
每个本地方法区和操作数栈的插槽(slot)都可以容纳任意Java的值,除了long和double的值。
long和double的值需要两个插槽。
这就使本地变量区的管理变得复杂了,即:第i个方法参数可能不存储在本地变量区的第i个插槽。
例如:调用方法‘Math.max(1L,2L)’会创建一个栈帧,本地变量区的第1个和第2个插槽存放1L,第3个和第4个插槽存放2L。
3.1.2 字节码指令集
一个字节码指令是有一个标识该指令的操作码和固定数目的参数组成:
- 操作码是一个无符号的byte值,因此字节码的名字,被一个助记符标识。例如,操作码值为0的助记符是NOP,对应的指令是什么也不操作。
- 参数是定义精确指令行为的静态值。它们会在操作码后给出。比如GOTO指令,操作码值是167,使用label作为参数,该参数指定了下一个执行的字节码指令。指令参数不要与指令操作数混淆:参数是静态已知的,存储在编译后的代码中,然而操作数是从操作栈中获取,只有在运行时才能知道。
字节码指令可以分为两种类型:一小部分指令被设计成从本地变量中传递数据给操作数栈,反之亦然;另一部分指令集仅作用在操作数栈上:从栈中弹出一些值,根据这些值计算出结果,并压入栈中。
ILOAD、LLOAD、FLOAD、DLOAD和ALOAD指令是读取一个本地变量,并压入到操作数栈。
这些指令所使用必须读取的参数索引变量i。
ILOAD可以用于读取一个boolean、byte、 char、 short、或者int类型的本地变量。
LLOAD、FLOAD和DLOAD则分别读取long、float和double类型的值,(LLOAD和DLOAD实际会读取本地变量的两个插槽:’i’和’i+1’)。
最后ALOAD用于加载非原数据类型的值,例如,对象或者数组引用。
相应的ISTORE、LSTORE、FSTORE、DSTORE和ASTORE指令会从操作数栈中弹出一个值,并存放在本地变量指定’i’索引的插槽中。
正如所见,xLOAD和xSTORE都是类型相关的(实际上,接下来也会看到,几乎所有的指令都是类型相关的)。
这是用于确保没有非法的转换操作。
事实上,存放一个值到本地变量区,用与其不同的类型读取这个变量是非法的。
比如,由‘ISTORE 1’和‘ALOAD 1’组成的执行序列是非法的,如果这样就会允许存储任意一个内存地址到本地变量区,然后将其转变为一个对象引用!
然而存放一个值到本地变量区,但该值的类型与当前本地变量区已经存放值的类型是不同,这是完全合法的(请找出语病:然而存放一个不同于当前本地变量区已经存放值的类型的值是完全合法的)。
这意味着一个本地变量的类型,即存储在该处的值的类型,可以随着方法的执行变化。
如上所诉,所有其他字节码都在操作栈上执行。它们被归为以下几类(参照 附录 A.1)
Stack:栈
这些指令被使用来对栈中的值进行操作:
- POP指令会弹出栈顶的值
- DUP指令会压入栈顶值的拷贝到栈上
- SWAP指令会弹出栈顶的两个值,按照相反的顺序压入栈中。
Constants:常量
这些指令将一个常量压入到操作栈中:
- ACONST_NULL: 将null压入到栈顶中。
- ICONST_0:将0压入到栈中。
- FCONST_0:将0f压入到栈中。
- DCONST_0:将0d压入到栈中。
- BIPUSH b:将类型为byte的值b压入到栈中。
- SIPUSH s:将类型为short的值s压入到栈中。
- LDC cst:将任意类型为int、float、long、double、String或者class(此处class对应的是Java语法中对应的xxx.class,例如:java.lang.String.class)的常量值cst压入到操作栈。
Arithmetic and logic:算术和逻辑
这些指令从操作栈中取出数值进行运算后,把计算结果再压入栈中。不需要任何参数。
- xADD、xSUB、xMUL、xDIV和xREM指令对应了‘+’、‘-‘、‘*‘、‘/‘和‘%’操作,其中‘x‘可以被替换成‘I’、‘L’、‘F’或‘D’。与之类似的,还有对于int和long类型的操作‘<<’、‘>>’、‘>>>’、‘|’、‘&’和‘^’,也有相应的指令。
Casts:类型转换
这些指令会将值从栈顶弹出,转换成其他类型后,再将结果压入栈中。这些指令于Java中的转换表达式相对应。例如I2F、F2D、L2D等,将数值型的值,从一个类型转换成另一个类型。‘CHECKCAST t’将一个引用值转换成类型t。
Objects:对象
这些指令由于创建对象、加锁对象、测试对象的类型等。例如‘NEW type‘指令,会创建一个类型为‘type‘的对象并压入栈顶(‘type‘是类的内部名)。
Fields:属性
这些指令用于读写一个field。
- GETFIELD owner name desc会弹出一个对象引用,然后压入该对象名为name的field到栈顶。
- PUTFIELD owner name desc会弹出一个值和一个对象引用,然后将该值存放在该对象名为name的field上。
在这两个示例中,所有的对象必须是owner类型,field的类型必须是desc。
GETSTATIC和PUTSTATIC两个指令和上面的类似,只不过针对的是静态属性。
Methods:方法
这些指令会调用一个方法或者一个构造函数。这些指令会弹出和方法参数一样多的值,加一个目标对象的值,然后把方法结果压入操作栈。
- INVOKEVIRTUAL owner name desc会调用在class owner中名为name的方法,该方法的描述符是desc。
- INVOKESTATIC用于调用静态方法。
- INVOKESPECIAL用于调用私有方法和构造函数。
- INVOKEINTERFACE用于接口中定义的方法。
- INVOKEDYNAMIC是在Java 7中定义的,支持非Java语言,尤其是动态语言,用于新的动态方法调用机制。
Arrays:数组
这些指令用于读写数组的值。
- xALOAD指令会弹出一个索引值‘index’和一个数组‘array’,然后将数组中索引为‘index’的元素压入栈顶。
- xASTORE指令会弹出一个值、一个索引值‘index’和一个数组‘array’,然后将该值存储在索引值为‘index’的数组‘array’中。
上面两条指令的‘x‘可以设置为‘I’、‘L’、‘F’、‘D’、‘A’、‘B’、‘C’或‘S’。
Jumps:跳转
这些指令会在某些条件为‘true’的情况下跳转到任意指令,或者无条件的跳转。
这些跳转用于‘if’、‘for’、‘do’、‘while’、‘break’和‘continue’操作。
例如,‘IFEQ label‘,会从栈顶弹出一个‘int’值,如果该值为‘0’,则会跳转到指令为‘label’处;如果该值为其他情况,程序会继续按顺序执行下去。
还存在很多其他跳转指令,例如‘IFNE’、‘IFGE’。
最后‘TABLESWITCH’和‘LOOKUPSWITCH’指令对应Java源码中的‘switch’。
Return:返回
最后‘xRETURN’和‘RETURN’使用结束一个方法的执行,并且返回一个结果给该方法的调用者。
‘RETURN’用于返回值为‘void’的方法,‘xRETURN’则用于其他返回值的方法。
3.1.3 示例
介绍一些基本的例子,跟加具体的了解以下字节码指令如何工作。考虑下面的Bean类:
‘getter’方法的字节码如下:
第一个指令读取本地变量中索引为0的值,当方法执行帧创建的初始化后,会将this压入栈顶。
第二个指令从栈中弹出this该值,并将该对象的属性f(即this.f)压入到栈顶。
最后一个指令,将栈顶的值this.f弹出,并返回给调用者。该方法执行的连续执行帧情况如下面表格3.2所示。
‘setter’方法的字节码如下:
如上面一样,第一个指令将this压入到栈顶。
第二个指令将本地变量索引1的值压入栈顶,在该方法执行帧初始化的时候,参数‘f’存放在该本地变量处。
第三个指令弹出栈顶的两个值,并且将int型的值存放在该对象的属性f上,即this.f。
最后一个指令,在代码中是隐式的,但在编译后的类中是强制的,作用是销毁当前的执行帧并且返回结果给调用方。
该方法的连续执行帧如下图表3.3所示。
Bean类还有一个默认的构造函数,由于没有在源码中定义任何构造函数,此处由编译器生成。
默认生成的构造函数是‘Bean() { super(); }’。字节码指令集如下所示:
第一个指令将this压入到栈顶。
第二个指令将该值从栈顶弹出,并调用Object类中定义的
这对应代码中的super()调用,即调用其父类Object的构造函数。
可以看出,构造函数的命名在源码和编译后字节码中是不同的:在编译后的类中都叫
最后一个指令,返回到调用者。
现在,让我们看一个稍微复杂点的setter方法:
新setter方法编译后的字节码如下:
第一个指令将本地变量索引是1的值压入操作栈顶,即初始化的方法参数f。
IFLT指令会从栈中弹出这个值,并于’0’进行比较。
如果它比0小(‘LT’),程序就会跳转到为‘label’的标签处执行,否则程序会顺序执行下一个指令。
接下来的三条指令和”setF”方法的指令一样。
‘GOTO’指令无条件的跳转到由程序指定的‘end’标签处,即RETURN指令。
在‘label’标签和‘end’标签之间的指令,创建并抛出了一个异常:‘NEW’指令创建了一个异常对象,并把这个对象压入到操作栈。
‘DUP’指令复制一份该值在操作栈上。
INVOKESPECIAL指令弹出栈顶其中一个值,并调用exception的构造函数。
最后,‘ATHROW’指令弹出栈顶保留的另一个值,并作为异常抛出(因此程序不会继续执行该方法的下一个指令。)。
3.1.4. 异常处理器 Exception Handlers
没有字节码指令来捕获异常:相对的,是由一系列异常处理器关联方法的字节码,来指定一个给定的部分如果抛出异常后,必须执行的代码。
一个异常处理器类似于一个‘try’ ‘catch’代码块:它有一个范围,即一系列指令集对应源码中‘try’代码块中的内容,它有一个处理(handler),对应源码中‘catch’代码块中的内容。
这个范围指定了开始和结束的标签(label),和一个有用开始标签(label)的处理器(handler)。
例如,源码如下:
编译后的指令集如下:
编译后的指令码中‘try’和‘catch’标签之间的指令对应源码中try的代码块。
‘TRYCATCHBLOCK’指令行,指定了异常处理器,包括在try和cache标签之间的覆盖范围,和一个处理器的开始标签‘catch’,并且指定了处理InterruptedException异常以及其子异常。
这意味着,如果在try和cache标签之间任何地方抛出该种异常,方法栈都会被清空,这个异常会压入一个空的执行栈中,程序会从catch标签处继续执行。
3.1.5. 帧 Frames
使用Java 6或者更高版本编译的class,除了包含字节码指令集以外,还包含了一组栈哈希帧(a set of stack map frames),用于在Java虚拟机内部加速类验证的速度。
一个栈哈希帧给出了方法执行帧在执行过程中某个位置的状态。
更确切地说,它给出了在某些特殊的字节码指令执行之前,每一个本地变量槽(slot)和方法操作栈槽(slot)中存放的值所对应的类型。
例如,参考上一节中的‘getF’方法,我们可以定义三个栈哈希帧,表示执行帧的三种状态:在‘ALOAD’指令执行前、在‘GETFIELD’指令执行前、在‘IRETURN’指令执行前.
这三个栈哈希帧对应了‘图表3.2’的三种情况,描述如下,第一个方括号中的类型对应本地变量,第二个对应操作栈:
在XXX指令执行前的状态 | 指令 |
---|---|
[pkg/Bean] [] | ALOAD 0 |
[pkg/Bean] [pkg/Bean] | GETFIELD |
[pkg/Bean] [I] | IRETURN |
也可以这样描述‘checkAndSetF’方法:
在XXX指令执行前的状态 | 指令 |
---|---|
[pkg/Bean I] [] | ILOAD 1 |
[pkg/Bean I] [I] | IFLT label |
[pkg/Bean I] [] | ALOAD 0 |
[pkg/Bean I] [pkg/Bean] | ILOAD 1 |
[pkg/Bean I] [pkg/Bean I] | PUTFIELD |
[pkg/Bean I] [] | GOTO end |
[pkg/Bean I] [] | label : |
[pkg/Bean I] [] | NEW |
[pkg/Bean I] [Uninitialized(label)] | DUP |
[pkg/Bean I] [Uninitialized(label) Uninitialized(label)] | INVOKESPECIAL |
[pkg/Bean I] [java/lang/IllegalArgumentException] | ATHROW |
[pkg/Bean I] [] | end : |
[pkg/Bean I] [] | RETURN |
这和前面的方法类似,除了‘Uninitialized(label)’这种类型。
这种特殊的类型仅仅用在栈哈希帧上(stack map frames),用于指定一个对象已经被分配了内存,但是还没有调用构造函数。
该参数指定了创建该对象的指令。
对于该类型的值,唯一可能调用的就是构造函。
当调用构造函数时,帧中所有该类型出现的地方都会被替换成真正的类型,在这里就是IllegalArgumentException。
栈哈希帧还可以使用其他三种特殊类型:
- ‘UNINITIALIZED_THIS’:是在构造函数中表示本地变量第’0’个变量的初始类型(因为方法中本地变量的第0个槽中应当存放‘this’,但此时本对象尚未初始化,所以使用该特殊类型)。
- ‘TOP’:对应一个未定义的值。
- ‘NULL’:对应null值。
如上所诉,从Java 6开始,编译后的类中,除了包含字节码外,还包含了一组栈哈希帧。
为了节省空间,一个编译的方法不会包含一个指令一帧的情况:实际上它仅包含了由跳转到目标地址、异常处理或者无条件跳转指令集组成的帧。
事实上,其他帧可以方便、快速地从这些帧上推断出来。
在‘checkAndSetF’方法的中,仅有两种帧存在:一个是‘NEW’指令集,因为他是‘IFLT’指令的目标,而且还因为它紧跟着无条件跳转指令‘GOTO’,另一个是‘RETURN’指令,因为它的目标是‘GOTO’指令,并且它紧跟着“无条件跳转指令”‘ATHROW’。
为了节省更多的空间,每一帧都仅仅压缩存储于前一帧的不同之处,并且初始帧完全不被存储,因为它很容易从方法参数类型推导出来。
在‘checkAndSetF’方法中的两帧存储必须相同,并且和初始帧相同,因此它们仅存放为一个字节的‘F_SAME’助记符。
这些帧仅可以在它们关联的字节码指令前展现。下面给出了‘checkAndSetF’最终的字节码: