日常编码中,我们常常用到switch语句,在我的另外一篇文章中【JAVA】优化if else的几种方式,也谈到了可以利用switch来优化if-else结构,那么switch底层究竟是如何实现的呢?
我们先写几个示例
第一个示例:case条件中的int值连续
public int switchInt(int i) { int result; switch (i) { case 0: result = 10; break; case 1: result = 20; break; case 2: result = 30; break; default: result = 40; } return result; }
老规矩,反编译后得到:
(首先javac Main.java,之后javap -c -p Main.class)
public class com.yang.testSwitch.Main { public com.yang.testSwitch.Main(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public int switchInt(int); Code: 0: iload_1 //将局部变量表下标为1的元素,也就是传入的i的值压入栈顶 1: tableswitch { // 0 to 2 //从栈顶中弹出元素,检查是否在[0,2]之内 0: 28 //如果为0,则程序计数器跳转到第28行 1: 34 //如果为1,则程序计数器跳转到第34行 2: 40 //如果为2,则程序计数器跳转到第40行 default: 46 //如果不在[0,2]内,则程序计数器跳转到第46行 } 28: bipush 10 //将常量10压入栈顶 30: istore_2 //将栈顶元素10存入局部变量表的第3个位置上 31: goto 49 //跳转到底49行 34: bipush 20 36: istore_2 37: goto 49 40: bipush 30 42: istore_2 43: goto 49 46: bipush 40 48: istore_2 49: iload_2 //将局部变量表中的第3个元素压入栈中 50: ireturn //弹出栈顶元素,方法返回 }
其中的tableswitch是后面会着重分析的内容,这里先放着。
这里有一个疑问:
为什么刚开始传进来的i的值,是存到局部变量表中的第二个位置,第一个位置到底放了啥?
因为该方法是一个实例方法,局部变量表的第一个位置总是存放着this引用。在构造方法中,已经使用aload_0将this应用存入到了局部变量表中的第一个位置上。
关于这些字节码指令,可以参考这篇文章jvm理论-字节码指令
第二个示例:case中的int值不连续
public int switchInt(int i) { int result; switch (i) { case 0: result = 10; break; case 3: result = 20; break; case 7: result = 30; break; default: result = 40; } return result; }
现在改成0,3,7了,继续观察反编译后得到的内容:
public class com.yang.testSwitch.Main { public com.yang.testSwitch.Main(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public int switchInt(int); Code: 0: iload_1 1: lookupswitch { // 3 0: 36 3: 42 7: 48 default: 54 } 36: bipush 10 38: istore_2 39: goto 57 42: bipush 20 44: istore_2 45: goto 57 48: bipush 30 50: istore_2 51: goto 57 54: bipush 40 56: istore_2 57: iload_2 58: ireturn }
仅仅改动了case中的值,由连续改成了不连续,得到的字节码指令居然发生了变化,由tableswitch变成了lookupswitch。
看到这里,我们可以总结一下
tableswitch和lookupswitch两者的区别
tableswitch
tableswitch用来处理条件连续的情况。首先进行一次范围检查,检查不通过便直接返回default。检查通过后,会得到对应case语句的偏移量。
tableswitch使用数组结构存储偏移量,因此利用下标可以快速定位到偏移量,时间复杂度为O(N)。
注意:这里的“连续”可以理解为相对连续或半连续,012连续,013可以理解为半连续。013也会使用tableswitch,只不过里面缺少的2和default分支会有同样的偏移量。
lookupswitch
lookupswitch则用来处理条件不连续的情况,当条件大面积不连续时,tableswitch将会产生大量的额外空间。使用lookupswitch,会将case值进行排序,之后可以利用二分法快速查到对应的分支偏移量。
lookupswitch则是维护了一个经过key排序的(key,value)结构,查找复杂度一般为O(logN)。
第三个示例:使用String类型
在jdk1.7的时候,可以使用String类型作为case条件了。
public int switchString(String str) { int result; switch (str) { case "a": result = 10; break; case "c": result = 20; break; case "f": result = 30; break; default: result = 40; } return result; }
这次我们不直接看字节码指令,仅仅观察class文件:
public class Main { public Main() { } public int switchString(String var1) { byte var4 = -1; switch(var1.hashCode()) { case 97: if (var1.equals("a")) { var4 = 0; } break; case 99: if (var1.equals("c")) { var4 = 1; } break; case 102: if (var1.equals("f")) { var4 = 2; } } byte var2; switch(var4) { case 0: var2 = 10; break; case 1: var2 = 20; break; case 2: var2 = 30; break; default: var2 = 40; } return var2; } }
可以看得出,主要流程为:
- 首先使用hashcode方法获取到字符串的哈希值
- 因为case条件中的字符串的哈希值大概率不连续,所以字节码指令一般是使用lookupswitch来保存对应的偏移量
- 之后执行偏移量的字节码指令,一上来会调用equals来判断var1是否与case条件中的String相匹配(因为可能存在两个不同字符串的哈希值相同的情况)
- 匹配成功后,会再利用tableswitch来查询var4代表的偏移量
- 最后返回var2
总的来说,使用String类型来作为case中的条件,本质上还是先转化为hashcode,接着查到对应的hashcode,最后再利用equals检测内容是否相同。
第四个示例:使用枚举类型
public enum Animal { CAT, DOG } public int switchEnum(Animal animal) { int result; switch (animal) { case CAT: result = 1; break; case DOG: result = 2; break; default: result = 3; } return result; }
编译该文件会得到3个文件,分别是
- Main.class 这个文件是肯定有的
- Main$Animal.class 这个是枚举类,也是正常的
- Main$1.class 这是个啥?
进到Main$1.class发现:
import com.yang.testSwitch.Main.Animal; // $FF: synthetic class class Main$1 { static { try { $SwitchMap$com$yang$testSwitch$Main$Animal[Animal.CAT.ordinal()] = 1; } catch (NoSuchFieldError var2) { } try { $SwitchMap$com$yang$testSwitch$Main$Animal[Animal.DOG.ordinal()] = 2; } catch (NoSuchFieldError var1) { } } }
使用javap -p Main$1.class后:
class com.yang.testSwitch.Main$1 { static final int[] $SwitchMap$com$yang$testSwitch$Main$Animal; static {}; }
原来这个class里,声明了一个静态的数组,数组利用枚举的ordinal()值作为下标,数组中的元素依次递增。
那这个数组到底是干啥用的呢?
我们接着使用javap -c -p Main.class反编译得到:
public class com.yang.testSwitch.Main { public com.yang.testSwitch.Main(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public int switchEnum(com.yang.testSwitch.Main$Animal); Code: 0: getstatic #2 // Field com/yang/testSwitch/Main$1.$SwitchMap$com$yang$testSwitch$Main$Animal:[I 3: aload_1 4: invokevirtual #3 // Method com/yang/testSwitch/Main$Animal.ordinal:()I 7: iaload 8: lookupswitch { // 2 1: 36 2: 41 default: 46 } 36: iconst_1 37: istore_2 38: goto 48 41: iconst_2 42: istore_2 43: goto 48 46: iconst_3 47: istore_2 48: iload_2 49: ireturn }
现在可以发现,首先是获取到这个静态数组,再调用枚举的ordinal()方法获取枚举的值,再将这个值当作静态数组的下标,获取这个静态数组中的某一个元素,然后从lookupswitch中寻找偏移量。
第五个示例:使用包装类型
public int switchByte(Byte i) { int result; switch (i) { case 1: result = 10; break; case 2: result = 20; break; case 3: result = 30; break; default: result = 40; } return result; }
反编译得到:
public class com.yang.testSwitch.Main { public com.yang.testSwitch.Main(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public int switchByte(java.lang.Byte); Code: 0: aload_1 1: invokevirtual #2 // Method java/lang/Byte.byteValue:()B 4: tableswitch { // 1 to 3 1: 32 2: 38 3: 44 default: 50 } 32: bipush 10 34: istore_2 35: goto 53 38: bipush 20 40: istore_2 41: goto 53 44: bipush 30 46: istore_2 47: goto 53 50: bipush 40 52: istore_2 53: iload_2 54: ireturn }
可以很清晰的看到,调用了Byte.byteValue()方法完成了对Byte的拆箱工作,之后比较byte值即可。
总结
我们用一个表格,来直观的说明各种类型的底层操作:
类型 | 操作 |
基本类型 | 直接比较(条件半连续,采用tableswitch,否则lookupswitch) |
String | 首先获取hashcode,之后采用equals判断 |
Enum | 获取ordinal()值,集合一个自动生成的数组 |
包装类型 | 先进行拆箱,之后比较基本类型 |
这里有几点需要注意的是:
(1)根据java虚拟机规范,java虚拟机的tableswitch和lookupswitch指令都只能支持int类型的条件值,所以switch不支持long、float、double与boolean,以及他们对应的包装类。
(2)break语句会在字节码层面生成一个goto语句,用来直接跳转出switch语句块。因此,如果有哪一次忘记写break,那么程序执行就会进入下一个case中,可能会造成某些匪夷所思的问题。