【JVM】字节码指令简介(三)

618 阅读7分钟

控制转移指令

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

  • 条件分支:

    • 条件分支的指令包括if<cond>,ifnull,ifnonnull,if_icmp<cond>if_acmp<cond>lcmpfcmp<op>,dcmp<op>
  • 复合条件转移: tableswitch, lookupswitch

  • 无条件转移:goto, goto_w, jsr, jsr_w, ret

条件分支指令详解

  • if<cond>指令

这个指令的作用是取出操作数栈顶的int值并与0进行比较,

指令含义
ifeqif的比较值 = 0,则成功
ifneif的比较值 ≠ 0,则成功
ifltif的比较值 < 0,则成功
ifleif的比较值 ≤ 0,则成功
ifgtif的比较值 > 0,则成功
ifgeif的比较值 ≥ 0,则成功
  • ifnull,ifnonnull指令

这两个指令可以通过名字看出来是用于判断操作数栈顶的引用是否为空

  • if_icmp<cond>指令

这个指令是比较操作数栈顶的两个int值,如果条件成立,则跳转

指令含义
if_icmpeq如果栈顶两个值value1 = value2,则成功
if_icmpne如果栈顶两个值value1 ≠ value2,则成功
if_icmplt如果栈顶两个值value1 < value2,则成功
if_icmple如果栈顶两个值value1 ≤ value2,则成功
if_icmpgt如果栈顶两个值value1 > value2,则成功
if_icmpge如果栈顶两个值value1 ≥ value2,则成功
  • if_acmp<cond>指令

这个指令是两个引用的比较指令

指令含义
if_acmpeq如果栈顶两个引用value1 = value2,则成功
if_acmpne如果栈顶两个引用value1 ≠ value2,则成功
  • lcmpfcmp<op>,dcmp<op>指令

lcmpfcmp<op>,dcmp<op>这三个指令分别是long,float和double比较指令。不同的是float和double都有两个比较指令,分别是fcmpg,fcmpl,dcmpg,dcmpl

这里拿fcmpgfcmpl对比,他们都是从操作数栈弹出两个操作数,并做比较,栈顶的元素是value2,栈顶第2个元素为value1(如下图,这个顺序使用于控制转移指令中的所有需要两个元素的指令),若value1=value2,则压入0,若value1>value2则压入1,若value1<value2则压入-1。这两个指令不同之处在于遇到NaN值,fcmpg会压入1,而fcmpl会压入-1。

image-20230212234221238

NaN补充说明

NaN(Not a Number)表示不是一个数字,比如0.0/0.0得到的可能是1.0(两个数相等),也可能是0.0(0.0是分子),也可能是无穷大(0.0是分母),NaN代表无法确定是什么数字。只有double和float类型中有可能出现NaN,而long类型不会出现NaN,所以只有lcmp,而没有lcml

复合条件转移指令

复合条件转移指令tableswitch,lookupswitch都是Java代码中switch编译后的指令,两者的查找效率不同

  • tableswitch:用于switch跳转中,case值连续的场景。它使用了一个数组,存放起始值和终止值以及若干跳转偏移量,通过给定的偏移量进行跳转,效率高;通过这种方式可以获得O(1)的时间复杂度。
  • lookupswitch:case值都是不连续的,lookupswitch维护了一个key-value的关系,通过逐个比较索引来查找匹配的待跳转的行数。而查找最好的性能是O(log n),如二分查找。

代码示例

    public String tableSwitch(int i) {
        switch (i) {
            case 1:
                return "tableSwitch1";
            case 2:
                return "tableSwitch2";
            case 4:
                return "tableSwitch4";
            default:
                return "defaultTableSwitch";
        }
    }
    public String lookupSwitch(String s) {
        switch (s) {
            case "10":
                return "lookupSwitch10";
            case "20":
                return "lookupSwitch2";
            case "40":
                return "lookupSwitch40";
            default:
                return "defaultLookupSwitch0";
        }
    }

编译后字节码

// tableSwitch 
 0 iload_1
 1 tableswitch 1 to 4   // 虽然代码中case的值不连续,编译器优化成tableswitch
    1:  32 (+31)
    2:  35 (+34)
    3:  41 (+40)
    4:  38 (+37)
    default:  41 (+40)
32 ldc #2 <tableSwitch1>
34 areturn
    // ...省略跳转代码
43 areturn

// lookupSwitch
    // 省略部分代码
  5 invokevirtual #6 <java/lang/String.hashCode : ()I>
  8 lookupswitch 3
    1567:  44 (+36)
    1598:  58 (+50)
    1660:  72 (+64)
    default:  83 (+75)
 44 aload_2
 45 ldc #7 <10>
 47 invokevirtual #8 <java/lang/String.equals : (Ljava/lang/Object;)Z>
 50 ifeq 83 (+33)
 53 iconst_0
 54 istore_3
 55 goto 83 (+28)
    // 省略部分代码
 83 iload_3
 84 tableswitch 0 to 2
    0:  112 (+28)
    1:  115 (+31)
    2:  118 (+34)
    default:  121 (+37)
112 ldc #11 <lookupSwitch10>
    // 省略部分代码
121 ldc #14 <defaultLookupSwitch0>
123 areturn

通过上面编译后的字节码可以发现即使case中的值并不连续,编译器会综合考虑时间和空间复杂度,有可能会将字节码优化为tableswitch。如果case后的值是字符串,编译器会优化成两个lookupSwitch,第一个先比较String的hashCode然后跳转到对应的分支,然后再调用equals方法比较。

switch...case...与if...else...对比
  • switch...case...相比if...else...在使用上有更多的限制

    • switch...case...只支持char, byte, short, int, Character, Byte, Short, Integer, String, or an enum类型,if...else...在使用上则没有限制
    • case后面不支持null,if...else...在使用上没有这些限制
  • 无论是tableswitch还是lookupswitch,都有对随机查找的优化,而if...else...是没有的

无条件转移指令

  • goto,goto_w指令

goto与goto_w都是无条件跳转到指定行,两者的区别是goto采用2字节的分支便宜量,goto_w采用4字节的分支偏移,虽然虽然goto_w采用4字节分支便宜,但由于其他原因goto_w的偏移量依然被限制到了65535,截止到JDK19 goto_w这个限制依然存在,也许在未来的虚拟机中会解除这些限制。

  • jsr, jsr_w, ret指令

指令jsr、jsr_w、ret 虽然也是无条件跳转的,但主要用于try-finally语句,且已经被虚拟机逐渐废弃,故不在这里介绍这两个指令。

异常处理指令

异常处理指令在java虚拟机处理显式抛出以及Java虚拟机指令检测到异常状况时抛出。显式抛出主要是指抛出异常的操作(throw语句)都由athrow指令来实现。指令检测到的异常状况时自动抛出,例如在执行idiv或ldiv指令时,当除数为0时,会抛出ArithmeticException异常。

我们拿如下代码举例:

    public static void main(String[] args) {
        try {
            if (div(10, 2) > 5) {
                throw new RuntimeException("计算错误");
            }
        } catch (Exception e) {
            System.out.println("捕获到异常");
            throw e;
        }
    }

编译后的字节码

// 部分字节码省略 
 7 if_icmple 20 (+13)
10 new #17 <java/lang/RuntimeException>
13 dup
14 ldc #18 <计算错误>
16 invokespecial #19 <java/lang/RuntimeException.<init> : (Ljava/lang/String;)V>
19 athrow                   // 查异常表
20 goto 34 (+14)
23 astore_1
24 getstatic #21 <java/lang/System.out : Ljava/io/PrintStream;>
27 ldc #22 <捕获到异常>
29 invokevirtual #23 <java/io/PrintStream.println : (Ljava/lang/String;)V>
32 aload_1
33 athrow                   // 查异常表                     
34 return

异常表:

image-20230219094953999

可以看出athrow指令用于显式地抛出异常,抛出异常后Java虚拟机首先去查异常表,如果代码中包含try...catch...的代码,异常会出现在异常表中,如果抛出的异常在异常表可以查到,则跳转到指定的行号,继续执行字节码,如果查不到则直接return。上面例子中第19行的athrow抛出RuntimeException在异常表中可以查到,则跳转到23行继续执行catch代码块中的代码。

同步控制指令

同步指令是用来支持方法级同步和方法内部一段指令序列同步,这两种同步结构都是使用管程(Monitor)来实现的。

同步方法是隐式的,没有字节码来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否是同步方法。如果被标识了ACC_SYNCHRONIZED,则执行线程在执行该方法时就要求先持有管程,在完成方法时(无论是正常执行完成还是非正常执行完成)释放管程。我们可以看下面示例:

public synchronized void syncMethod() {}

编译后的字节码如下图所示,该方法被标识为synchronized方法。

image-20230219101103238

synchronized语句块是通过monitorenter和monitorexit两条指令来支持同步的。如下示例

    public int syncAdd(int a, int b) {
        synchronized (this) {
            return a + b;
        }
    }

编译后的字节码如下所示

// 省略部分代码
 3 monitorenter
 4 iload_1
 5 iload_2
 6 iadd
 7 aload_3
 8 monitorexit
 9 ireturn
10 astore 4
12 aload_3
13 monitorexit
14 aload 4
16 athrow

异常表如下所示

image-20230219102103922

我们可以通过字节码发现,虽然我们没有显式地处理异常,Java虚拟机还是帮我们加上了异常处理的代码,即使在同步代码块中抛出异常,当前线程持有的管程还是可以被正确的释放。

相关文章

【JVM】字节码指令简介(一) - 掘金 (juejin.cn)

【JVM】字节码指令简介(二) - 掘金 (juejin.cn)

【JVM】字节码指令简介(三) - 掘金 (juejin.cn)

【JVM】字节码指令简介(四)-invokedynamic详解 - 掘金 (juejin.cn)

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 4 天,点击查看活动详情