Java 虚拟机的指令(方法表的属性表中的 Code 属性保存的字节码流)由一个字节长度的、有某种特定操作含义的数字(Opcode)以及跟随其后的零至多个该操作所需的参数(Operand)构成。由于 Java 虚拟机采用的是面向操作数栈的架构,所以大多数指令都只有一个操作码,指令参数都存放在操作数栈中。
如果不考虑异常处理,那 Java 虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模型来理解:
do {
自动计算PC寄存器的值加1;
根据PC寄存器指示的位置,从字节码流中取出操作码;
if(字节码存在操作数) {
从字节码流中取出操作数;
}
执行操作码所定义的操作;
} while (字节码流长度 > 0);
字节码与数据类型
在 Java 虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。举个例子,iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class 文件中它们必须拥有各自独立的操作码。
但 Java 虚拟机的操作码长度只有一字节,所以包含了数据类型的操作码为指令集的设计带来了很大压力。因此 Java 虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。
实际上,大部分指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。编译器会在编译期或运行期将 byte 和 short 类型的数据带符号扩展为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展为相应的 int 类型数据。因此,boolean 类型的字段在内存中也占用 4 个字节。
但是在处理 byte、short 和 char 类型的数组时,JVM 提供了对应的指令进行操作,如:baload、saload、caload 指令。这里仍旧没有为 boolean 类型的数组提供单独的指令,但由于提供了 byte 数组相关的指令,所以 boolean 数组会转化为 byte 数组,这样 boolean 数组中的每个值只占用 1 个字节。
boolean 类型占用的字节数:https://zhuanlan.zhihu.com/p/138648453
字节码相关指令
1. 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括:
- 将一个局部变量加载到操作栈:iload、iload
、lload、lload 、fload、fload 、dload、dload 、aload、aload_ - 将一个数值从操作数栈存储到局部变量表:istore、istore
、lstore、lstore 、fstore、fstore 、dstore、dstore 、astore、astore_ - 将一个常量加载到操作数栈:bipush、sipush、ldc、ldcw、ldc2_w、aconst_null、iconst_m1、iconst、lconst
、fconst 、dconst_ - 扩充局部变量表的访问索引的指令:wide
存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
2. 运算指令
算术指令 用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。大体上运算指令可以分为两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令。
整数与浮点数的算术指令在溢出和被零除时有各自不同的行为表现。但无论哪种算术指令,均是使用 Java 虚拟机的算术类型来进行计算的,因此是不存在直接支持 byte、short、char 和 boolean 类型的算术指令,对于上述几种数据的运算,应使用操作 int 类型的指令代替。
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 取反指令:ineg、lneg、fneg、dneg
- ……
数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能会是一个负数,这种数学上不可能出现的溢出现象,对于程序员来说是很容易理解的,但其实《Java 虚拟机规范》中并没有明确定义过整型数据溢出具体会得到什么计算结果,仅规定了在处理整型数据时,只有除法指令及求余指令中当出现除数为零时会导致虚拟机抛出 ArithmeticException 异常,其余任何整型数运算场景都不应该抛出运行时异常。
3. 类型转换指令
类型转换指令 可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理上面提到的字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
Java 虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换,即小范围类型向大范围类型的安全转换:
- int 类型到 long、float 或者 double 类型
- long 类型到 float、double 类型
- float 类型到 double 类型
与之相对的,处理窄化类型转换时,就必须显式地使用转换指令来完成,窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
在将 int 或 long 类型窄化转换为整数类型 T 的时候,转换过程仅仅是简单丢弃除最低位 N 字节以外的内容,N 是类型 T 的数据类型长度,这将可能导致转换结果与输入值有不同的正负号。因为原来符号位处于数值的最高位,高位被丢弃之后,转换结果的符号就取决于低 N 字节的首位了。
尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是《Java 虚拟机规范》中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
4. 对象创建与访问指令
虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令包括:
- 创建类实例的指令:new
- 创建数组的指令:newarray、anewarray、multianewarray
- 访问类字段和实例字段的指令:getfield、putfield、getstatic、putstatic
- 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
- 将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
- 取数组长度的指令:arraylength
- 检查类实例类型的指令:instanceof、checkcast。前者用来判断给定对象是否是某一个类的实例,后者用于检查类型强制转换是否可以进行。
5. 控制转移指令
控制转移指令 可以让 Java 虚拟机有条件或无条件地从指定位置指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改 PC 寄存器的值。控制转移指令包括:
- 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne
- 复合条件分支:tableswitch、lookupswitch
- 无条件分支:goto、goto_w、jsr、jsr_w、ret
与前面算术运算的规则一致,对于 boolean、byte、char 和 short 类型的条件分支比较操作,都使用 int 类型的比较指令来完成,而对于 long、float 和 double 类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行 int 类型的条件分支比较操作来完成整个分支跳转。
6. 方法调用和返回指令
- invokevirtual:用于调用对象的实例方法。
- invokeinterface:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- invokestatic:用于调用类静态方法。
- invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。
除 invokedynamic 外,其他的方法调用指令所消耗的操作数栈元素是根据调用类型以及目标方法描述符来确定的。在进行方法调用前,程序需要依次压入调用者(invokestatic 不需要)以及各个参数。
方法返回指令:
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法、类和接口的类初始化方法使用。
7. 异常处理指令
在 Java 程序中显式抛出异常的操作(throw)都由 athrow 指令来实现,此外,《Java 虚拟机规范》还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。例如前面介绍整数运算中,当除数为零时,虚拟机会在 idiv 或 ldiv 指令中抛出 ArithmeticException 异常。
而在 Java 虚拟机中,处理异常(catch 语句)不是由字节码指令来实现的,而是采用异常表来完成。
8. 同步指令
Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用 管程(Monitor)来实现的。
8.1 方法同步
当用 synchronized 标记方法时,方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的 方法表 结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否被声明为同步方法,例如下面的示例代码:
public synchronized void foo(Object lock) {
lock.hashCode();
}
反编译后的字节码:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了则执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。这里的锁对象是隐式的,对于实例方法来说,锁对象是 this;对于静态方法来说,锁对象则是所在类的 Class 实例。
在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
8.2 代码块同步
同步一段指令集序列通常是由 Java 语言中的 synchronized 语句块来表示的,Java 虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义,正确实现 synchronized 关键字需要 Javac 编译器与 Java 虚拟机两者共同协作支持,例如下面的示例代码:
public void test(String flag) {
synchronized (flag) {
inc(1, 3);
}
}
通过 javac 编译后生成的字节码序列如下:
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须有其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为此,编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。