字节码指令集

字节码指令集

起男 410 2022-12-06

字节码指令集

java字节码对于虚拟机,就好像汇编语言对于计算机,属于基本指向指令

java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(操作码Opcode)以及紧随其后的零至多个代表此操作所需参数(操作数Operands)而构成。由于java虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码

由于限制了java虚拟机操作码的长度为一个字节(0-255),这意味着指令集的操作数码总数不能超过256条

执行模型

如果不考虑异常处理的话,那么java虚拟机的解释器可以使用下面这个伪代码当作最基本的执行模型来理解

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

字节码与数据类型

在java虚拟机的指令中,大多数的指令都包含了其操作所对应的数据类型信息

对于大部分与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:

  • i:int
  • l:long
  • s:short
  • b:byte
  • c:char
  • f:float
  • d:double

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

还有另外一些指令是与数据类型无关的

大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期,将byte和short类型的数据带符号扩展(sign-extend)为相应的int类型数据,将Boolean和char类型数据零位扩展(zero-extend)为相应的int类数据。与此类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对Boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型操作作为运算符

存储与加载指令

加载和存储指令用于将数据从栈帧的本地变量表和操作数栈之间来回传递

指令关键字

指令关键字 说明
load 变量表压栈指令,将一个局部变量加载到操作数栈
const push ldc 常量入栈指令,将一个常量加载到操作数栈,根据数据的大小选择具体的指令
store 出栈装入局部变量表指令,将一个数值从操作数栈存储到局部变量表
wide 扩充基本变量表的访问索引指令

算数指令

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

大体上算数指令可以分为两种:对整形数据进行运算的指令和对浮点数类型运算的指令

byte、short、char和boolean没有直接的算数指令,对于这些指令都是使用int来处理

指令关键字

指令关键字 说明
add 加法
sub 减法
mul 乘法
div 除法
rem 求余
neg 取反
inc 自增
shl 左移
shr 右移
ushr 无符号右移
or 按位或
and 按位与
xor 按位异或

类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换

这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题

宽化类型转换

jvm直接支持特定类型(int->long->float->double)的宽类型转换(小范围类型向大范围类型的安全转换),也就是说不需要指令执行

对应指令为:

类型 指令
int->long、float、double i2l、i2f、i2d
long->float、double l2f、l2d
float->double f2d

窄化类型转换

jvm支持特定类型的窄化类型转换

类型 指令
int->byte、short、char i2b、i2c、i2s
long->int l2i
float->int、long f2i、f2l
double->int、long、float d2i、d2l、d2f

如从long转为byte,需要先l2i,再i2b。其他同理。有多条路径时,用最短的

浮点数NaN转整形,整形值位0

浮点数Infinity转整形,整形值为当前类型最大值

对象的创建与访问指令

java是面向对象的程序设计语言,虚拟机平台从字节码层面对面向对象做了深层次的支持。有一系列专门用于对象操作,可进一步细分为创建指令、字段访问指令、数组操作指令、类型检查指令

创建指令

虽然类实例和数组都是对象,但java虚拟机对类实例和数组的创建使用了不同的字节码指令

创建类实例:使用new指令,它接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后,将对象的引用压入栈

创建数组:

  • newarray:创建基本类型数组
  • anewarray:创建引用类型数组
  • multianewarray:创建多维数组

字段访问指令

访问类字段(static字段,或称为类变量):getstatic、putstatic

访问类实例字段(非static字段,或称为实例变量):getfield、putfield

数组操作指令

把一个数组元素加载到操作数栈的指令:类型+aload,在执行前需要转变索引、数组引用

将一个操作数栈的值存储到数组元素中的指令:类型+astore,在执行前需要准备值、索引、数组引用

获取数组长度的指令:arraylength,该指令从栈顶弹出数组元素,获取输出长度,并压入栈

类型检查指令

指令checkcast用于检查类型强制转换是否可以进行。如果可以进行,那么checkcast指令不会改变操作数栈,否则它会抛出ClassCastException异常

指令instanceof用于判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈

方法调用与返回指令

方法调用指令

  • invokevirtual:调用对象的实例方法,根据对象的实际类型进行分派,支持多态。这也是java语言中最常见的方法分派方式

  • invokeinterface:调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用

  • invokespecial:调用一些需要特殊处理的实例方法,包括初始化方法(构造器)、私有方法和父类方法(不存在方法的重写)。这些方法都是静态类型绑定的,不会在调用时进行动态派发

  • invokestatic:调用类中的类方法(static)。这个是静态绑定的

  • invokedynamic:调用动态绑定方法,这个是jdk1.7后新增的。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法

    前四条调用指令的分派逻辑都固化在java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的

方法返回指令

方法调用结束前,需要进行返回。方法返回指令是根据返回值类型区分

  • 包括ireturn(boolean、byte、char、short、int)、lreturn、freturn、dreturn和areturn。可以将操作数栈顶的元素弹出,并将这个元素压入调用者函数的操作数栈中,当前函数操作数栈的其他元素都会被丢弃
  • 另外还有一个return指令供声明为void的方法、实例初始化方法以及类和接口初始化方法使用

如果当前方法是synchronized的,那么还会执行一个隐含的monitorexit指令,退出临界区

操作数栈管理指令

  • 将一个或两个元素从栈顶弹出,并直接废弃:pop、pop2(弹出两个slot,可以是两个int,或1一个long)
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
    • 不带_x的指令是复制栈顶数据并压入栈顶
    • 带_x的指令是复制栈顶数据并插入栈顶以下的某个位置
  • 将栈最顶端的两个slot数值位置交换:swap。jvm没有提供交换两个64位数据类型(long、double)的指令
  • 指令nop,是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等

这些指令属于通用性,对栈的压入和弹出无需指明数据类型

控制转移指令

比较指令

比较指令是比较操作数栈顶两个元素的大小,并将比较结果入栈

比较指令有:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。首字母d表示double,f表示float、l表示long

对于double和float类型的数字,由于NaN的存在,各有两个版本的比较指令、遇到NaN值,cmpg会压入1,而cmpl会压入-1

条件跳转指令

条件跳转指令通常和比较指令结合使用。在跳转指令执行前,一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转

指令 说明
ifeq 当栈顶int类型数值等于0时跳转
ifne 当栈顶int类型数值不等于0时跳转
iflt 当栈顶int类型数值小于0时跳转
ifgt 当栈顶int类型数值大于0时跳转
ifle 当栈顶int类型数值小于等于0时跳转
ifge 当栈顶int类型数值大于等于0时跳转
ifnull 为null时跳转
ifnonnull 不为null时跳转

这些指令都接收两个字节的操作数,用于计算跳转的位置(16位符号整数作为当前位置的offset)

比较条件跳转指令

比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一

指令 说明
if_icmpeq 比较栈顶两int类型数值大小,当前者等于后者时跳转
if_icmpne 比较栈顶两int类型数值大小,当前者不等于后者时跳转
if_icmplt 比较栈顶两int类型数值大小,当前者小于后者时跳转
if_icmple 比较栈顶两int类型数值大小,当前者小于等于后者时跳转
if_icmpgt 比较栈顶两int类型数值大小,当前者大于后者时跳转
if_icmpge 比较栈顶两int类型数值大小,当前者大于等于后者时跳转
if_acmpeq 比较栈顶两引用类型数值,当结果相等时跳转
if_acmpne 比较栈顶两引用类型数值,当结果不相等时跳转

多条件分支

多条件分支跳转指令是专为switch-case语句设计的

指令 说明
tableswitch 用于switch条件跳转,case值连续。内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量位置,因此效率比较高
lookupswitch 用于switch条件跳转,case值不连续。内部存放着各个离散的case-offset对,每次执行都要搜索全部的case-offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低(编译时会自动排序)

无条件跳转

目前主要的无条件跳转指令为goto。指令goto接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处

如果指令偏移量太大,超过双字节的带符号整数范围,则可以使用goto_w,它和goto有相同的作用,但是接收4个字节的操作数

指令jsr、jsr_w、ret也是无条件跳转指令,主要用于try-finally语句,但以及被虚拟机废弃

异常处理指令

抛出异常

抛出异常指令:athrow指令,在java中显示抛出异常的操作(throw语句)都是由athrow指令实现。除了显示抛出异常情况之外,jvm还规定了许多运行时异常会在其他指令检测到异常状况时自动抛出。例如:当除零时,虚拟机会自动在idiv或ldiv指令中抛出ArithmeticException异常

正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛出异常时,java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上

处理异常

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

异常表:

如果一个方法定义了一个try-catch或者try-finally的异常处理,就会创建一个异常表。它包含了每个异常处理或者finally块的信息。异常表保存了每个异常处理信息。比如:

  • 起始位置
  • 结束位置
  • 程序计数器纪录的代码处理的偏移量
  • 被捕获的异常类在常量池中的索引

当一个异常被抛出时,jvm会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法。如果所有栈帧都没有合适的处理,这个线程将会终止。如果这个线程是最后一个非守护线程jvm会直接终止

不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行

同步控制指令

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

方法级的同步

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

当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否设置

  • 如果设置了,执行线程将先持有同步锁、然后执行方法。最后在方法完成(正常或非正常)时释放同步锁
  • 在方法执行期间,执行线程持有了同步锁,其他任何线程都无法再获得同一个锁
  • 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放

方法内部一段指令序列的同步

同步一段指令序列:通常是由java中的synchronized语句块来表示的,jvm的指令集有monitorentermonitorexit

当一个线程进入同步代码块时,它使用monitorenter指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入,若为1,则判断持有当前监视器的线程是否为自己,如果是则进入,否则等待,直到监视器计数器为0,才会被允许进入同步块

当线程退出同步块时,需要使用monitorexit声明退出。在jvm中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态

指令monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后的锁定和释放都是针对这个对象的监视器进行的