深入JVM字节码探索switch指令

本文深入探讨Java中的switch语句,从字节码角度揭示其背后的工作原理。通过分析JVM的tableswitch和lookupswitch指令,阐述了如何处理不同类型的参数,并讨论了编译器在不同情况下的选择。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

深入JVM字节码探索switch指令

引言

从 C 到 C++ 到 Java 到一系列各种各样的语言,大多都支持多路分支语句,比如 Kotlin 的when和 Rust 的match等等,在 Java SE 14 版本的语言规范也添加了对switch表达式的支持。
本文主要对 Java SE 8 版本中的switch语句从字节码层面进行研究,理解switch语句相关的各种细节,并尝试着对其编译产物人工地进行反编译,探索具体实现方式。

目录

  1. switch关键字基础
  2. switch所对应的两种指令

1. switch关键字基础

首先,引用一下语言规范中的下面几句话:

The switch Statement

The switch Statement

The switch statement transfers control to one of several statements depending on the value of an expression.

The type of the Expression must be char, byte, short, int, Character, Byte, Short, Integer, String, or an enum type, or a compile-time error occurs.

When the switch statement is executed, first the Expression is evaluated. If the Expression evaluates to null, a NullPointerException is thrown and the entire switch statement completes abruptly for that reason. Otherwise, if the result is of a reference type, it is subject to unboxing conversion.

这里提到switch所接收的表达式参数必须为charbyteshortintCharacterByteShortIntegerStringEnum,若该表达式参数为null,则抛出 NPE,否则若为引用类型则需要进行拆箱转换。

实际上switch在字节码层面可以认为它仅仅只支持int一种类型,下文我会对此进行解释。

然后我们可以在这里注意到几个细节:

  1. 这里的参数类型应是编译期确定的类型,而不是运行时类型。

  2. 这里的参数在运行时不可为null,否则将抛出 NPE。

  3. 支持CharacterByteShortInteger这四种类型的参数

    在编译期将分别对这四种类型的参数通过charValuebyteValueshortValueintValue这四个方法转变为charbyteshortint类型,也就是说实质上对于CharacterByteShortIntegerswitch实质上仍旧是对于charbyteshortintswitch

  4. switch不支持LongDoubleFloatBoolean类型的参数

    通过对上一条的理解,switch不支持LongDoubleFloatBoolean类型的原因应该是因为switch不支持longdoublefloatboolean

  5. switch不支持longdoublefloat类型的参数

    因为从longdoublefloat类型向int类型进行转换可能会造成损失,所以编译期不会轻易地将它们隐式转换为int类型,我们只能在自己的源代码中手动地进行显式强制类型转换,才可以将它们转为int类型。

  6. switch不支持boolean类型的参数

    booleanint的转换是没有损失的,但是实际上我们并没有用switchboolean类型的参数进行多路分支的必要,毕竟我们可以直接使用if语句。

到此为止,我们遗留了几个主要的问题:

  1. switch是如何支持int类型的

  2. switch如何依赖对int的支持而提供对charbyteshort的支持

  3. switch指令如何进行多路分支的跳转

  4. switch如何依赖对int的支持而提供对String的支持

  5. switch如何依赖对int的支持而提供对Enum的支持

后面将对以上问题进行讨论与解答,本文仅含前三个。

2. switch所对应的两种指令

Compiling Switches

Compilation of switch statements uses the tableswitch and lookupswitch instructions.

The Java Virtual Machine’s tableswitch and lookupswitch instructions operate only on int data. Because operations on byte, char, or short values are internally promoted to int, a switch whose expression evaluates to one of those types is compiled as though it evaluated to type int.

switch语句要使用tableswitchlookupswitch这两个指令,这两个指令只针对int类型进行操作,而charbyteshort这三种类型将被隐式转换为int

如果熟悉 JVM 字节码指令集,那么应该很容易理解这两种switch仅仅支持int类型的原因,事实上 JVM 中许多操作都没有对每种基本类型都专门设计单独的指令,这是因为 JVM 的所有指令都仅有一个字节而已,这样的好处是不必进行对齐,因此效率比较高,但是其弊端就是最多只能提供 256 种指令,假如真的让所有操作都同时对booleancharfloatdoublebyteshortintlong以及引用这 9 种类型都提供支持,那么 JVM 将最多只能支持二三十种操作,这绝对是不够用的,因此许多的操作都仅仅只支持其中的一部分类型,而其余的类型将在运行过程中经过类型转换。

2.1 tableswitch

对以下程序进行编译然后进行反编译:

class Test {
    static int test(int var0) {
        switch (var0) {
            case 0:
                return 0;
            case 2:
                return 2;
            case 3:
                return 3;
            default:
                return -1;
        }
    }
}
Compiled from "Test.java"
class Test {
  Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  static int test(int);
    Code:
       0: iload_0
       1: tableswitch   { // 0 to 3
                     0: 32
                     1: 38
                     2: 34
                     3: 36
               default: 38
          }
      32: iconst_0
      33: ireturn
      34: iconst_2
      35: ireturn
      36: iconst_3
      37: ireturn
      38: iconst_m1
      39: ireturn
}

上面这个类的文件在我编译后是这样的:

  Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 	
00000000: CA FE BA BE 00 00 00 37 00 10 0A 00 03 00 0D 07    J~:>...7........
00000010: 00 0E 07 00 0F 01 00 06 3C 69 6E 69 74 3E 01 00    ........<init>..
00000020: 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69    .()V...Code...Li
00000030: 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65 01 00 04    neNumberTable...
00000040: 74 65 73 74 01 00 04 28 49 29 49 01 00 0D 53 74    test...(I)I...St
00000050: 61 63 6B 4D 61 70 54 61 62 6C 65 01 00 0A 53 6F    ackMapTable...So
00000060: 75 72 63 65 46 69 6C 65 01 00 09 54 65 73 74 2E    urceFile...Test.
00000070: 6A 61 76 61 0C 00 04 00 05 01 00 04 54 65 73 74    java........Test
00000080: 01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A    ...java/lang/Obj
00000090: 65 63 74 00 20 00 02 00 03 00 00 00 00 00 02 00    ect.............
000000a0: 00 00 04 00 05 00 01 00 06 00 00 00 1D 00 01 00    ................
000000b0: 01 00 00 00 05 2A B7 00 01 B1 00 00 00 01 00 07    .....*7..1......
000000c0: 00 00 00 06 00 01 00 00 00 01 00 08 00 08 00 09    ................
000000d0: 00 01 00 06 00 00 00 5C 00 01 00 01 00 00 00 28    .......\.......(
000000e0: 1A AA 00 00 00 00 00 25 00 00 00 00 00 00 00 03    .*.....%........
000000f0: 00 00 00 1F 00 00 00 25 00 00 00 21 00 00 00 23    .......%...!...#
00000100: 03 AC 05 AC 06 AC 02 AC 00 00 00 02 00 07 00 00    .,.,.,.,........
00000110: 00 16 00 05 00 00 00 03 00 20 00 05 00 22 00 07    ............."..
00000120: 00 24 00 09 00 26 00 0B 00 0A 00 00 00 06 00 04    .$...&..........
00000130: 20 01 01 01 00 01 00 0B 00 00 00 02 00 0C          ..............

从 0x0x000000e1 至 0x0x000000ff 的这 31 个字节便是tableswitch

按照tableswitch的解释,tableswitch即 0xAA,其后分别跟随 default、low、high,在本例中即分别为37、0、3,再其后跟随其余的 high - low + 1 即 4 个 offset,在本例中分别为 31、37、33、35。

这里需要注意一个细节,那就是在 0xAA 与 default 之间存在 0 至 3 个填充字节,目的在于保证 default、low、high 以及后面的每一个 offset 在 class 文件中相对于这个方法的第一个指令的地址偏移都是 4 的倍数,在本例中填充字节有 2 个,该方法的第一个指令的地址是 0x000000e0。

tableswitchint参数从操作数栈中弹出,并直接查表然后跳转。此处应注意到上面的源码中并不存在case 1,但是在字节码中却在case 1的位置存在一个和 default 相等的值。

这是因为tableswitch需要直接查表获取即将跳转的地址偏移,这个偏移从 0xAA 的位置开始计算,所以这个映射表必须支持随机读取,也就是说所有的case都要在 low 至 high 的范围内顺序排列,若参数小于 low 或大于 high 则跳转到 default 的位置,而在 table 内部被填充的表项也对应此 default 值。

tableswitch看起来应十分高效,因为虚拟机可以直接查表从而得到对应的 offset,但是这里也有一个缺陷,也就是上一段刚刚提到的,需要保证每个case的连续,即使源码中并不存在这个case,也要在编译后填充。因此tableswitch仅适用于switchcase相对来说比较密集的情况下,而在其比较稀疏的情况下则不应使用tableswitch而应使用lookupswitch

2.2 lookupswitch

对以下程序进行编译然后进行反编译:

class Test {
    static int test(int var0) {
        switch (var0) {
            case 1000:
                return 0;
            case 100:
                return 1;
            case 10:
                return 2;
            default:
                return -1;
        }
    }
}
Compiled from "Test.java"
class Test {
  Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  static int test(int);
    Code:
       0: iload_0
       1: lookupswitch  { // 3
                    10: 40
                   100: 38
                  1000: 36
               default: 42
          }
      36: iconst_0
      37: ireturn
      38: iconst_1
      39: ireturn
      40: iconst_2
      41: ireturn
      42: iconst_m1
      43: ireturn
}

上面这个类的文件在我编译后是这样的:

  Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 	
00000000: CA FE BA BE 00 00 00 37 00 10 0A 00 03 00 0D 07    J~:>...7........
00000010: 00 0E 07 00 0F 01 00 06 3C 69 6E 69 74 3E 01 00    ........<init>..
00000020: 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69    .()V...Code...Li
00000030: 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65 01 00 04    neNumberTable...
00000040: 74 65 73 74 01 00 04 28 49 29 49 01 00 0D 53 74    test...(I)I...St
00000050: 61 63 6B 4D 61 70 54 61 62 6C 65 01 00 0A 53 6F    ackMapTable...So
00000060: 75 72 63 65 46 69 6C 65 01 00 09 54 65 73 74 2E    urceFile...Test.
00000070: 6A 61 76 61 0C 00 04 00 05 01 00 04 54 65 73 74    java........Test
00000080: 01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A    ...java/lang/Obj
00000090: 65 63 74 00 20 00 02 00 03 00 00 00 00 00 02 00    ect.............
000000a0: 00 00 04 00 05 00 01 00 06 00 00 00 1D 00 01 00    ................
000000b0: 01 00 00 00 05 2A B7 00 01 B1 00 00 00 01 00 07    .....*7..1......
000000c0: 00 00 00 06 00 01 00 00 00 01 00 08 00 08 00 09    ................
000000d0: 00 01 00 06 00 00 00 60 00 01 00 01 00 00 00 2C    .......`.......,
000000e0: 1A AB 00 00 00 00 00 29 00 00 00 03 00 00 00 0A    .+.....)........
000000f0: 00 00 00 27 00 00 00 64 00 00 00 25 00 00 03 E8    ...'...d...%...h
00000100: 00 00 00 23 03 AC 04 AC 05 AC 02 AC 00 00 00 02    ...#.,.,.,.,....
00000110: 00 07 00 00 00 16 00 05 00 00 00 03 00 24 00 05    .............$..
00000120: 00 26 00 07 00 28 00 09 00 2A 00 0B 00 0A 00 00    .&...(...*......
00000130: 00 06 00 04 24 01 01 01 00 01 00 0B 00 00 00 02    ....$...........
00000140: 00 0C                                              ..

从 0x0x000000e1 至 0x0x00001003 的这 35 个字节便是lookupswitch

按照lookupswitch的解释,lookupswitch即0xAB,其后分别跟随 default、npairs,在本例中即分别为41、3,再其后跟随其余的 npairs 即 3 组映射,在本例中分别为10、39、100、37、1000、35。

lookupswitch也需要填充 0 至 3 个字节,本例中是 2 个,该方法第一个指令的地址为 0x000000e0。

lookupswitchint参数也从操作数栈中弹出,并在这些case中查找匹配项。在我上面的源码中case顺序是 1000、100、10,但是在字节码中却是 10、100、1000,这是因为lookupswitch要求所有 offset 应以递增的顺序排列,从而使 JVM 可以支持比线性更高效的查找方式,比如二分查找,但是规范中在此并没有要求虚拟机所实现的查找方式。

由于lookupswitch需要进行查找,而不能像tableswitch那样直接查表,因此或许效率会有所降低,但是在case相对比较稀疏的情况下,比起tableswitchlookupswitch将节省大量的空间。

对于我们在源码中的switch语句,选择lookupswitch或是tableswitch将依赖于编译器的具体实现。

参考文献

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值