深入理解java虚拟机笔记(5)字节码指令

Java虚拟机的指令由一个字节(字节码总数不会超过256)的代表着某种特定操作含义的数字(称为操作码, Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数) 构成。

由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构, 所以大多数指令都不包含操作数, 只有一个操作码, 指令参数都存放在操作数栈中。

一、 字节码与数据类型

在Java虚拟机的指令集中, 大多数指令都包含其操作所对应的数据类型信息。 举个例子, iload指令用于从局部变量表中加载int型的数据到操作数栈中, 而fload指令加载的则是float类型的数据。 这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的, 但在Class文件中它们必须拥有各自独立的操作码。 对于大部分与数据类型相关的字节码指令, 它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:

i代表对int

l代表long

s代表short

b代表byte

c代表char

f代表float

d代表double

a代表reference

也有一些指令的助记符中没有明确指明操作类型的字母, 例如arraylength指令, 它没有代表数据类型的特殊字符, 但操作数永远只能是一个数组类型的对象。

还有另外一些指令, 例如无条件跳转指令goto则是与数据类型无关的指令。 表6-40列举了Java虚拟机所支持的与数据类型相关的字节码指令, 通过使用数据类型列所代表的特殊字符替换opcode列的指令模板中的T, 就可以得到一个具体的字节码指令。 如果在表中指令模板与数据类型两列共同确定的格为空, 则说明虚拟机不支持对这种数据类型执行这项操作。 例如load指令有操作int类型的iload, 但是没有操作byte类型的同类指令

 

 

 

 

大部分指令都没有支持整数类型byte、 char和short, 甚至没有任何指令支持boolean类型。 编译器会在编译期或运行期将byte和short类型的数据带符号扩展为相应的int类型数据, 将boolean和char类型数据零位扩展 为相应的int类型数据。

二、加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输, 这类指令包括:

1.将一个局部变量加载到操作栈:iload、 iload<n>、 lload、 lload<n>、 fload、 fload<n>、 dload、 dload<n>、 aload、 aload

2.将一个数值从操作数栈存储到局部变量表: istore、 istore<n>、 lstore、 lstore<n>、 fstore、 fstore<n>、 dstore、 dstore<n>、 astore、 astore<n> 3.将一个常量加载到操作数栈: bipush、 sipush、 ldc、 ldc_w、 ldc2_w、 aconst_null、 iconst_m1、 iconst<i>、 lconst<l>、 fconst<f>、 dconst

4.扩充局部变量表的访问索引的指令: wide

存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作, 除此之外, 还有少量指令如访问对象的字段或数组元素的指令也会向操作数栈传输数据。 上面所列举的指令助记符中, 有一部分是以尖括号结尾的(例如iload<n>) , 这些指令助记符实际上代表了一组指令(例如iload<n>, 它代表了iload_0、 iload_1、 iload_2和iload_3这几条指令) 。 这几组指令都是某个带有一个操作数的通用指令(例如iload) 的特殊形式, 对于这几组特殊指令, 它们省略掉了显式的操作数, 不需进行取操作数的动作, 因为实际上操作数就隐含在指令中。 除了这点不同以外, 它们的语义与原生的通用指令是完全一致的(例如iload_0的语义与操作数为0时的iload指令语义完全一致) 。 这种指令表示方法, 在本书和《Java虚拟机规范》 中都是通用的。

三、运算指令

算术指令用于对两个操作数栈上的值进行某种特定运算, 并把结果重新存入到操作栈顶。 大体上运算指令可以分为两种: 对整型数据进行运算的指令与对浮点型数据进行运算的指令。 整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现。

无论是哪种算术指令, 均是使用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 ·位移指令: ishl、 ishr、 iushr、 lshl、 lshr、 lushr ·按位或指令: ior、 lor ·按位与指令: iand、 land ·按位异或指令: ixor、 lxor ·局部变量自增指令: iinc ·比较指令: dcmpg、 dcmpl、 fcmpg、 fcmpl、 lcmp

如果某个操作结果没有明确的数学定义的话,将会使用NaN(Not a Number) 值来表示。 所有使用NaN值作为操作数的算术操作, 结果都会返回NaN。 在对long类型数值进行比较时, Java虚拟机采用带符号的比较方式, 而对浮点数值进行比较时(dcmpg、 dcmpl、 fcmpg、 fcmpl) , 虚拟机会采用IEEE 754规范所定义的无信号比较方式进行。

四、类型转换指令

类型转换指令可以将两种不同的数值类型相互转换, 这些转换操作一般用于实现用户代码中的显式类型转换操作, 或者用来处理本节开篇所提到的字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。 Java虚拟机直接支持以下数值类型的宽化类型转换( 即小范围类型向大范围类型的安全转换) :

1.int类型到long、 float或者double类型

2.long类型到float、 double类型

3.float类型到double类型

与之相对的, 处理窄化类型转换时, 就必须显式地使用转换指令来完成, 这些转换指令包括i2b、 i2c、 i2s、 l2i、 f2i、 f2l、 d2i、 d2l和d2f。 窄化类型转换可能会导致转换结果产生不同的正负号、 不同的数量级的情况, 转换过程很可能会导致数值的精度丢失。

在将int或long类型窄化转换为整数类型T的时候, 转换过程仅仅是简单丢弃除最低位N字节以外的内容, N是类型T的数据类型长度, 这将可能导致转换结果与输入值有不同的正负号。 对于了解计算机数值存储和表示的程序员来说这点很容易理解, 因为原来符号位处于数值的最高位, 高位被丢弃之后, 转换结果的符号就取决于低N字节的首位了。 Java虚拟机将一个浮点值窄化转换为整数类型T(T限于int或long类型之一) 的时候, 必须遵循以下转换规则:

1.如果浮点值是NaN, 那转换结果就是int或long类型的0。

2.如果浮点值不是无穷大的话, 浮点值使用IEEE 754的向零舍入模式取整, 获得整数值v。 如果v在目标类型T(int或long) 的表示范围之类, 那转换结果就是v; 否则, 将根据v的符号, 转换为T所能表示的最大或者最小正数。 3.从double类型到float类型做窄化转换的过程与IEEE 754中定义的一致, 通过IEEE 754向最接近数舍入模式舍入得到一个可以使用float类型表示的数字。 如果转换结果的绝对值太小、 无法使用float来表示的话, 将返回float类型的正负零; 如果转换结果的绝对值太大、 无法使用float来表示的话, 将返回float类型的正负无穷大。 对于double类型的NaN值将按规定转换为float类型的NaN值。

尽管数据类型窄化转换可能会发生上限溢出、 下限溢出和精度丢失等情况, 但是《Java虚拟机规范》 中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常

五、对象创建与访问指令

虽然类实例和数组都是对象, 但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令 对象创建后, 就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素, 这些指令包括:

1.创建类实例的指令: new

2.创建数组的指令: newarray、 anewarray、 multianewarray

3.访问类字段(static字段) 和实例字段(实例变量) 的指令: getfield、 putfield、 getstatic、 putstatic

4.把一个数组元素加载到操作数栈的指令: baload、 caload、 saload、 iaload、 laload、 faload、 daload、 aaload

5.将一个操作数栈的值储存到数组元素中的指令: bastore、 castore、 sastore、 iastore、 fastore、 dastore、 aastore

6.取数组长度的指令: arraylength

7.检查类实例类型的指令: instanceof、 checkcast

六、操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样, Java虚拟机提供了一些用于直接操作操作数栈的指令, 包括:

1.将操作数栈的栈顶一个或两个元素出栈: pop、 pop2

2.复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶: dup、 dup2、 dup_x1、dup2_x1、 dup_x2、 dup2_x2

3.将栈最顶端的两个数值互换: swap

七、控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令) 的下一条指令继续执行程序, 从概念模型上理解, 可以认为控制指令就是在有条件或无条件地修改PC寄存器的值。 控制转移指令包括:

1.条件分支: ifeq、 iflt、 ifle、 ifne、 ifgt、 ifge、 ifnull、 ifnonnull、 if_icmpeq、 if_icmpne、 if_icmplt、if_icmpgt、 if_icmple、 if_icmpge、 if_acmpeq和if_acmpne

2.复合条件分支: tableswitch、 lookupswitch

3.无条件分支: goto、 goto_w、 jsr、 jsr_w、 ret

在Java虚拟机中有专门的指令集用来处理int和reference类型的条件分支比较操作, 为了可以无须明显标识一个数据的值是否null, 也有专门的指令用来检测null值。

与前面算术运算的规则一致, 对于boolean类型、 byte类型、 char类型和short类型的条件分支比较操作, 都使用int类型的比较指令来完成, 而对于long类型、 float类型和double类型的条件分支比较操作, 则会先执行相应类型的比较运算指令(dcmpg、 dcmpl、 fcmpg、 fcmpl、 lcmp) , 运算指令会返回一个整型值到操作数栈中, 随后再执行int类型的条件分支比较操作来完成整个分支跳转。

由于各种类型的比较最终都会转化为int类型的比较操作, int类型比较是否方便、 完善就显得尤为重要, 而Java虚拟机提供的int类型的条件分支指令是最为丰富、 强大的。

八、方法调用和返回指令

方法调用(分派、 执行过程) 将在第8章具体讲解, 这里仅列举以下五条指令用于方法调用:

1.invokevirtual指令: 用于调用对象的实例方法, 根据对象的实际类型进行分派(虚方法分派) ,这也是Java语言中最常见的方法分派方式。

2.invokeinterface指令: 用于调用接口方法, 它会在运行时搜索一个实现了这个接口方法的对象, 找出适合的方法进行调用。

3.invokespecial指令: 用于调用一些需要特殊处理的实例方法, 包括实例初始化方法、 私有方法和父类方法。

4.invokestatic指令: 用于调用类静态方法(static方法) 。

5.invokedynamic指令: 用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。 前面四条调用指令的分派逻辑都固化在Java虚拟机内部, 用户无法改变, 而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

方法调用指令与数据类型无关, 而方法返回指令是根据返回值的类型区分的, 包括ireturn(当返回值是boolean、 byte、 char、 short和int类型时使用) 、 lreturn、 freturn、 dreturn和areturn, 另外还有一条return指令供声明为void的方法、 实例初始化方法、 类和接口的类初始化方法使用

九、异常处理指令

在Java程序中显式抛出异常的操作(throw语句) 都由athrow指令来实现, 除了用throw语句显式抛出异常的情况之外, 《Java虚拟机规范》 还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。 例如前面介绍整数运算中, 当除数为零时, 虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。

而在Java虚拟机中, 处理异常(catch语句) 不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现, 现在已经不用了) , 而是采用异常表来完成。

十、同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步, 这两种同步结构都是使用锁(Monitor) 来实现的。 方法级的同步是隐式的, 无须通过字节码指令来控制, 它实现在方法调用和返回操作之中。 虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。

当方法调用时, 调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置, 如果设置了, 执行线程就要求先成功持有管程, 然后才能执行方法, 最后当方法完成(无论是正常完成还是非正常完成) 时释放管程。

在方法执行期间, 执行线程持有了管程, 其他任何线程都无法再获取到同一个管程。 如果一个同步方法执行期间抛出了异常, 并且在方法内部无法处理此异常, 那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。

同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的, Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义, 正确实现synchronized关键字 需要Javac编译器与Java虚拟机两者共同协作支持

编译器必须确保无论方法通过何种方式完成, 方法中调用过的每条monitorenter指令都必须有其对应的monitorexit指令, 而无论这个方法是正常结束还是异常结束

posted @ 2022-03-18 10:22  Mars.wang  阅读(239)  评论(0编辑  收藏  举报