【目录】 【上一篇:Class 文件结构】 【下一篇:JVM 监控及诊断工具 - 命令行】
九、JVM 指令
1、指令与数据类型
- Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字
(操作码)
以及跟随其后的零至多个代表此操作所需要的参数(操作数)
而构成(iload 4)。由于 Java 虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码。 - 由于限制了 Java 虚拟机操作码的长度为一个字节(0 ~ 255),这意味着指令集的操作码总数不能超过 256 条。
- 在 Java 虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。
- 大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。编译器会在编译期或运行期将 byte 和 short 类型的数据带符号扩展(Sign-Extend)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据。与之类似,在处理 boolean、byte、short 和 char 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理。因此,大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型。
字节码指令类型 | 数据类型 |
---|---|
i | int |
l | long |
s | short |
b | byte |
c | char |
f | float |
d | double |
a | 引用类型 |
2、加载与存储指令
用于将数据从栈帧的局部变量表和操作数栈之间来回传递
- 【局部变量压栈指令】将一个局部变量加载到操作数栈:
xload
、xload_<n>
(其中 x 为 i、l、f、d、a;n 为 0 - 3;如 aload_0); - 【常量入栈指令】将一个常量加载到操作数栈:
bipush
、sipush
、ldc
、ldc_w
、ldc2_w
、aconst_null
、iconst_m1
、iconst_<i>
、lconst_<l>
、fconst_<f>
、dconst_<d>
- 【出栈装入局部变量表指令】将一个数值从操作数栈存储到局部变量表:
xstore
、xstore_<n>
(其中 x 为 i、l、f、d、a;n 为 0 - 3;);xastore
(其中 x 为 i、l、f、d、a、b、c、s)
3、算术指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈。
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 取反指令:ineg、lneg、fneg、dneg
- 自增指令:iinc
- 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
- 位运算:
- 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor
- 按位与指令:iand、land
- 按位异或指令:ixor、lxor
算数指令 | int(boolean、byte、char、short) | long | float | double |
---|---|---|---|---|
加法指令 | iadd | ladd | fadd | dadd |
减法指令 | isub | lsub | fsub | dsub |
乘法指令 | imul | lmul | fmul | dmul |
除法指令 | idiv | ldiv | fdiv | ddiv |
求余指令 | irem | lrem | frem | drem |
取反指令 | ineg | lneg | fneg | dneg |
自增指令 | iinc | |||
按位或指令 | ior | lor | ||
按位或指令 | ior | lor | ||
按位与指令 | iand | land | ||
按位异或指令 | ixor | lxor | ||
比较指令 | lcmp | fcmpg / fcmpl | dcmpg / dcmpl |
4、类型转换指令
4.1、宽化类型转换:
- int 转换成 long、float、double:i2l、i2f、i2d
- long 转换成 float、double:l2f、l2d
- folat 转换成 double:f2d
💡 从 byte、chat、short 类型转换成 int 类型,在虚拟机层面是不存在的。对于这个转换,虚拟机没有做实质性的转化处理,只是简单地通过操作数栈交换了两个数据。
4.2、窄化类型转换:
- int 转换成 byte、short、char:i2b、i2s、i2c
- long 转换成 int:l2i
- float 转换成 int、long:f2i、f2l
- double 转换成 int、long、float:d2i、d2l、d2f
行转列 | byte | char | short | int | long | float | double |
---|---|---|---|---|---|---|---|
byte | i2b | i2b | i2b | l2i + i2b | f2i + i2b | d2i + i2b | |
char | i2c | i2c | i2c | l2i + i2c | f2i + i2c | d2i + i2c | |
short | i2s | i2s | i2s | l2i + i2s | f2i + i2s | d2i + i2s | |
int | l2i | f2i | d2i | ||||
long | i2l | i2l | i2l | i2l | f2l | d2l | |
float | i2f | i2f | i2f | i2f | l2f | d2f | |
double | i2d | i2d | i2d | i2d | l2d | f2d |
💡 long、float、double 类型无法直接转换成 byte、char、short 类型,需要先转换成 int 类型,再进行转换
5、对象的创建与访问指令
5.1、创建类实例指令
- 创建实例指令:new
- 它接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后,将对象的引用压入栈。
5.2、创建数组指令
- 创建数组的指令:newarray、anewarray、multianewarray
- newarray:创建基本类型数组
- anewarray:创建引用类型数组
- multianewarray:创建多维数组
5.3、字段操作指令
- 操作类字段:getstatic、putstatic
- 操作类实例字段:getfield、putfield
5.4、数组操作指令
- 把一个数组元素加载到操作数栈的指令:baload、caload、saload、ialoda、laload、faload、daload、aaload
- 将一个操作数栈的值存储到数组元素中:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore
- 获取数组长度:arraylength
5.5、类型检查指令
- 检查类实例或数组类型的指令:instanceof、checkcast
- 指令 checkcast 用于检查类型强制转换是否可以进行;如果可以进行,那么 checkcast 指令不会改变操作数栈;否则它会抛出 ClassCastException 异常。
- 指令 instanceof 用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈。
6、方法调用与返回指令
6.1、方法调用
- 普通指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法
- invokespecial:调用方法、私有方法及父类方法,解析阶段确定唯一方法
- invokevirtual:调用所有虚方法(对象实例方法),根据对象的实际类型进行分派(虚方法分派),支持多态。这也是Java语言中最常见的方法分派方式。
- invokeinterface:调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用。
- 动态调用指令:
- invokedynamic:调用动态绑定的方法,用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本,其中invokestatic指令和invokespecial指令调用的方法为非虚方法。
💡 关于 invokedynamic 指令:
JVM 字节码指令集一直比较稳定,一直到 Java7 中才增加了一个 invokedynamic 指令,这是 Java 为了实现【动态类型语言】支持而做的一种改进。
但是在 Java7 中并没有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 这种底层字节码工具来生成 invokedynamic 指令。直到 Java8 的 Lambda 表示式的出现,在 Java 中才有了直接生成 invokedynamic 指令的方式。
Java7 中增加的动态语言支持本质是对 Java 虚拟机规范的修改,而不是对 Java 语言规则的修改。
💡 动态类型语言与静态类型语言:
动态类型语言和静态类型语言两者的区别在于对类型的检测是在编译期进行还是在运行期进行。在编译期进行的为静态类型语言(Java),在运行期进行的是动态类型语言(JS、python)。
例:
Java: String str = “123”; // 在编译期就知道了 str 变量是一个字符串变量
JS: var str = 123; var str = “123”; // 在运行期才知道 str 变量是字符串变量还是数字变量.
6.2、方法返回指令
返回值指令会将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中;并且当前函数操作数栈中的其他元素会被丢弃
- ireturn:当返回值是 boolean、byte、char、short、int 类型时使用
- lreturn:当返回值是 long 类型时使用
- freturn:当返回值是 float 类型时使用
- dreturn:当返回值类型是 double 类型时使用
- areturn:当返回值类型是引用类型时使用
- return:当无返回值时使用
7、操作数栈管理指令
- 将一个或两个元素从栈顶弹出并直接抛弃:pop、pop2
- 复制栈顶一个或两个元素,并将复制的元素重新压入栈:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
- 将操作数栈中最顶端两个 Slot 元素交换位置:swap
8、控制转移指令
程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上可以分为:比较指令、条件跳转指令、比较条件跳转指令、多条件分支跳转指令、无条件跳转指令
8.1、比较指令:
- 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
比较指令的作用是比较栈顶两个元素的大小,并将比较结果压入栈中;
8.2、条件跳转指令:
条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前,一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转。
指令 | 作用 |
---|---|
iflt | 当栈顶 int 类型数值小于(<)0 时跳转 |
ifle | 当栈顶 int 类型数值小于等于(≤)0 时跳转 |
ifeq | 当栈顶 int 类型数值等于(=)0 时跳转 |
ifne | 当栈顶 int 类型数值不等于(≠)0 时跳转 |
ifgt | 当栈顶 int 类型数值大于(>)0 时跳转 |
ifge | 当栈顶 int 类型数值大于等于(≥)0 时跳转 |
ifnull | 为 null 时跳转 |
ifnonnull | 不为 null 时跳转 |
与前面运算规则一致:
- 对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成
- 对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转
8.3、比较条件跳转指令
比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。
指令 | 作用 |
---|---|
if_icmpeq | 比较栈顶两 int 类型数值大小,当前者等于(=)后者时跳转 |
if_icmpne | 比较栈顶两 int 类型数值大小,当前者不等于(≠)后者时跳转 |
if_icmpit | 比较栈顶两 int 类型数值大小,当前者小于(<)后者时跳转 |
if_icmple | 比较栈顶两 int 类型数值大小,当前者小于等于(≤)后者时跳转 |
if_icmpgt | 比较栈顶两 int 类型数值大小,当前者大于(>)后者时跳转 |
if_icmpge | 比较栈顶两 int 类型数值大小,当前者大于等于(≥)后者时跳转 |
if_acmpeq | 比较栈顶两引用类型数值,当结果相等(=)时跳转 |
if_acmpne | 比较栈顶两引用类型数值,当结果不相等(≠)时跳转 |
8.4、多条件分支跳转
多条件分支跳转指令是专为 switch - case 语句设计的,主要有 tableswitch、lookupswitch
- tableswitch:要求多个条件分支值是连续的,它内存只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数 index,可以立即定位到跳转偏移量的位置,因此销量比较高;
- lookupswitch:内部存放着各个离散的 case - offset 对,每次执行都要搜索全部的 case - offset 对,找到匹配的 case 值,并根据对应的 offset 计算跳转地址,因此效率较低
8.5、无条件跳转
goto ,带两个字节的操作数;如果操作数超过两位数,则指令为 goto_w,它接收 4 个字节的操作数
9、异常处理指令
9.1、抛出异常指令:
- athrow:在 Java 程序中显式抛出异常的操作(throw 语句)都是由 athrow 指令来实现。除了使用 throw 语句显式抛出异常情况之外,JVM 规范还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常情况时自动抛出。例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在 idiv 或 ldiv 指令中抛出 ArithmeticException 异常。
9.2、异常处理与异常表:
处理异常:在 Java 虚拟机中,处理异常不是由字节码指令来实现的,而是采用异常表来完成的。
异常表:如果一个方法定义了 try - catch - finally 的异常处理,就会创建一个异常表,它包含了每个异常处理或则 finally 块的信息。
当一个异常被抛出时,JVM 会在当前方法的异常表里寻找一个匹配的异常处理,如果没有找到,这个方法就会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法。如果在所有栈帧弹出前任然没有找到合适的异常处理,这个线程将会被终止。
不管什么时候抛出的异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下,如果方法结束后没有抛出异常,仍然执行 finally 块,在 retuan 前,它直接跳转到 finally 块来完成代码
10、同步控制指令
10.1、方法级的同步
方法级的同步(就是在方法上声明 synchronized 关键字)是隐式的
,即无需通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标识得知一个方法是否声明为同步方法。
当调用方法时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标识是否设置:
- 如果设置了,执行线程将先持有同步锁,然后执行方法。最后在方法完成(无论是否是正常完成)时释放同步锁;
- 在方法执行期间,执行线程有了同步锁,其他任何线程都无法再获得同一个锁;
- 如果一个同步方法在执行期间抛出了异常,并且在方法内部也没有处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放。
10.2、方法内指定指令序列的同步
方法内部有同步代码块,JVM 会使用 monitorenter
和 monitorexit
两条指令来支持 synchronized 关键字的语义。
- 当一个线程进入同步代码块时,它使用 monitorenter 指令请求进入,如果当前锁对象的监视器计数器值为 0,则它会被准许进入;如果值为 1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到锁对象的监视器计数器为 0,才会被运行进入同步代码块。
- 当线程退出同步快时,需要使用 monitorexit 声明退出。在 Java 虚拟机中,任何一个对象都有一个监视器与之相关联(对象头里面的锁标识),用来判断对象是否被锁定,当监视器计数器值不为 0 时,对象处于锁定状态。