暂无图片
暂无图片
1
暂无图片
暂无图片
暂无图片

Switch-case的实现原理

奔波儿灞取经 2021-10-14
3553

实现原理

语言的底层就是算法,所以switch-case的底层也是算法: 数组和二分查找。

switch-case是一个条件语句,也就是说: 如果满足条件,那么就执行对应的指令,也就是: 找条件!那么就是查找!也就是算法里的查找!

那么为什么是数组和二分查找呢?

其实switch-case的状态值只能存放一个int的大小,比如byte,char,shor,int等。如果大于一个int的大小就不可以,比如long,double等。那么String为什么也可以呢?因为switch(String)会取String对应的hashcode,而hashcode正好是一个int的大小。

正是因为状态值是一个int的大小,所以可以对状态值进行排序,「如果状态值是紧凑的,那么就会将状态值优化为tableswitch(可以理解为数组),查找效率就是O(1)的;如果状态值是分散的,那么就会优化为lookupswitch,然后使用二分查找来找条件。」

现在我们来证明,假设有如下代码:

public void test(int a){
    int b = 10;
    switch(a) {
        case 1: 
            b = 1;
        break;
        case 2: 
            b = 2;
        break;
        case 3: 
            b = 3;
        break;
        default:
            b = 4;
        break;
    }
}

我们使用javac指令得到Test.class对象,然后使用javap -verbose Test.class得到字节码如下(这里只粘出关键部分):

public void test(int);
    descriptor: (I)V // 表示函数参数类型是int,返回类型是void
    flags: (0x0001) ACC_PUBLIC // 表示函数是public的
    Code:
      stack=1, locals=3, args_size=2 // 栈深度为1,局部变量表为2,参数个数为2
         0: bipush        10
         2: istore_2
         3: iload_1
         4: tableswitch   { // 1 to 3 看这里,优化成了tableswitch
                       1: 32 // 表示case到1就执行第32行字节码
                       2: 37
                       3: 42
                 default: 47
            }

可以看到,我们确实是优化成了tableswitch,因为我们case的值是"连续的",连续的肯定是紧凑的,那么紧凑必须是连续的吗?非也!我们修改代码如下:

public void test(int a){
    int b = 10;
    switch(a) {
        case 1: 
            b = 1;
        break;
        case 3: 
            b = 3;
        break;
        case 5: 
            b = 5;
        break;
        default:
            b = 4;
        break;
    }
}

现在,我们的case值是1、3、5,不连续了,我们继续javap一下来看字节码:

public void test(int);
    descriptor: (I)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=3, args_size=2
         0: bipush        10
         2: istore_2
         3: iload_1
         4: tableswitch   { // 1 to 5 这里自动补上了2和4
                       1: 40
                       2: 55 // 执行第55行字节码
                       3: 45
                       4: 55 // 也是执行第55行字节码
                       5: 50
                 default: 55 // 也是执行第55行字节码
            }

可以看到,这里自动补上了2和4,变为连续的了,为的就是凑齐一个数组,然后可以直接根据下标进行查找定位,从而达到O(1)的时间复杂度。而且2和4跟default一样,都执行第55行字节码,因为本来就没有2和4,就按default处理。

现在,让我们修改case 3为case 100,其他不变,然后看字节码:

public void test(int);
    descriptor: (I)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=3, args_size=2
         0: bipush        10
         2: istore_2
         3: iload_1
         4: lookupswitch  { // 3 变为lookupswitch了
                       1: 40
                       5: 51
                     100: 45
                 default: 56
            }

我们发现,现在变为lookupswitch了,并且还发现,case的值按照顺序排序了,这是为了使用二分查找。

因为从5到100断层比较严重,我们不可能为了O(1)的时间复杂度去插入95个值,可以看出JVM在时间和空间还是衡量的比较人性化的。

这里需要注意两点:

  • tableswitch的生成的条件是"紧凑"而不是"数值小",比如10001,10002,10003也会优化成tableswitch,而1,100,200就只能优化成lookupswitch。
  • 如果case的值是String,那么会优化成lookupswitch,因为hashcode一般都是比较分散的(避免碰撞嘛)。

对String的特殊处理

上面我们说到,对String的处理是通过hashcode的。那么,如果两个String的hashcode相同会怎么样呢?比如"Aa"和"BB",他们的hashcode都是2112,那么如下函数:

public void test(String a) {
    int b = 10;
    switch(a) {
        case "Aa":
            b = 1;
        break;
        case "BB":
            b = 100;
        break;
        default:
            b = 1000;
        break;
    }


是不是传入"Aa"和"BB"都一样呢?不会!

因为如果switch的状态值是String的时候,除了进行hashcode校验,还会使用equals进行值校验,类似于HashMap中的定位逻辑,等价代码如下:

public void test(String a) {
    int b = 10;
    switch(a.hashCode()) { // 这里使用hashcode进行检索
        case 2112: // 转换为hashcode进行检索
            if(a.equals("Aa")) { // hashcode命中了,就是用equals来进行二次定位
                b = 1;
            }else if(a.equals("BB")) {
                b = 100;
            }
        break;
        default:
            b = 1000;
        break;
    }


可以看到,switch-case在条件值是String的时候,会使用hashcode作为switch-case的状态值,当hashcode相同的时候,再使用equals来进行二次比较,只有hashcode和equals都相同才说明复合条件,「这属于分页思想:粗略定位到精准定位」

「Tips:并不是只有hashcode相同才使用equals比较,而是不管hashcode是否相同,都会使用equals进行比较。」

我们将上述代码编译下,来看字节码:

 public void test(java.lang.String);
    descriptor: (Ljava/lang/String;)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=5, args_size=2
         0: bipush        10
         2: istore_2
         3: aload_1
         4: astore_3
         5: iconst_m1
         6: istore        4
         8: aload_3
         9: invokevirtual #7                  // Method java/lang/String.hashCode:()I // 这里调用了String的hashCode()
        12: lookupswitch  { // 1
                    2112: 32 // 只有一个2112,说明合并了,32表示执行第32行字节码
                 default: 59 // 否则就执行第59行字节码
            }

我们接着来看字节码指令(只看注释部分即可),对字节码指令不熟悉可以看这里:

32: aload_3                          
33: ldc           #13                 // String BB // 将BB推送至栈顶
35: invokevirtual #15                 // Method java/lang/String.equals:(Ljava/lang/Object;)Z // 执行equals语句
38: ifeq          47                  // 如果相同(if equals)
41: iconst_1
42: istore        4
44: goto          59
47: aload_3
48: ldc           #19                 // String Aa // jiangAa推送值栈顶
50: invokevirtual #15                 // Method java/lang/String.equals:(Ljava/lang/Object;)Z // 执行equals语句
53: ifeq          59                  // 如果相同
56: iconst_0
57: istore        4
59: iload         4
61: lookupswitch  { // 2
                0: 88
                1: 93
            default: 99
    }
88: iconst_1
89: istore_2
90: goto          103
93: bipush        100
95: istore_2
96: goto          103
99: sipush        1000
102: istore_2
103: return

上述代码验证了我们的结论: 「switch-case条件是String时候,使用String的hashCode来作为状态值,如果相同就合并,内部使用equals进行二次比较。」

优化

明白了上述原理,我们怎么在代码中优化呢?很简单!

  • 第一: 「我们尽可能的让case的值连续,或者紧凑,千万别为了装x而xjb写,而且尽量不使用String(因为hashcode本来就比较分散)」

  • 第二: 「如果业务比较复杂,或者出于拓展性考虑,那么可以使用hashmap来优化,毕竟hashmap的时间复杂度大部分情况都是O(1)的,而且将来修改也满足OCP」

比如上述代码我们就可以这样用hashmap来优化:

// 首先定义一个Action接口:
public interface Action {
    int action();
}

// 然后初始化
private HashMap<Integer, Action> actions = new HashMap<>();
{
    actions.put(1, () -> 1); // case 1
    actions.put(3, () -> 3); // case 3
    actions.put(5, () -> 5); // case 5
}

// test函数就可以简写为如下:
public void test(int a) {
    int b = actions.get(a).action();
}

将来要添加case或删除case,只要在actions中添加或删除值就行,美滋滋。

综上,我们可以知道:

  • 1 建议优先使用hashmap来处理switch-case这种复合条件的场景。
  • 2 如果条件比较简单,或者不必考虑拓展性,可以使用swtich-case,但是建议 非必要不使用 String来作为case值,而且建议case值一定要紧凑。


文章转载自奔波儿灞取经,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论