字节码指令解析下篇
一、控制转移指令
1.比较指令
- 比较指令的作用是比较栈顶两个元素的大小,并将比较结果入栈。
对于double类型的数据,指令有dcmpg
、dcmpl,由于double类型的数值有可能是NaN,所以需要两种处理方式。- 这两个指令都从栈中弹出两个操作数,并将它们做比较,然后将比较的结果压入操作数栈。设桟顶的元素为v2,栈顶顺位第2位的元素为v1。若v1 = v2,则压入0;若v1 > v2,则压入1;若v1 < v2则压入-1。
- 两个指令的不同之处在于,如果待比较double类型的数据数值为NaN,那么dcmpg指令会压入1,而dcmpl指令会压入-1。
对于float类型的数据,指令有fcmpg、fcmpl
,由于float类型的数值有可能是NaN,所以需要两种处理方式。- 这两个指令都从栈中弹出两个操作数,并将它们做比较,然后将比较的结果压入操作数栈。设桟顶的元素为v2,栈顶顺位第2位的元素为v1。若v1 = v2,则压入0;若v1 > v2,则压入1;若v1 < v2则压入-1。
- 两个指令的不同之处在于,如果待比较float类型的数据数值为NaN,那么fcmpg指令会压入1,而fcmpl指令会压入-1。
对于long类型的数据,指令有lcmp
,由于double类型的的数值不可能是NaN值,故无需准备两套指令。
2.条件跳转指令
-
条件跳转指令通常和比较指令结合使用,一般可以先用比较指令选行栈顶元素的准备,然后进行条件跳转。首先弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置。
-
条件跳转指令有:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull,这些指令都接收两个字节的操作数,用于计算跳转的位置。
-
对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成。
-
对于1ong、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。
举例1
public void compare(){
int a = 0;
if(a != 0){
a = 10;
}else{
a = 20;
}
}
举例2
public boolean compare(String str){
if(str == null){
return true;
}else{
return false;
}
}
举例3
public void compare(){
float f1 = 9;
float f2 = 10;
System.out.println(f1 < f2);
}
举例4
public void compare(){
int i = 10;
long l = 20;
System.out.println(i > l);
}
举例5
public int compare(double d){
if(d > 50.0){
return 1;
}else{
return -1;
}
}
3.比较条件跳转指令
-
比较条件跳转指令类似于比较指令和条件跳转指令的结合体,这类指令在执行的时候,首先进行比较然后进行跳转。
-
比较跳转指令都接收两个字节的操作数作为参数,用于计算跳转的位置。同时在执行指令时,栈顶需要准备两个元素进行比较。指令执行完成后,栈顶的这两个元素被清空,且没有任何数据入栈。如果预设条件成立,则执行跳转,否则,继续执行下一条语句。
-
比较条件跳转指令有:if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、 ificmpge、if_ acmpeq、if_acmpne,其中
if_i
开头的代表byte、short、int类型,if_a
开头的代表引用类型
举例1
public void compare(){
int i = 10;
int j = 20;
System.out.println(i < j);
}
举例2
public void compare(){
short s = 9;
byte b = 10;
System.out.println(s > b);
}
举例3
public void compare(){
Object obj1 = new Object();
Object obj2 = new Object();
System.out.println(obj1 == obj2);
System.out.println(obj1 != obj2);
}
4.多条件分支跳转指令
-
多条件分支跳转指令是专为
switch-case
语句进行设计的,主要有tableswitch
和lookupswitch
两种指令。 -
tableswitch
指令要求多个条件分支的判断值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量位置,因此效率比较高。 -
lookupswitch
指令不要求多个条件分支的判断值是连续的,但编译器会将无序的条件值进行排序,然后每次执行都要对全部的分支条件进行判断,找到匹配的分支,并根据对应的偏移量计算跳转地址,因此效率较低。
举例1(tableswitch示例)
public void test(int select){
int num;
switch(select){
case 1:
num = 10;
break;
case 2:
num = 20;
break;
case 3:
num = 30;
break;
default:
num = 40;
}
}
举例2(lookupswitch示例)
public void test(int select){
int num;
switch(select){
case 100:
num = 10;
break;
case 500:
num = 20;
break;
case 200:
num = 30;
break;
default:
num = 40;
}
}
举例3(分支条件判断类型为字符串)
public void test(String season){
switch(season){
case "SPRING": break;
case "SUMMER": break;
case "AUTUMN": break;
case "WINTER": break;
}
}
5.无条件跳转指令
- 无条件跳转指令
goto
接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处。 - 无条件跳转指令
goto_w
接收四个字节的操作数,它和goto
指令有相同的作用,但是它可以表示更大的地址范围。 - 指令
jsr、jsr_w、ret
也是无条件跳转的,但主要用于try-finally
语句,而且已经被虚拟机逐渐废弃。
举例1
public void test(){
int i = 0;
while(i < 100){
String s = "atguigu";
i++;
}
}
举例2
public void test(){
short i;
for(i = 0; i < 100; i++){
String s = "atguigu";
}
}
二、异常处理指令
异常主要涉及两个过程:异常抛出和异常处理
1.异常抛出指令
- 在Java程序中显示抛出异常的操作都是由athrow指令来实现。 除了使用throw语句显示抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出,比如:ArithmeticException除数为0异常。
- 正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java虚拟机会清除操作数栈上的所有内容,然后将异常实例压入调用者操作数栈上。
举例1
public void test(int i){
if(i == 0){
throw new RuntimeException("参数值为0");
}
}
举例2(和上面对比,多了异常表信息)
public void test(int i) throws RuntimeException, IOException{
if(i == 0){
throw new RuntimeException("参数值为0");
}
}
2.异常处理指令
- 在Java虚拟机中,处理异常的过程不是由字节码指令来实现的(不过早期是使用的
jsr
和ret
指令),而是采用异常表来进行的。 - 如果一个方法中定义了
try-catch
语句或者try-finally
语句,那么编译时就会创建一个异常表。它包含了每个异常处理的详细信息,比如:异常发生的起始位置、异常发生的结束位置、程序计数器记录的代码处理的偏移地址、被捕获的异常类在常量池中的索引等。 - 在
tyr-finally
语句中,如果方法结束后没有抛出异常,会继续执行finally
块,也就是在return
前,它会直接跳到finally
块中执行相应操作。 - 当一个异常被抛出时,Java虚拟机会在当前的方法中寻找异常处理块。如果没有找到,那么这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法。如果在所有栈帧弹出前仍然没有找到合适的异常处理块,那么整个线程将被终止。如果这个异常在最后一个非守护线程里抛出,将会导致Java虚拟机自身终止。
举例1
public void test(){
try{
File file = new File("d:/hello.txt");
FileInputStream fis = new FileInputStream(file);
String info = "hello!";
}catch(FileNotFoundException e){
e.printStackTrace();
}catch(RuntimeException e){
e.printStackTrace();
}
}
举例2
public static func(){
String str = "hello";
try{
return str;
}finally{
str = "atguigu";
}
}
三、同步控制指令
Java虚拟机支持两种同步结构:方法级的同步和方法内部指令序列的同步,这两种同步都是使用
monitor
相关指令进行的。
1.方法级的同步
- 方法级的同步是隐式的,也就是无须通过字节码指令来控制,它实现在方法调用和返回操作之中。Java虚拟机可以从方法常量池的方法表结构中的
ACC_SYNCHRONIZED
访问标志判断一个方法是否被声明为一个同步方法。 - 当调用方法时,调用指令将会检查方法的
AC_SYNCHRONIZED
访问标志是否设置。如果设置了,那么执行线程将将先持有同步锁,然后执行方法,最后在方法完成时释放同步锁。 - 在方法执行期间,如果某个执行线程持有了同步锁,那么其它任何线程都无法再获得此锁,直到该锁被释放。
- 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放。
举例
private int i = 0;
public synchronized void add(){
i++;
}
💡【注】:从字节码指令的角度来看,上面这段同步代码和不加synchronized关键字的非同步代码,没有任何区别,也就是没有使用monitorenter
和monitorexit
指令进行同步控制。
因为对于方法级的同步而言,Java虚拟机会通过方法的访问标示符判断它是否为同步方法,如果是,那么Java虚拟机会自动在方法调用前对其进行加锁,当同步方法执行完毕后,不管方法是正常结束还是有异常抛出,都会由Java虚拟机释放这个锁。
因此,对于方法级同步,monitorenter
和monitorexit
指令是隐式存在的,并未直接出现在字节码中。
2.方法内指令序列的同步
-
方法内指令序列同步通常是由Java中的synchronized语句块来表示的,并由
monitorenter
和monitorexit
这两条指令来支持synchronized关键字的语义。 -
当一个线程进入同步代码块时,需要使用
monitorenter
指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入,若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。 -
当一个线程退出同步代码块时,需要使用
monitorexit
指令请求退出。在Java虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态。 -
指令
monitorenter
和monitorexit
在执行时,都需要在操作数栈顶压入对象,之后monitorenter
和monitorexit
的锁定和释放都是针对这个对象的监视器进行的。
示例
private int i = 0;
private Object obj = new Object();
public void test(){
synchronized(obj){
i--;
}
}