二十一、字节码指令集与解析举例2
7、方法调用与返回指令
方法调用指令
方法调用指令:invokevirtual、 invokeinterface、invokespecial、 invokestatic 、 invokedynamic
以下5条指令用于方法调用:
- invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态。这也是Java语言中最常见的方法分派方式。(多态场景下方法的重写)
- invokeinterface指令:用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用。
- invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用时进行动态派发。(主要指不能被重写的一些方法)
- invokestatic指令:用于调用命名类中的类方法(static方法)。这是静态绑定的。
- invokedynamic指令:调用动态绑定的方法,这个是JDK1.7后新加入的指令。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条调用指令的分派逻辑都固化在java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
举例说明:
invokespecial
invokestatic
invokeinterface
invokevirtual
方法返回指令
方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的。
- 包括ireturn(当返回值是 boolean、byte、char、 short和int类型时使用)、 lreturn、 freturn、 dreturn和areturn
- 另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
举例:
- 通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃。
- 如果当前返回的是synchronized方法,那么还会执行一个隐含的monitorexit指令,退出临界区。
- 最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者
8、操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,JVM提供的操作数栈管理指令,可以用于直接操作操作数栈的指令。
这类指令包括如下内容:
-
将一个或两个元素从栈顶弹出,并且直接废弃:pop, pop2;
-
pop:将栈顶的1个Slot数值出栈。例如1个short、int类型数值
-
pop2:将栈顶的2个Slot数值出栈。例如1个double类型数值,或者2个int类型数值
-
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup, dup2, dup_x1,dup2_x1, dup_x2,dup2_x2;
-
不带_x的指令是复制栈顶数据并压入栈顶。包括两个指令,dup和dup2。dup的系数代表要复制的Slot个数。
- dup开头的指令用于复制1个Slot的数据。例如1个int或1个reference类型数据
- dup2开头的指令用于复制2个Slot的数据。例如1个long,或2个int,或1个int+1个float
-
带_x的指令是复制栈顶数据并插入栈顶以下的某个位置。共有4个指令,dup_x1, dup2_x1,dup_x2,dup2_x2。对于带_x的复制插入指令,只要将指令的dup和x的系数相加,结果即为需要插入的位置。因此
- dup_x1插入位置:1+1=2,即栈顶2个Slot下面
- dup_x2插入位置:1+2=3,即栈顶3个Slot下面
- dup2_x1插入位置:2+1=3,即栈顶3个Slot下面
- dup2_x2插入位置:2+2=4,即栈顶4个Slot下面
-
将栈最顶端的两个Slot数值位置交换:swap。 Java虚拟机没有提供交换两个64位数据类型(long double)数值的指令。
-
指令nop,是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等。
示例1:
示例2:
9、控制转移指令
程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上可以分为 1)比较指令、2)条件跳转指令、3)比较条件跳转指令、4)多条件分支跳转指令、5)无条件跳转指令等。
比较指令(算术指令)
比较指令的说明:
- 比较指令的作用是比较栈顶两个元素的大小,并将比较结果入栈。
- 比较指令有:dcmpg、dcmpl、 fcmpg、fcmpl、lcmp。
与前面讲解的指令类似,首字符d表示 double类型,f表 float示,l表示long
- 对于 double和float类型的数字,由于NaN的存在,各有两个版本的比较指令。以 float为例,有fcmpg和fcmpl两个指令,它们的区别在于在数字比较时,若遇到NaN值,处理结果不同。
- 指令lcmp针对long型整数,由于long型整数没有NaN值,故无需准备两套指令。
举例:
指令fcmpg和fcmpl都从栈中弹出两个操作数,并将它们做比较,设桟顶的元素为v2,栈顶顺位第2位的元素为v1,若v1=v2,则压入0;若v1>v2则压入1;若v1 < v2则压入-1。
两个指令的不同之处在于,如果遇到NaN值,fcmpg会压入1,而fcmpl会压入-1。
条件跳转指令
条件跳转指令通常和比较指令结合使用。在条件跳转指令前,一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转。
条件跳转指令有:ifeq, iflt, ifle, ifne, ifgt, ifge, ifnull, ifnonnull。这些指令都接收两个字节的操作数,用于计算跳转的位置(16位符号整数作为当前位置的offset)。 它们的统一含义为:弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置。
指令 | 说明 |
---|---|
ifeq | equals 当栈顶int类型数值等于0时跳转 |
ifne | not equals 当栈顶in类型数值不等于0时跳转 |
iflt | lower than 当栈顶in类型数值小于0时跳转 |
ifle | lower or equals 当栈顶in类型数值小于等于0时跳转 |
ifgt | greater than 当栈顶int类型数组大于0时跳转 |
ifge | greater or equals 当栈顶in类型数值大于等于0时跳转 |
ifnull | 为null时跳转 |
ifnonnull | 不为null时跳转 |
注意:
1、 对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成;
2、 对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中(所以条件跳转指令只有int的跳转),随后再执行int类型的条件分支比较操作来完成整个分支跳转;
3、 由于各类型的比较最终都会转为int类型的比较操作,所以Java虚拟机提供的int类型的条件分支指令是最为丰富和强大的;
示例1:ifeq举例
示例2:ifnonnull举例
示例3:
有人可能会疑惑,输出的应该是boolean类型是数据,但是压入栈中的是数值1,这是因为调用的println方法有很多重载的,在常量池中有具体标识。
而Z就代表的是boolean类型的参数
比较条件跳转指令
比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。
这类指令有:if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、 ificmpge、if_ acmpeq和if_acmpne其中指令助记符加上“if_”后,以字符“i”开头的指令针对int型整数操作(也包括 short和byte类型),以字符“a”开头的指令表示对象引用的比较。
这些指令都接收两个字节的操作数作为参数,用于计算跳转的位置。同时在执行指令时,栈顶需要准备两个元素进行比较。指令执行完成后,栈顶的这两个元素被清空,且没有任何数据入栈。如果预设条件成立,则执行跳转,否则,继续执行下一条语句。
示例1:
示例2:
多条件分支跳转
多条件分支跳转指令是专为switch一case语句设计的,主要有tableswitch和lookupswitch。
指令名称 | 描述 |
---|---|
tableswitch | 用于switch条件跳转,case值连续 |
lookupswitch | 用于switch条件跳转,case值不连续 |
从助记符上看,两者都是switch语句的实现,它们的区别:
- tableswitch要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量位置,因此效率比较高。
- 指令lookupswitch内部存放着各个离散的case一offse对,每次执行都要搜索全部的case一offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低。
示例1:
示例2:
示例3:
无条件跳转
目前主要的无条件跳转指令为goto。指令goto接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处。
如果指令偏移量太大,超过双字节的带符号整数的范围,则可以使用指令goto_w,它和goto有相同的作用,但是它接收4个宇节的操作数,可以表示更大的地址范围。
指令jsr、jsr_w、ret虽然也是无条件跳转的,但主要用于 try一finally语句,且已经被虚拟机逐渐废弃,故不在这里介绍这两个指令。
指令名称 | 描述 |
---|---|
goto | 无条件跳转 |
goto_w | 无条件跳转(宽索引) |
jsr | 跳转至指定16位offset位置,并将jsr下条指令地址压入栈顶 |
jsr_w | 跳转至指定32位offer位置,并将jsr_w下条指令地址压入栈顶 |
ret | 返回至由指定的局部变量所给出的指令位置(一般与jsr、jsr_w联合使用) |
- whileTest和forTest的字节码是一样的,区别是i的作用域不同
10、异常处理指令
抛出异常指令
在Java程序中显示抛出异常的操作 (throw语句)都是由athrow指令来实现。 除了使用throw语句显示抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在 idiv或 ldiv指令中抛出 ArithmeticException异常。
正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上。
异常及异常的处理:
- 过程一:异常对象的生成过程 --> throw(手动/自动) --> 指令:athrow
- 过程二:异常的处理:抓抛模型。try-catch-finally --> 使用异常表
示例1:
示例2:
异常处理与异常表
1、 处理异常:在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的;
2、 异常表:如果一个方法定义了一个try一catch或者try一finally的异常处理,就会创建一个异常表它包含了每个异常处理或者finally块的信息异常表保存了每个异常处理信息比如:-起始位置-结束位置-程序计数器记录的代码处理的偏移地址-被捕获的异常类在常量池中的索引;
当一个异常被抛出时,JVM会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)。**如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个线程将终止。**如果这个异常在最后一个非守护线程里抛出,将会导致JVM自己终止,比如这个线程是个main线程。
不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下,如果方法结束后没有抛出异常,仍然执行finally块,在return前,它直接跳到finally块来完成目标。
11、同步控制指令
java虚拟机支持两种同步结构:方法级的同步和方法内部一段指令序列的同步,这两种同步都是使用monitor来支持的。
方法级的同步
方法级的同步:是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法;
当调用方法时,调用指令将会检查方法的AC_SYNCHRONIZED访问标志是否设置。
- 如果设置了,执行线程将先持有同步锁,然后执行方法。最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁。
- 在方法执行期间,执行线程持有了同步锁,其他任何线程都无法再获得同一个锁。
- 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放。
这段代码和普通的无同步操作的代码没有什么不同,没有使用monitorenter和monitorexit进行同步区控制。这是因为,对于同步方法而言,当虚拟机通过方法的访问标示符判断是一个同步方法时,会自动在方法调用前进行加锁,当同步方法执行完毕后,不管方法是正常结束还是有异常抛出,均会由虚拟机释放这个锁。因此,对于同步方法而言,monitorenter和monitorexit指令是隐式存在的,并未直接出现在字节码中。
方法内指定指令序列的同步
同步一段指令集序列:通常是由java中的synchronized语句块来表示的。jvm的指令集有 monitorenter 和monitorexit 两条指令来支持synchronized关键字的语义。
- 当一个线程进入同步代码块时,它使用monitorenter指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入,若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入(可重入锁),否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。
- 当线程退出同步块时,需要使用monitorexit声明退出。在Java虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态。
指令monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是针对这个对象的监视器进行的。