JVM高级特性与实践(七):九大类字节码指令集(实例探究 )

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)已经跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)构成。

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

此篇博文将介绍九大类字节码指令集,并给出代码测试,对比字节码序列来加深指令学习,大致知识点如下:

  • 字节码概念
  • 字节码所支持的数据类型
  • 九大类字节码指令集
  • 各指令集的作用、组成、重点
  • 部分指令集的字节码序列实践

JVM高级特性与实践(一):Java内存区域 与 内存溢出异常
JVM高级特性与实践(二):对象存活判定算法(引用) 与 回收
JVM高级特性与实践(三):垃圾收集算法 与 垃圾收集器实现
JVM高级特性与实践(四):内存分配 与 回收策略
JVM高级特性与实践(五):实例探究Class类文件 及 常量池
JVM高级特性与实践(六):Class类文件的结构(访问标志,索引、字段表、方法表、属性表集合)
JVM高级特性与实践(八):虚拟机的类加载机制
JVM高级特性与实践(九):类加载器 与 双亲委派模式(自定义类加载器源码探究ClassLoader)
JVM高级特性与实践(十):虚拟机字节码执行引擎(栈帧结构)
JVM高级特性与实践(十一):方法调用 与 字节码解释执行引擎(实例解析)
JVM高级特性与实践(十二):Java内存模型 与 高效并发时的内外存交互(volatile变量规则)
JVM高级特性与实践(十三):线程实现 与 Java线程调度
JVM高级特性与实践(十四):线程安全 与 锁优化


一. 字节码与数据类型

1. 字节码指令集概述

字节码指令集是一种具有鲜明特点、优劣势都很突出的指令集架构,由于限制了Java虚拟机操作码长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条。由于Class文件格式放弃了编译后代码的操作数长度对齐,意味着虚拟机处理那些超过一个字节数据的时候,不得不在运行时从字节中重建出具体数据结构。

在不考虑异常的情况下,Java虚拟机的解释器可使用下面的伪代码当做基本执行模型来理解:

do{
    自动计算PC寄存器的值加1 ;
    根据PC寄存器的指示位置,从字节码流中取出操作码;
    if (字节码存在操作数) 从字节码流中取出操作数;
    执行操作码所定义的操作;
}while( 字节码流长度 > 0);

2. 字节码中的数据类型

在Java虚拟机指令集中:

  • 大多数的指令都包含了其操作所对应的数据类型信息,它们的操作码助记符中都有特殊字符来表明专门为哪种数据类型服务,例如: i 代表对int 类型数据操作、l 代表 long、s 代表 short、b 代表 byte、 c 代表 char、 f 代表 float、d 代表 double、a 代表 reference。

  • 一些指令的助记符中无明确指令操作类型的字母,如arraylength。但操作数永远只能是一个数组类型的对象。

  • 还有一些指令,如无条件跳转指令goto则与数据类型无关。

由于Java虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码就为指令集的设计带来难度:如果每一种与数据类型有关的指令都支持Java虚拟机所有运行时数据类型,那指令的数量会超出一个字节表示的数量范围。因此,Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,被设计成非完全独立。


3. 指令集支持的数据类型

下表中列举了Java虚拟机支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换 opcode(操作码)列指令模板的T,就可得到一个具体的字节码指令。(如果表中指令模板与数据类型两项共同确定的格为空,则说明虚拟机不支持对这种数据类型执行这项操作)。

JVM高级特性与实践(七):九大类字节码指令集(实例探究 )_第1张图片

注意:大多数指令没有支持整数类型 byte、char、short或boolean类型,是因为编译器在编译期或运行期将这些数据扩展为相应的int类型数据。因此,对于这些数据类型的操作,实际上是使用相应的int类型作为运算类型。




二. 九大类字节码操作

1. 加载和存储指令

(1)作用

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

(2)组成

这类指令包括如下内容:

  • 将一个局部变量加载到操作数栈: iload, iload_n, lload, lload_n, fload, fload_n, dload, dload_n, aload, aload_n;
  • 将一个数值从操作数栈存储到局部变量表: istore, istore_n, lstore_, lstore_n, fstore, fstore_n, dstore_, dstore_n, astore, astore_n;
  • 将一个常量加载到操作数栈: bipush, sipush, ldc, ldc_w, ldc2_w, aconst_null, iconst_m1, iconst_i, lconst_l, fconst_f, dconst_d;
  • 扩充局部变量表的访问索引的指令: wide;

(3)注意

存储数据的操作数栈和局部变量表:主要就是由加载和存储指令进行操作。除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。


2. 运算指令

(1)作用

运算指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

(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。
  • 位移指令: ishl, ishr, iushr, lshl, lshr, lushr。
  • 按位或指令: ior, lor。
  • 按位与指令: iand, land。
  • 按位异或指令: ixor, lxor。
  • 局部变量自增指令: iinc。
  • 比较指令: dcmpg, dcmpl, fcmpg, fcmpl, lcmp。

(3)运算时的溢出

数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数。其实Java虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为0时会导致虚拟机抛出异常ArithmeticException。

(4)运算模式

  • 向最接近数舍入模式: jvm 要求在进行浮点数计算时, 所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为零的;
  • 向零舍入模式:将浮点数转换为整数时,采用该模式, 该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果;

(6)NaN值使用

当一个操作产生溢出时,将会使用有符号的无穷大表示,如果某个操作结果没有明确的数学定义的话,将会使用 NaN值来表示。而且所有使用NaN值作为操作数的算术操作,结果都会返回 NaN;


3. 类型转换指令

(1)作用

类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显示类型转换操作,或者用于处理字节码指令集中数据类型相关指令无法与数据一一对应的问题。

(2)宽化型转换(Widening Numeric Conversions)

宽化型转换:小范围类型向大范围类型的安全转换。Java虚拟机直接支持(即转换时无需显示的转换指令)以下数值类型的转换:

  • int到long, float或double;
  • long到float或double;
  • float到double;

(3)窄化类型转换(Narrowing Numeric Conversion)

窄化类型转换:必须显示地使用转换指令来完成,可能会导致转换结果产生不同的正负号、不同数量级情况,会导致数值的精度丢失。

  • 将 int 或long类型窄化转换为整数类型 T
    转换过程仅仅是丢弃除最低位N个字节外的内容, N是类型T 的数据类型长度,这将可能导致转换结果与输入值有不同的正负号。(因为原来符号位处于数值的最高位,高位被丢弃后,转换结果的符号就取决于低N个字节的首位了)

  • 将一个浮点值窄化转换为整数类型 T(T限于int 或 long类型之一)
    在此转换中遵循如下的转换规则:

    • 如果浮点值是NaN, 那转换结果是int 或 long类型的0;
    • 如果浮点值不是无穷大的话,浮点值使用 向零舍入模式取整,获得整数值v,且v在目标类型T(int或double)的表示范围内;
    • 否则,根据v的符号,转换为T所能表示的最大或最小整数;
  • 将一个double 类型窄化转换为 float类型
    通过向最接近数舍入模式舍入一个可以使用float类型表示的数字。最后结果根据下面这3条规则判断:

    • 如果转换结果的绝对值太小而无法使用 float来表示,将返回 float类型的正负零
    • 如果转换结果的绝对值太大而无法使用 float来表示,将返回 float类型的正负无穷大
    • 对于double 类型的 NaN值将按规定转换为 float类型的 NaN值

(4)代码实践

public long convert()
    {
        short shortNum = 50;
        int intNum = 1000;
        long result = shortNum  * intNum  + 1000000;
        return result;
    }

编译后,生成的字节码序列:

public long convert();
Code:
Stack=2, Locals=5, Args_size=1 //声明了栈的最大深度、本地字数和传入参数数,对于对象方法,会传入this引用,因此这里Arg_szie=1,如上的程序,this会占用1个 字,shortNum 和 intNum分别占1个字,result占2个字(long),因此这里Locals=5

0: bipush 50 //将50入到栈,在栈中会占1个字的位置
2: istore_1 //将栈顶值弹出设给第2个本地变量(传入参数也会以本地变量的方式存在,在这了第1个参数是this),这两段指令等价于short shortNum  = 80,从这里可以看出,JVM直接把short当做integer来运算的
3: sipush 1000 //与上类似,把1000入到栈顶,这里1000超过了b所能表示的范围,所以是sipush
6: istore_2 //同样的,把堆栈值弹出并设给第3个本地变量,这两段等价于int  intNum = 1000
7: iload_1 
8: iload_2 //把第2个本地变量(shortNum 和 intNum)入栈
9: imul //乘运算,弹出2个栈顶值(shortNum 和 intNum),并把运算结果入栈,这时候栈顶值就是 shortNum *  intNum
10: ldc #16; //1000000超过short能够表示的范围,会以常量池中条目的形式存在,这里#16就是1000000,这里把1000000入栈
12: iadd //弹出栈顶值2个字的值,并进行add操作,把add结果再入栈,这时shortNum * intNum和1000000被弹出栈,并把 shortNum * intNum+1000000的值入栈
13: i2l //从栈顶弹出1个字的值,并转换成l型,再入到栈中(这时候,shortNum * intNum  +1000000会占用栈顶2个字的位置。
14: lstore_3 //从栈顶弹出2个字(因为是l型的),并把结果赋给第4和第5个local位置(l需要占2个位置),想当于把运算结果赋给result
15: lload_3 //将第4和第5个local位置的值入栈
16: lreturn //返回指令,将栈顶2个位置的值弹出,并压入方法调用者的操作栈(上一个方法的操作栈),同时把本方法的操作栈清空

4. 对象创建与访问指令

(1)作用

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

(2)组成

这类指令包括如下内容:

  • 创建类实例的指令: new;
  • 创建数组的指令: newarray, anewarray, multianewarray;
  • 访问类字段(static字段或称为类变量)和实例字段的指令: getfield, putfield, getstatic, putstatic;
  • 把一个数组元素加载到操作数栈的指令: baload, caload, saload, iaload, laload, faload, daload, aaload;
  • 将一个操作数栈的值存储到数组元素中的指令: bastore, castore, sastore, iastore, fastore, dastore, aastore;
  • 取数组长度指令: arrayLength;
  • 检查类实例类型的指令: instanceof, checkcast;

(3)代码实践

public void newarray()
    {
        //单维数组
        int[] iarray = new int[10];
        iarray[3] = 10;
        int length = iarray.length;
        int result = iarray[3];

        //对象数组
        Object[] objs = new Object[10];
    }

编译后,生成的字节码序列:

public void newarray();
Code:
Stack=3, Locals=6, Args_size=1
0: bipush 10 //将数组长度入栈
2: newarray int //创建int[10],并将数组引用入栈
4: astore_1 //将创建的数组的引用出栈,赋给第2个本地变量,即iarray
5: aload_1 //将iarray入栈
6: iconst_3 //数组下标是3
7: bipush 10 //值是10
9: iastore //设置iarray[3] = 10,并将3个值出栈
10: aload_1 //将iarray入栈
11: arraylength //将iarray出栈,获得数组长度,并将长度值入栈
12: istore_2 //将数组长度值出栈,并赋给第3个本地变量,即length
13: aload_1 //将iarray入栈
14: iconst_3 //数组下标是3
15: iaload //将如上2个参数出栈,并将iarray[3]的值入对栈
16: istore_3 //将栈顶值(即iarray[3])出栈,并赋给第4个本地变量,即使result
17: bipush 10
19: anewarray #3; //class java/lang/Object,创建Object数组
22: astore 4
24: return

5. 操作数栈管理指令

(1)作用

如同操作一个普通数据结构中的堆栈那样,jvm提供的操作数栈管理指令,可以用于直接操作操作数栈的指令

(2)组成

这类指令包括如下内容:

  • 将一个或两个元素出栈: pop,pop2;
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶: dup, dup2, dup_x1, dup2_x1, dup_x2, dup2_x2;
  • 将栈最顶端的两个数值交换: swap;

6. 控制转移指令

(1)作用

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

(2)组成

这类指令包括如下内容:

  • 条件分支: 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;

(3)注意

与前面运算规则一致:

  • 对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成;
  • 对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行 int 类型的条件分支比较操作来完成整个分支跳转。

由于各类型的比较最终都会转为 int 类型的比较操作,所以Java虚拟机提供的 int 类型的条件分支指令是最为丰富和强大的。

(4)代码实例

public int ifAndSwitch(int i)
    {
        if (i > 100)
        {
            return 200;
        }

        //case语句比较连续,会翻译成tableswitch
        switch (i)
        {
        case 1:
            return 1;
        case 2:
            return 2;
        }

        //case语句不连续,会翻译成lookupswitch
        switch (i)
        {
        case 1:
            return 1;
        case 100:
            return 100;
        }

        return 0;
    }

编译后,生成的字节码序列:

public int ifAndSwitch(int);
Code:
Stack=2, Locals=2, Args_size=2
0: iload_1 //将第2个参数入栈,即i
1: bipush 100 //将100入栈
3: if_icmple 10 //如果i<=100,则跳转到第10条语句
6: sipush 200 
9: ireturn //返回200
10: iload_1 //将第2个参数入栈,即i
11: tableswitch{ //1 to 2
1: 32;
2: 34;
default: 36 }
//case语句比较连续,使用tableswitch
32: iconst_1
33: ireturn
34: iconst_2
35: ireturn
36: iload_1
37: lookupswitch{ //2
1: 64;
100: 66;
default: 69 }
//case语句不连续,使用lookupswitch
64: iconst_1
65: ireturn
66: bipush 100
68: ireturn
69: iconst_0
70: ireturn

7. 方法调用和返回指令

(1)组成

具体作用在后续“虚拟机执行字节码引擎”时再讲解,这里仅作了解即可。这类指令包括如下内容:

  • invokevirtual:用于调用对象的实例方法, 根据对象的实际类型进行分派(虚方法分派),这也是java中最常见的方法分派方式;
  • invokeinterface:用于调用接口方法, 它会在运行时搜索一个实现了这个接口方法的对象,找出合适的方法进行调用;
  • invokespecial:用于调用一些需要特殊处理的实例方法, 包括实例初始化方法,私有方法和父类方法;
  • invokestatic:用于调用类方法(static方法);
  • invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在 java 虚拟机内部,而 invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的;

(2)注意

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


8. 异常处理指令

(1)athrow指令

在Java程序中显示抛出异常的操作(throw语句)都是由athrow指令来实现。

除了使用throw语句显示抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在 ididv或 ldiv指令中抛出 ArithmeticException异常。

(2)注意

在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的。


9. 同步指令

(1)组成

java虚拟机支持两种同步结构:方法级的同步方法内部一段指令序列的同步,这两种同步都是使用管程(monitor)来支持的。

  • 方法级的同步:隐式的, 即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法;

  • 同步一段指令集序列:通常是由java 中的synchronized语句块来表示的,jvm的指令集有 monitorenter 和 monitorexit 两条指令来支持 synchronized关键字的语义。

(2)synchronized 测试

下面根据一段简单的代码来测试方法内部一段指令序列的同步,理解若要正确实现synchronized 关键字,需要Javac 编译器与JVM两者共同协作支持,代码如下:

private int age;
    public void synchronizedTest()
    {
        Object obj = new Object();
        synchronized (obj)
        {
            int result = age;       
        }
    }

编译后,生成的字节码序列:

public void synchronizedTest();
Code:
Stack=2, Locals=4, Args_size=1
0: new #3; //class java/lang/Object //创建Object对象,并将引用入栈
3: dup
4: invokespecial #10; //Method java/lang/Object."":()V //调用Object对象的构造函数,因为方法调用会弹出参数(这里是Object对象),因此需要上面的dup指令,保证在调用构造函数之后栈顶上还是 Object对象的引用,很多种情况下dup指令都是为这个目的而存在的
7: astore_1
8: aload_1
9: dup
10: astore_2
11: monitorenter //☆☆☆☆☆进入Object对象的锁,这里会弹出Object的引用,因此需要注意保存锁对象引用本身
12: aload_0
13: getfield #17; //Field age:I //读age属性,注意,这里可能会抛出异常,这里需要确保进入Object对象的锁后准确地在退出的时候调用monitorexit,看后面的异常表
16: istore_3
17: aload_2
18: monitorexit //☆☆☆☆☆ 退出同步
19: goto 25
22: aload_2
23: monitorexit //☆☆☆☆☆ 退出同步
24: athrow
25: return
Exception table:
from to target type
12 19 22 any
22 24 22 any

(3)测试分析

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

从字节码序列中可以看出,为了保证在方法异常完成时 monitorentermonitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,它可处理所有的异常,目的是用来执行monitorexit指令。



三. 总结

本博文中没有对字节码指令中每条指令进行逐一讲解,但阅读字节码作为了解Java虚拟机的基础技能,是一项应当熟练掌握的能力。原书将JVM中的字节码指令集按用途大致分成 9 类,此篇博文按照这9类字节码指令分别介绍了其作用、组成,并提供了部分实例综合理解,结合实际与理论,这里的实例我参考了其它学者的博文亲自实践而获得结果(在此谢过此作者分享学习),这样对各指令的理解更加深入,所以了解字节码指令集的基本组成、作用等底层知识是不可避免的。

之前已经说过,此篇博文与前面几篇有关于Class类文件结构的学习,都是为了后续“虚拟机执行子系统”相关知识的铺垫,下一篇将会记录学习“虚拟机类加载机制”,其中会运用到这些知识,可谓环环相扣,缺一不可。


若有错误,欢迎指教~

你可能感兴趣的:(Java学习笔记,JVM高级特性与实践)