平时经常会遇到一个需求,针对一个字符串变量的不同值,返回不同的数据或者进行不同的操作。可能我们会选择使用一堆if来判断,也可能会使用Switch来进行操作。那么这两个到底选择使用哪个好呢?我们这次从字节码角度来分析看下。
-
存在如下两个方法:
public int a(String n) { switch (n) { case "12": return 1; case "14": return 2; case "sss": return 4; default: return 5; } } public int b(String n){ if (n.equals("12")) { return 1; } else if (n.equals("14")) { return 2; } else if (n.equals("sss")){ return 4; } else { return 5; } }
这两个方法实现的功能一样,都是对于输入的字符串N,根据N的不同值,返回不同的对应的整型。那么我们该优先选择哪个呢?
先看下字节码。
- 对于方法 a
0 aload_1
1 astore_2
2 iconst_m1
3 istore_3
4 aload_2
5 invokevirtual #2 <java/lang/String.hashCode : ()I>
8 lookupswitch 3
1569: 44 (+36)
1571: 58 (+50)
114195: 72 (+64)
default: 83 (+75)
44 aload_2
45 ldc #3 <12>
47 invokevirtual #4 <java/lang/String.equals : (Ljava/lang/Object;)Z>
50 ifeq 83 (+33)
53 iconst_0
54 istore_3
55 goto 83 (+28)
58 aload_2
59 ldc #5 <14>
61 invokevirtual #4 <java/lang/String.equals : (Ljava/lang/Object;)Z>
64 ifeq 83 (+19)
67 iconst_1
68 istore_3
69 goto 83 (+14)
72 aload_2
73 ldc #6 <sss>
75 invokevirtual #4 <java/lang/String.equals : (Ljava/lang/Object;)Z>
78 ifeq 83 (+5)
81 iconst_2
82 istore_3
83 iload_3
84 tableswitch 0 to 2
0: 112 (+28)
1: 114 (+30)
2: 116 (+32)
default: 118 (+34)
112 iconst_1
113 ireturn
114 iconst_2
115 ireturn
116 iconst_4
117 ireturn
118 iconst_5
对于上述JVM字节码指令,大体含义流程如下图。
原理:
1. 底层存在两个表,
2. 一个是基于case中对应的key字符串的hashcode和指令地址映射关系的表,这个数组下标是不连续的,所以是通过指令 lookupswitch 来查找
3. 另一个是保存Switch中每个case返回值的数组,这个数组下标从0开始,数据是连续的,取值时候直接通过下标来取值,所以是 tableswitch
4. 对于java Switch一个字符串,会先根据传入的字符串N的hashCode找到其对应下一步指令地址
5. 找到对应指令地址后,会去将其在tableSwitch中的数组下标存储到对应的操作数栈中
6. 到tableSwitch中通过下标直接取到对应的值。
通过上述流程原理,可以计算出,Switch一个字符串的耗时是固定的,无论case有多少,操作步骤一样多,所以耗时一样。永远都是4步。
1. 取hashCode 2. lookupswitch 3. equals 4. tableswitch
我们接下来看下连续if语句的字节码原理:
0 aload_1
1 ldc #3 <12>
3 invokevirtual #4 <java/lang/String.equals : (Ljava/lang/Object;)Z>
6 ifeq 11 (+5)
9 iconst_1
10 ireturn
11 aload_1
12 ldc #5 <14>
14 invokevirtual #4 <java/lang/String.equals : (Ljava/lang/Object;)Z>
17 ifeq 22 (+5)
20 iconst_2
21 ireturn
22 aload_1
23 ldc #6 <sss>
25 invokevirtual #4 <java/lang/String.equals : (Ljava/lang/Object;)Z>
28 ifeq 33 (+5)
31 iconst_4
32 ireturn
33 iconst_5
34 ireturn
对于上述JVM字节码指令,大体含义流程如下图。
原理很简单:
从第一个if开始,依次判断比对if里的条件,只要满足就返回对应结果,否则跳转到下一个指令地址继续比对。
对于这种连续if,字节码很单纯,就是按照顺序比对if,所以如果if条件越多,对于越靠后的if条件,判断时候花费时间就越多。
如果对于每个if条件被满足 触发的概率相同,那么总消耗时间就是 1+2+3+..+n = (n + 1)n/2
- 比较两种方式
第一种方式耗时永远是4,第二种方式为(n+1)n/2,所以当n<3时,第二种方式总耗时小,当n>=3时候,第一种耗时少。
同时我们可以看到上图,在AS中,如果if大于等于3个时候,就会提示我们转为Switch,当Switch小于3个时候,就会提示我们转为if。进一步验证了我们的推论。
进阶
对于Switch,case如果不连续会有什么区别吗?
实际研究一下,我们看代码。
public void a(int a) {
switch (a){
case 11:
System.out.println("0");
break;
case 7:
System.out.println("1");
break;
case 12:
System.out.println("2");
break;
case 13:
System.out.println("3");
break;
case 18:
System.out.println("5");
break;
default:
break;
}
}
存在上述一个方法,case为 7,11,12,13,18。可以看到case的值是连续增长的,但是中间也存在断层,直接从7到了11,从13到了18。对于这种情况,字节码是什么样子呢?
1 tableswitch 7 to 18
7: 75 (+74)
8: 119 (+118)
9: 119 (+118)
10: 119 (+118)
11: 64 (+63)
12: 86 (+85)
13: 97 (+96)
14: 119 (+118)
15: 119 (+118)
16: 119 (+118)
17: 119 (+118)
18: 108 (+107)
default: 119 (+118)
可以看到,这种情况下,仍然使用的是tableSwitch,并且JVM自动补齐了中间相差的case。
我们将case值再改一下,增大断层之间的差值。
public void a(int a) {
switch (a) {
case 7:
System.out.println("1");
break;
case 11:
System.out.println("0");
break;
case 12:
System.out.println("2");
break;
case 13:
System.out.println("3");
break;
case 22:
System.out.println("5");
}
}
将最后的case直接改为22,稍微增加了一下间距。看下字节码。
1 lookupswitch 5
7: 63 (+62)
11: 52 (+51)
12: 74 (+73)
13: 85 (+84)
22: 96 (+95)
default: 107 (+106)
此时JVM使用了loopupswitch的方式,且这次没有补齐中间的case。
注意loopupswitch方式是通过二分来查找case的
所以,当case处于连续的情况,或者联系中间间断差距小的情况(具体差距阈值多少由JVM确定的,笔者没研究明白),会自动补齐中间空缺的case值,使用tableSwitch。tableSwitch直接通过case值在表中定位到具体值。
当case不连续且差距大的情况下,JVM会使用loopupswitch。loopupswitch使用的是二分法查找来定位的。
总结下两者的优劣和使用建议:
1. 在每个if条件触发概率一样的情况下,以3为界限,小于3个时候,使用连续if判断,大于等于3个时候改为Switch 2. Switch修饰一个字符串时候,会开辟额外的空间,相当于以空间换效率。 3. 对于if中条件触发概率不同的情况,触发概率大的if判断,要优先放到前边,这样可以提高效率。 对于Switch使用时候,case要尽量保证连续或者差距不要太大,可以使用tableSwitch来保证性能。