简介
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。
字节码指令集可算是一种具有鲜明特点、优势和劣势均很突出的指令集架构,由于限制了 Java 虚拟机操作码的长度为一个字节(即 0 ~ 255),这意味着指令集的操作码总数不能够超过 256 条;又由于 Class 文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构,,譬如要将一个 16 位长度的无符号整数使用两个无符号字节存储起来,需要采用 byte1 << 8 | byte2 这样的形式。这种操作在某种程度上会导致解释执行字节码时将损失一些性能,但这样做的优势也同样明显:放弃了操作数长度对齐,就意味着可以省略掉大量的填充和间隔符号;用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。这种追求尽可能小数据量、高传输效率的设计是由 Java 语言设计之初主要面向网络、智能家电的技术背景所决定的,并一直沿用至今。
字节码与数据类型
在 Java 虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。举个例子,iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class 文件中它们必须拥有各自独立的操作码。
因为 Java 虚拟机的操作码长度只有一字节,所以包含了数据类型的操作码就为指令集的设计带来了很大的压力:如果每一种与数据类型相关的指令都支持 Java 虚拟机所有运行时数据类型的话,那么指令的数量恐怕就会超出一字节所能表示的数量范围了。Java 虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,即指令集将会被故意设计成非完全独立的。
大部分指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。编译器会在编译期或运行期将 byte 和 short 类型的数据带符号扩展(Sign-Extend)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据。与之类似,在处理 boolean、byte、short 和 char 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理。
加载和存储指令
存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。大体上运算指令可以分为两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令。整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现。
数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能会是一个负数,这种数学上不可能出现的溢出现象,对于程序员来说是很容易理解的,但其实《Java 虚拟机规范》中并没有明确定义过整型数据溢出具体会得到什么计算结果,仅规定了在处理整型数据时,只有除法指令(idiv 和 ldiv)以及求余指令(irem 和 lrem)中当出现除数为零时会导致虚拟机抛出 ArithmeticException 异常,其余任何整型数运算场景都不应该抛出运行时异常。
虚拟机实现在处理浮点数时,必须严格遵循 IEEE 754 规范中所规定行为和限制。什么是 IEEE 754 读者可以自行查阅资料,在一般的计算机组成原理教材中都会有详细阐述。
类型转换指令
类型转换指令可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换:
- int 类型到 long、float 或者 double 类型
- long 类型到 float、double 类型
- float 类型到 double 类型
处理窄化类型转换(Narrowing Numeric Conversion)时,就必须显式地使用转换指令来完成,这些转换指令包括 i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f。
对象创建与访问指令
虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令包括:
操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:
控制转移指令
控制转移指令可以让 Java 虚拟机有条件或无条件地从指定位置指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改 PC 寄存器的值。
在 Java 虚拟机中有专门的指令集用来处理 int 和 reference 类型的条件分支比较操作,为了可以无须明显标识一个数据的值是否 null,也有专门的指令用来检测 null 值。
方法调用和返回指令
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法、类和接口的类初始化方法使用。
- invokevirtual 指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚
方法分派),这也是 Java 语言中最常见的方法分派方式。 - invokeinterface 指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial 指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- invokestatic 指令:用于调用类静态方法(static 方法)。
- invokedynamic 指令:用于在运行时动态解析出调用点限定符所引用的方法。并执
行该方法。前面四条调用指令的分派逻辑都固化在 Java 虚拟机内部,用户无法改变,而 - invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。
异常处理指令
在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现,除了用 throw 语句显式抛出异常的情况之外,《Java 虚拟机规范》还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。
而在 Java 虚拟机中,处理异常(catch 语句)不是由字节码指令来实现的(很久之前曾经使用 jsr 和 ret 指令来实现,现在已经不用了),而是采用异常表来完成。
同步指令
Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
同步一段指令集序列通常是由 Java 语言中的 synchronized 语句块来表示的,Java 虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义。