# 3.1 结构

在编译类的内部,方法的代码存储为一系列的字节码指令。为生成和转换类,最根本的就是要了解这些指令,并理解它们是如何工作的。本节将对这些指令进行全面概述,这些内容足以开始编写简单的类生成器与转换器代码。如需完整定义,应当阅读 Java 虚拟机规范。

# 3.1.1 执行模型

在介绍字节代码指令之前,有必要先来介绍 Java 虚拟机执行模型。我们知道,Java 代码是在线程内部执行的。每个线程都有自己的执行栈,栈由组成。每个帧表示一个方法调用:每次调用一个方法时,会将一个新帧压入当前线程的执行栈。当方法返回时,或者是正常返回,或者是因为异常返回,会将这个帧从执行栈中弹出,执行过程在发出调用的方法中继续进行(这个方法的帧现在位于栈的顶端)。

每一帧包括两部分:一个局部变量部分和一个操作数栈部分。局部变量部分包含可根据索引以随机顺序访问的变量。由名字可以看出,操作数栈部分是一个栈,其中包含了供字节代码指令用作操作数的值。这意味着这个栈中的值只能按照“后入先出”顺序访问。不要将操作数栈和线程的执行栈相混淆:执行栈中的每一帧都包含自己的操作数栈。

局部变量部分与操作数栈部分的大小取决于方法的代码。这一大小是在编译时计算的,并随字节代码指令一起存储在已编译类中。因此,对于对应于某一给定方法调用的所有帧,其局部变量与操作数栈部分的大小相同,但对应于不同方法的帧,这一大小可能不同。

给出了一个具有 3 帧的示例执行栈。第一帧包含 3 个局部变量,其操作数栈的最大值为 4,其中包含两个值。第二帧包含 2 个局部变量,操作数栈中有两个值。最后是第三帧,位于执行栈的顶端,包含 4 个局部变量和两个操作数。

在创建一个帧时,会将其初始化,提供一个空栈,并用目标对象 this(对于非静态方法) 及该方法的参数来初始化其局部变量。例如,调用方法 a.equals(b)将创建一帧,它有一个空栈,前两个局部变量被初始化为 a 和 b(其他局部变量未被初始化)。

局部变量部分和操作数栈部分中的每个槽 (slot) 可以保存除 longdouble 变量之外的任意 Java 值。longdouble 变量需要两个槽。这使局部变量的管理变得复杂:例如,第 i 个方法参数不一定存储在局部变量 i 中。例如,调用 Math.max(1L, 2L)创建一个帧,1L 值位于前两个局部变量槽中,值 2L 存储在第三和第四个槽中。

# 3.1.2 字节代码指令

字节代码指令由一个标识该指令的操作码和固定数目的参数组成:

  • 操作码是一个无符号字节值——即字节代码名,由助记符号标识。例如,操作码 0 用助记符号 NOP 表示,对应于不做任何操作的指令。
  • 参数是静态值,确定了精确的指令行为。它们紧跟在操作码之后给出。比如 GOTO 标记指令(其操作码的值为 167)以一个指明下一条待执行指令的标记作为参数标记。不要将指令参数与指令操作数相混淆:参数值是静态已知的,存储在编译后的代码中,而操作数值来自操作数栈,只有到运行时才能知道。

字节代码指令可以分为两类:一小组指令,设计用来在局部变量和操作数栈之间传送值;其他一些指令仅用于操作数栈:它们从栈中弹出一些值,根据这些值计算一个结果,并将它压回栈中。

ILOAD, LLOAD, FLOAD, DLOADALOAD 指令读取一个局部变量,并将它的值压到操作数栈中。它们的参数是必须读取的局部变量的索引 iILOAD 用于加载一个 booleanbytecharshortint 局部变量。LLOADFLOADDLOAD 分别用于加载longfloatdouble 值。(LLOADDLOAD 实际加载两个槽 ii+1)。最后,ALOAD 用于加载任意非基元值,即对象和数组引用。与之对应,ISTORELSTOREFSTOREDSTOREASTORE 指令从操作数栈中弹出一个值,并将它存储在由其索引 i 指定的局部变量中。

可以看到,xLOADxSTORE 指令被赋入了类型(事实上,下面将要看出,几乎所有指令都被赋予了类型)。它用于确保不会执行非法转换。实际上,将一个值存储在局部变量中,然后再以不同类型加载它,是非法的。例如,ISTORE 1ALOAD 1 序列是非法的——它允许将一个 任意内存位置存储在局部变量 1 中,并将这个地址转换为对象引用!但是,如果向一个局部变量中存储一个值,而这个值的类型不同于该局部变量中存储的当前值,却是完全合法的。这意味着一个局部变量的类型,即这个局部变量中所存值的类型可以在方法执行期间发生变化。

上面已经说过,所有其他字节代码指令都仅对操作数栈有效。它们可以划分为以下类别(见附件 A.1):

内容 描述
这些指令用于处理栈上的值:POP 弹出栈顶部的值,DUP 压入顶部栈值的一个副本, SWAP 弹出两个值,并按逆序压入它们,等等。
常量 这些指令在操作数栈压入一个常量值:ACONST_NULL 压入 null,ICONST_0 压入 int 值 0,FCONST_0 压入 0f,DCONST_0 压入 0d,BIPUSH b 压入字节值 b,SIPUSH s 压入 short 值 s,LDC cst 压入任意 int、float、long、double、String 或 class① 常量 cst,等等。
算术与逻辑 这些指令从操作数栈弹出数值,合并它们,并将结果压入栈中。它们没有任何参数。xADD、xSUB、xMUL、xDIV 和 xREM 对应于+、-、*、/和%运算,其中 x 为 I、 L、F 或 D 之一。类似地,还有其他对应于<<、>>、>>>、与、&和^运算的指令,用于处理 int 和 long 值。
类型变换 这些指令从栈中弹出一个值,将其转换为另一类型,并将结果压入栈中。它们对应于 Java 中的类型转换表达式。I2F, F2D, L2D 等将数值由一种数值类型转换为另一种类型。CHECKCAST t 将一个引用值转换为类型 t。
对象 这些指令用于创建对象、锁定它们、检测它们的类型,等等。例如,NEW type 指令将一个 type 类型的新对象压入栈中(其中 type 是一个内部名)。
字段 这些指令读或写一个字段的值。GETFIELD owner name desc 弹出一个对象引用,并压和其 name 字段中的值。PUTFIELD owner name desc 弹出一个值和一个对象引用,并将这个值存储在它的 name 字段中。在这两种情况下,该对象都必须是 owner 类型,它的字段必须为 desc 类型。GETSTATIC 和 PUTSTATIC 是类似指令,但用于静态字段。
方法 这些指令调用一个方法或一个构造器。它们弹出值的个数等于其方法参数个数加 1 (用于目标对象),并压回方法调用的结果。INVOKEVIRTUAL owner name desc 调用在类 owner 中定义的 name 方法,其方法描述符为 desc。INVOKESTATIC 用于静态方法, INVOKESPECIAL 用于私有方法和构造器,INVOKEINTERFACE 用于接口中定义的方法。最后,对于 Java 7 中的类,INVOKEDYNAMIC 用于新动态方法调用机制。
数组 这些指令用于读写数组中的值。xALOAD 指令弹出一个索引和一个数组,并压入此索引处数组元素的值。xASTORE 指令弹出一个值、一个索引和一个数组,并将这个值存储在该数组的这一索引处。这里的 x 可以是 I、L、F、D 或 A,还可以是 B、C 或 S。
跳转 这些指令无条件地或者在某一条件为真时跳转到一条任意指令。它们用于编译 if、 for、do、while、break 和 continue 指令。例如,IFEQ label 从栈中弹出一个 int 值,如果这个值为 0,则跳转到由这个 label 指定的指令处(否则,正常执行下一条指令)。还有许多其他跳转指令,比如 IFNE 或 IFGE。最后,TABLESWITCH 和LOOKUPSWITCH 对应于 switch Java 指令。
返回 最后,xRETURN 和 RETURN 指令用于终止一个方法的执行,并将其结果返回给调用者。RETURN 用于返回 void 的方法,xRETURN 用于其他方法。

# 3.1.3 示例

让我们看一些基本示例,具体体会一下字节代码指令是如何工作的。考虑下面的 bean 类:

package pkg;

public class Bean {
    private int f;

    public int getF() {
        return this.f;
    }

    public void setF(int f) {
        this.f = f;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

getter 方法的字节代码为:

ALOAD 0
GETFIELD pkg/Bean f I IRETURN
1
2

第一条指令读取局部变量 0(它在为这个方法调用创建帧期间被初始化为 this),并将这个值压入操作数栈中。第二个指令从栈中弹出这个值,即 this,并将这个对象的 f 字段压入栈中, 即 this.f。最后一条指令从栈中弹出这个值,并将其返回给调用者。图 3.2 中给出了这个方法执行帧的持续状态。

getF 方法的持续帧状态:a) 初始状态,b) 在 ALOAD 0 之后,c) 在 GETFIELD 之后

setter 方法的字节代码:

ALOAD 0
ILOAD 1
PUTFIELD pkg/Bean f I RETURN
1
2
3

和之前一样,第一条指令将 this 压入操作数栈。第二条指令压入局部变量 1,在为这个方法调用创建帧期间,以 f 参数初始化该变量。第三条指令弹出这两个值,并将 int 值存储在被引用对象的 f 字段中,即存储在 this.f 中。最后一条指令在源代码中是隐式的,但在编译后的代码中却是强制的,销毁当前执行帧,并返回调用者。这个方法执行帧的持续状态如图 3.3 所示。

setF 方法的持续状态:a) 初始状态,b) 在 ALOAD 0 之后,c)在 ILOAD 1 之后,d) 在 PUTFIELD 之后

Bean 类还有一个默认的公有构造器,由于程序员没有定义显式的构造器,所以它是由编译器生成的。这个默认的公有构造器被生成为 Bean() { super(); }。这个构造器的字节代码如下:

ALOAD 0
INVOKESPECIAL java/lang/Object <init> ()V RETURN
1
2

第一条指令将 this 压入操作数栈中。第二条指令从栈中弹出这个值,并调用在 Object 对象中定义的<init>方法。这对应于 super()调用,也就是对超类 Object 构造器的调用。在这里可以看到,在已编译类和源类中对构造器的命名是不同的:在编译类中,它们总是被命名为<init>,而在源类中,它们的名字与定义它们的类同名。最后一条指令返回调用者。

现在让我们考虑一个稍为复杂一点的 setter 方法:

public void checkAndSetF(int f) {
    if (f >= 0) {
        this.f = f;
    } else {
        throw new IllegalArgumentException();
    }
}
1
2
3
4
5
6
7

这个新 setter 方法的字节代码如下:

ILOAD 1
IFLT label
ALOAD 0
ILOAD 1
PUTFIELD pkg/Bean f I GOTO end
label:
NEW java/lang/IllegalArgumentException DUP
INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V ATHROW
end:
RETURN
1
2
3
4
5
6
7
8
9
10

第一条指令将初始化为 f 的局部变量 1 压入操作数栈。IFLT 指令从栈中弹出这个值,并将它与 0 进行比较。如果它小于(LT)0,则跳转到由 label 标记指定的指令,否则不做任何事情,继续执行下一条指令。接下来的三条指令与 setF 方法中相同。GOTO 指令无条件跳转到由 end 标记指定的指令,也就是 RETURN 指令。label 和 end 标记之间的指令创建和抛出一个异常:NEW 指令创建一个异常对象,并将它压入操作数栈中。DUP 指令在栈中重复这个值。 INVOKESPECIAL 指令弹出这两个副本之一,并对其调用异常构造器。最后,ATHROW 指令弹出剩下的副本,并将它作为异常抛出(所以不会继续执行下一条指令)。

# 3.1.4 异常处理器

不存在用于捕获异常的字节代码:而是将一个方法的字节代码与一个异常处理器列表关联在一起,这个列表规定了在某方法中一给定部分抛出异常时必须执行的代码。异常处理器类似于try catch 块:它有一个范围,也就是与 try 代码块内容相对应的一个指令序列,还有一个处理器,对应于 catch 块中的内容。这个范围由一个起始标记和一个终止标记指定,处理器由一个起始标记指定。比如下面的源代码:

public static void sleep(long d) {
    try {
        Thread.sleep(d);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
1
2
3
4
5
6
7

可被编译为:

TRYCATCHBLOCK try catch catch java/lang/InterruptedException
try:
LLOAD 0
INVOKESTATIC java/lang/Thread sleep (J)V RETURN
catch:
INVOKEVIRTUAL java/lang/InterruptedException printStackTrace ()V RETURN
1
2
3
4
5
6

Try 和 catch 标记之间的代码对应于 try 块,而 catch 标记之后的代码对应于 catch。 TRYCATCHBLOCK 行指定了一个异常处理器,覆盖了 try 和 catch 标记之间的范围,有一个开始于 catch 标记的处理器,用于处理一些异常,这些异常的类是 InterruptedException 的子类。这意味着,如果在 try 和 catch 之间抛出了这样一个异常,栈将被清空,异常被压入这个空栈中,执行过程在 catch 处继续。

# 3.1.5 帧

除了字节代码指令之外,用 Java 6 或更高版本编译的类中还包含一组栈映射帧,用于加快 Java 虚拟机中类验证过程的速度。栈映射帧给出一个方法的执行帧在执行过程中某一时刻的状态。更准确地说,它给出了在就要执行某一特定字节代码指令之前,每个局部变量槽和每个操作数栈槽中包含的值的类型。

例如,如果考虑上一节的 getF 方法,可以定义三个栈映射帧,给出执行帧在即将执行 ALOAD、即将执行 GETFIELD 和即将执行 IRETURN 之前的状态。这三个栈映射帧对应于图 3.2 给出的三种情况,可描述如下,其中第一个方括号中的类型对应于局部变量,其他类型对应于操作数栈:

如下代码之前的执行帧状态 指令
[pkg/Bean] [] ALOAD 0
[pkg/Bean] [pkg/Bean] GETFIELD
[pkg/Bean] [I] IRETURN
如下代码之前的执行帧状态 指令
[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] [未初始化(标记)] DUP
[pkg/Bean I] [Uninitialized(label) INVOKESPECIAL
Uninitialized(label)] -
[pkg/Bean I] ATHROW
[java/lang/IllegalArgumentException] -
[pkg/Bean I] [] end :
[pkg/Bean I] [] RETURN

除了 Uninitialized(label) 类型之外,它与前面的方法均类似。这是一种仅在栈映射帧中使用的特殊类型,它指定了一个对象,已经为其分配了内存,但还没有调用其构造器。参数规定了创建此对象的指令。对于这个类型的值,只能调用一种方法,那就是构造器。在调用它时, 在帧中出现的所有这一类型都被代以一个实际类型,这里是 IllegalArgumentException。栈映射帧可使用三种其他特殊类型:UNINITIALIZED_THIS 是构造器中局部变量 0 的初始类型,TOP 对应于一个未定义的值,而 NULL 对应于 null。

上文曾经说过,从 Java 6 开始,除了字节代码之外,已编译类中还包含了一组栈映射帧。为节省空间,已编译方法中并没有为每条指令包含一个帧:事实上,它仅为那些对应于跳转目标或异常处理器的指令,或者跟在无条件跳转指令之后的指令包含帧。事实上,可以轻松、快速地由这些帧推断出其他帧。

在 checkAndSetF 方法的情景中,这意味着仅存储两个帧:一个用于 NEW 指令,因为它是 IFLT 指令的目标,还因为它跟在无条件跳转 GOTO 指令之后,另一个用于 RETURN 指令,因为它是 GOTO 指令的目标,还因为它跟在“无条件跳转”ATHROW 指令之后。

为节省更多空间,对每一帧都进行压缩:仅存储它与前一帧的差别,而初始帧根本不用存储, 可以轻松地由方法参数类型推导得出。在 checkAndSetF 方法中,必须存储的两帧是相同的, 都等于初始帧,所以它们被存储为单字节值,由 F_SAME 助记符表示。可以在与这些帧相关联的字节代码指令之前给出这些帧。这就给出了 F_SAME 方法的最终字节代码:

ILOAD 1
IFLT label
ALOAD 0
ILOAD 1
PUTFIELD pkg/Bean f I GOTO end
label:
F_SAME
NEW java/lang/IllegalArgumentException DUP
INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V ATHROW
end:
F_SAME
RETURN
1
2
3
4
5
6
7
8
9
10
11
12