深入JVM字节码探索switch字符串
本文主要讨论对字符串switch
的实现原理。
以下代码作为示例:
class Test {
static int test(String var0) {
switch (var0) {
case "foo":
return 0;
case "bar":
return 1;
case "10":
return 2;
case "0O":
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(java.lang.String);
Code:
0: aload_0
1: astore_1
2: iconst_m1
3: istore_2
4: aload_1
5: invokevirtual #2 // Method java/lang/String.hashCode:()I
8: lookupswitch { // 3
1567: 72
97299: 58
101574: 44
default: 97
}
44: aload_1
45: ldc #3 // String foo
47: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
50: ifeq 97
53: iconst_0
54: istore_2
55: goto 97
58: aload_1
59: ldc #5 // String bar
61: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
64: ifeq 97
67: iconst_1
68: istore_2
69: goto 97
72: aload_1
73: ldc #6 // String 0O
75: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
78: ifeq 86
81: iconst_3
82: istore_2
83: goto 97
86: aload_1
87: ldc #7 // String 10
89: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
92: ifeq 97
95: iconst_2
96: istore_2
97: iload_2
98: tableswitch { // 0 to 3
0: 128
1: 130
2: 132
3: 134
default: 136
}
128: iconst_0
129: ireturn
130: iconst_1
131: ireturn
132: iconst_2
133: ireturn
134: iconst_3
135: ireturn
136: iconst_m1
137: ireturn
}
人工地反编译:
class Test {
static int test(String var0) {
String var1 = var0;
int var2 = -1;
switch (var1.hashCode()) {
case 101574:
if (var1.equals("foo")) {
var2 = 0;
}
break;
case 97299:
if (var1.equals("bar")) {
var2 = 1;
}
break;
case 1567:
if (var1.equals("0O")) {
var2 = 3;
} else if (var1.equals("10")) {
var2 = 2;
}
break;
}
switch (var2) {
case 0:
return 0;
case 1:
return 1;
case 2:
return 2;
case 3:
return 3;
default:
return -1;
}
}
}
在我的环境下,这和上面的字符串switch
编译结果几乎完全一致,javap -c
无任何区别。
对字符串的switch
实际上被拆分成了 5 个步骤:
-
调用参数字符串的
hashCode
方法获取 hash 值 -
对其 hash 值进行第一次
switch
-
调用
equals
方法并修改状态码 -
对状态码进行第二次
switch
-
执行对应分支中的语句
这里注意几个细节:
-
若参数字符串为
null
,则调用hashCode
方法将抛出 NPE,符合虚拟机规范的要求。 -
状态码的默认值为 -1,若参数字符串没有匹配任何一个
case
字面量,则会保持不变,否则将被修改为对应case
在源代码中的序号,从 0 开始计算。 -
仅对参数字符串的 hash 进行
switch
不足以确定其是否与case
字面量匹配,因此至少会调用一次equals
方法。比如上例中
"0O"
和"10"
这两个字符串的 hash 值相同,所以在完成对 hash 值的switch
后,要以这两个case
的倒序逐个调用equals
方法并判断结果。此处可以参考 Java 8 中
java.lang.String
的源码:/** * Returns a hash code for this string. The hash code for a * {@code String} object is computed as * <blockquote><pre> * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1] * </pre></blockquote> * using {@code int} arithmetic, where {@code s[i]} is the * <i>i</i>th character of the string, {@code n} is the length of * the string, and {@code ^} indicates exponentiation. * (The hash value of the empty string is zero.) * * @return a hash code value for this object. */ public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
-
case
字符串字面量的 hash 值需要在编译期经过计算并写入 class 字节码文件中,而参数字符串的hashCode
方法需要在运行时调用才能够得到结果,这就要求同一个字符串的 hash 算法必须在编译期和运行时是对应的,否则经过第一次switch
后状态码将被赋予错误的值,于是在第二次switch
将走入错误的分支路径中,执行错误的逻辑。由于虚拟机会对被加载的类进行版本验证,因此 hash 算法的一致在类加载的流程中可以被虚拟机所保证。
自定义类的 hash 算法在编译期是无法确定的,因此switch
不能以和字符串字面量相同的方式实现自定义类的多路分支。
枚举类型则是一个例外,switch
可以支持自定义枚举的多路分支,但不是以取 hash 值的方式,本文不再对此详细展开。