一、背景
《阿里巴巴Java开发手册》详解第 9 节 当 switch 遇到空指针小节有一个学员给出了这样一个课后题:
public class SwitchTest { public static void main(String[] args) { String param = null; switch (param="null") { case "null": System.out.println("null"); break; default: System.out.println("default"); } }
weibo_LittleYul 同学提出了下面的疑问:
有点迷,看了反编译的代码,param参数压根没有被用到,switch中的赋值语句被拎到前面了,而且还多了个临时变量var2=“null”, 后续实际上是使用的var2,跟param没有关系了。
求解惑
问题是代码输出的结果是什么?
二、反汇编和反编译
专栏的《加餐1:工欲善其事必先利其器》小节 给出了多种反编译和反汇编的工具,大家感兴趣自己去看。
我们先Java 反汇编看字节码然后再反编译看“源码”。
我这里所说的反汇编是指从 class文件中解析字节码的过程;所谓的反编译是指将 class文件反向解析为 Java 源代码的过程。
2.1 反汇编
编译 javac SwitchTest.java
反汇编 javap -c -v SwitchTest
public class com.imooc.basic.learn_switch.SwitchTest minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #9.#20 // java/lang/Object."<init>":()V #2 = String #21 // null #3 = Methodref #22.#23 // java/lang/String.hashCode:()I #4 = Methodref #22.#24 // java/lang/String.equals:(Ljava/lang/Object;)Z #5 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream; #6 = Methodref #27.#28 // java/io/PrintStream.println:(Ljava/lang/String;)V #7 = String #29 // default #8 = Class #30 // com/imooc/basic/learn_switch/SwitchTest3 #9 = Class #31 // java/lang/Object #10 = Utf8 <init> #11 = Utf8 ()V #12 = Utf8 Code #13 = Utf8 LineNumberTable #14 = Utf8 main #15 = Utf8 ([Ljava/lang/String;)V #16 = Utf8 StackMapTable #17 = Class #32 // java/lang/String #18 = Utf8 SourceFile #19 = Utf8 SwitchTest3.java #20 = NameAndType #10:#11 // "<init>":()V #21 = Utf8 null #22 = Class #32 // java/lang/String #23 = NameAndType #33:#34 // hashCode:()I #24 = NameAndType #35:#36 // equals:(Ljava/lang/Object;)Z #25 = Class #37 // java/lang/System #26 = NameAndType #38:#39 // out:Ljava/io/PrintStream; #27 = Class #40 // java/io/PrintStream #28 = NameAndType #41:#42 // println:(Ljava/lang/String;)V #29 = Utf8 default #30 = Utf8 com/imooc/basic/learn_switch/SwitchTest3 #31 = Utf8 java/lang/Object #32 = Utf8 java/lang/String #33 = Utf8 hashCode #34 = Utf8 ()I #35 = Utf8 equals #36 = Utf8 (Ljava/lang/Object;)Z #37 = Utf8 java/lang/System #38 = Utf8 out #39 = Utf8 Ljava/io/PrintStream; #40 = Utf8 java/io/PrintStream #41 = Utf8 println #42 = Utf8 (Ljava/lang/String;)V { public com.imooc.basic.learn_switch.SwitchTest3(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: aconst_null 1: astore_1 2: ldc #2 // String null 4: dup 5: astore_1 6: astore_2 7: iconst_m1 8: istore_3 9: aload_2 10: invokevirtual #3 // Method java/lang/String.hashCode:()I 13: lookupswitch { // 1 3392903: 32 default: 43 } 32: aload_2 33: ldc #2 // String null 35: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z 38: ifeq 43 41: iconst_0 42: istore_3 43: iload_3 44: lookupswitch { // 1 0: 64 default: 75 } 64: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream; 67: ldc #2 // String null 69: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 72: goto 83 75: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream; 78: ldc #7 // String default 80: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 83: return LineNumberTable: line 5: 0 line 6: 2 line 8: 64 line 9: 72 line 11: 75 line 13: 83 StackMapTable: number_of_entries = 5 frame_type = 254 /* append */ offset_delta = 32 locals = [ class java/lang/String, class java/lang/String, int ] frame_type = 10 /* same */ frame_type = 20 /* same */ frame_type = 10 /* same */ frame_type = 249 /* chop */ offset_delta = 7 } SourceFile: "SwitchTest.java"
如果你是初学者,看不太懂上面的字节码,可以根据右侧的注释大致了解即可。
如果想想写了解每个指令的含义,可以使用加餐1 里推荐的 jclasslib 插件。
如果想系统学习字节码相关知识,请自行学习《深入理解Java虚拟机》、《Java虚拟机规范》。
这段代码和该小节的字节码有些类似,但是稍微复杂一点点。
通过字节码我们可以看出两次使用了switch ;我们知道先通过 hash值和 case比较,然后再通过 equals 进行对比。
2.2 反编译
2.2.1 IDEA 反编译
// // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // public class SwitchTest { public SwitchTest() { } public static void main(String[] var0) { String var1 = null; var1 = "null"; String var2 = "null"; byte var3 = -1; switch(var2.hashCode()) { case 3392903: if (var2.equals("null")) { var3 = 0; } default: switch(var3) { case 0: System.out.println("null"); break; default: System.out.println("default"); } } } }
我们发现这种反汇编方式,基本按照字节码的逻辑完整翻译过来。
2.2.2 JD-GUI 反编译
public class SwitchTest { public static void main(String[] paramArrayOfString) { String str1 = null; String str2 = str1 = "null"; byte b = -1; switch (str2.hashCode()) { case 3392903: if (str2.equals("null")) b = 0; break; } switch (b) { case 0: System.out.println("null"); return; } System.out.println("default"); } }
和上述的反编译代码基本相同,变量的命名和赋值略有差异。
2.2.3 Luyten 反编译
public class SwitchTest { public static void main(final String[] array) { final String s = "null"; switch (s) { case "null": { System.out.println("null"); break; } default: { System.out.println("default"); break; } } } }
使用 luyten 反编译的代码和上面的代码有较大差异,但是本质相同。
3 问题解答
3.1 为什么会有这种疑问
先思考下面一个问题,两个函数是否等价?
public void some(String a){ // 代码省略 } public void some(String b){ // 该函数代码,除了参数名不同,其他完全一样 }
下面两个函数是否等价:
public void some(){ String a; // 代码省略 } public void some(){ String b; // 该函数代码,除了变量名不同,其他完全一样 }
再比如很多Java语法糖的内容,反编译之后都会有另外一种表示形式,反推回来代码会有不同:
public class ListForEach { public static void main(String[] args) { List<String> data = new ArrayList<>(); data.add("a"); data.add("b"); for (String str: data) { System.out.println(str); } } }
反编译后main函数内的代码:
0 new #2 <java/util/ArrayList> 3 dup 4 invokespecial #3 <java/util/ArrayList.<init>> 7 astore_1 8 aload_1 9 ldc #4 <a> 11 invokeinterface #5 <java/util/List.add> count 2 16 pop 17 aload_1 18 ldc #6 <b> 20 invokeinterface #5 <java/util/List.add> count 2 25 pop 26 aload_1 27 invokeinterface #7 <java/util/List.iterator> count 1 32 astore_2 33 aload_2 34 invokeinterface #8 <java/util/Iterator.hasNext> count 1 39 ifeq 62 (+23) 42 aload_2 43 invokeinterface #9 <java/util/Iterator.next> count 1 48 checkcast #10 <java/lang/String> 51 astore_3 52 getstatic #11 <java/lang/System.out> 55 aload_3 56 invokevirtual #12 <java/io/PrintStream.println> 59 goto 33 (-26) 62 return
可以看出用了迭代器实现的。
那么如果只给你这个字节码文件,让你反向写出源码显然你通过迭代器的方式也没问题,因为本质是一样的:
public static void main(String[] args) { List<String> data = new ArrayList<>(); data.add("a"); data.add("b"); Iterator<String> iterator = data.iterator(); while (iterator.hasNext()) { System.out.println(iterator.next()); } }
现在回头看该问题:
param参数压根没有被用到,switch中的赋值语句被拎到前面了,而且还多了个临时变量var2=“null”, 后续实际上是使用的var2,跟param没有关系了。
为什么会有这种疑问?
1、第一点是对概念理解的不清楚。
我专栏一直强调的,很多人总是记忆结论,忽略了“是什么”和“为什么”,这才是他们学不好的根本原因之一。
反编译是逆向工程,是根据产物逆推源代码的过程,和原始的代码不完全一样非常正常。
2、第二点“买椟还珠”,只重其形,不重其意。
两个函数并不会因为参数名字不同就不等价,这个问题一直纠结的就是“形式”,而不是思考两者的等价关系。
这是很多人学习不好的另外一个经典表现。
其实上面几种反编译的源码执行效果和我们的原始代码执行效果相同,逻辑类似。
3、看过并不代表记住,更不代表理解。
专栏中明确引用了《Java语言规范》的部分内容,讲到switch 括号内会先执行括号内的表达式, 而 param = "null" 表达式的(返回)值为“null”字符串,然后根据将表达式的结果通过不同的函数(String类型会使用 hashCode)转为整数,再去判断,因此后续操作本来就和 param没啥关系了。
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: aconst_null 1: astore_1 2: ldc #2 // String null 4: dup 5: astore_1 6: astore_2 7: iconst_m1 8: istore_3 9: aload_2 10: invokevirtual #3 // Method java/lang/String.hashCode:()I 13: lookupswitch { // 1 3392903: 32 default: 43 } // 省略其他
再从字节码层面看,param即为局部变量1, aconst_null ;astore_1即将 null赋值给param.
ldc #2 将 “null”字符串加载到操作数栈;
dup 将“null”字符串,复制一份并再次压栈;
astore_1 将字符串"null" 保存到局部变量表索为1的位置(赋值给param)
注:静态函数的局部变量表中,索引为0的位置是参数 args
astore_2 将字符串"null" 保存到局部变量表索引为2的位置(赋值给新的字符串变量,如 var2或者 s等临时变量)
然后执行上面局部变量的 hashCode函数,然后去比较,然后.....
后续的执行和原始的param变量没有关系了(通过反编译的一些代码也可以看出来)。
这也是我另外一篇文章《为什么要推荐大家学习字节码》的重要原因,你多了一个理解程序的途径。
另外有些同学可能对 dup有点困惑,不懂为什么要复制一份,或者担心这种复制是不是效率更低。
字符串 null 复制一份并再次入栈 是为了 store 存到局部变量表
这种重复是必然的,因为要保存到两个局部变量中,而且每次store就会出栈,所以必须dup一次,否则后面还得加载 null 字符串再store
相当于把:
ldc #2 astore_1 ldc #2 astore_2
改为了
ldc #2 dup astore_1 astore_2
效果相同,没有新增变量,操作指令也没增加,而且从时间和空间局部性的角度考虑,这种效率应该更高
可以类比理解:这就像两次查询数据库同一条数据(而且确认不会修改)然后使用,其实可以查询一次然后clone一份对象分别使用即可。
3.2 思维和方法的重要性
3.2.1 形式和内涵
高中政治中学过“要透过现象看本质”,如果我们能够重其义而忽略其形式,就更容易抓住问题的本质。
参数的不同只不过是形式的不同而已,本质是等价的。
3.2.2 类比分析
大家写同一个汉字,每个人的字体多少都会有些差异,难道就不是同一个汉字吗?
同一句汉语,用不同的句式准确翻译成英语,意思就变了吗?
一个前后两天换不同的外套,就不是一个人吗?
这种例子还有很多,通过类比分析就非常容易理解这个问题。
3.2.3 对比分析
正如上面所示,我们可以使用多种反编译工具去反编译,对比他们之间的异同,这非常有助于我们理解这个问题,理解不同工具的差异。
3.2.4 逆向思维
我们是不是可以把反编译后的代码都重新编译成类文件然后都反汇编去观察异同呢?
显然是可以的,这里就不动手了,大家可以尝试一下,本质应该差异不大。
3.2.5 以终为始
源码是给人看的,字节码是给虚拟机执行的,因此字节码对虚拟机来说易执行即可,对用户的可读性要求并不太高。
因此变量名称即使没有编译到类文件中,只要类型和引用能够对应上,并不影响程序的正确性和效率等。
当然,还可能有其他思维和方法,这里只举例这几个比较典型的。
3.3 Think More
当然这位同学也非常值得肯定,是因为他起码能够主动思考,注意到被很多人容易忽略的东西。
有好奇心,才能驱使自己去探究问题。
按照咱们专栏的惯例,我们应该会思考:为什么参数名不同呢?(5W 思想,和先猜想后验证的思想)
根据不同的反编译工具产出的源码我们不难猜想:
他们都是根据该函数字节码的逻辑反向生成的源码,参看反汇编的字节码,可以看出只给出了函数参数类型描述、变量类型描述等,并没有给出具体的变量名称:
public class com.imooc.basic.learn_switch.SwitchTest minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #9.#20 // java/lang/Object."<init>":()V #2 = String #21 // null #3 = Methodref #22.#23 // java/lang/String.hashCode:()I #4 = Methodref #22.#24 // java/lang/String.equals:(Ljava/lang/Object;)Z #5 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream; #6 = Methodref #27.#28 // java/io/PrintStream.println:(Ljava/lang/String;)V #7 = String #29 // default #8 = Class #30 // com/imooc/basic/learn_switch/SwitchTest3 #9 = Class #31 // java/lang/Object #10 = Utf8 <init> #11 = Utf8 ()V #12 = Utf8 Code #13 = Utf8 LineNumberTable #14 = Utf8 main #15 = Utf8 ([Ljava/lang/String;)V #16 = Utf8 StackMapTable #17 = Class #32 // java/lang/String #18 = Utf8 SourceFile #19 = Utf8 SwitchTest.java #20 = NameAndType #10:#11 // "<init>":()V #21 = Utf8 null #22 = Class #32 // java/lang/String #23 = NameAndType #33:#34 // hashCode:()I #24 = NameAndType #35:#36 // equals:(Ljava/lang/Object;)Z #25 = Class #37 // java/lang/System #26 = NameAndType #38:#39 // out:Ljava/io/PrintStream; #27 = Class #40 // java/io/PrintStream #28 = NameAndType #41:#42 // println:(Ljava/lang/String;)V #29 = Utf8 default #30 = Utf8 com/imooc/basic/learn_switch/SwitchTest3 #31 = Utf8 java/lang/Object #32 = Utf8 java/lang/String #33 = Utf8 hashCode #34 = Utf8 ()I #35 = Utf8 equals #36 = Utf8 (Ljava/lang/Object;)Z #37 = Utf8 java/lang/System #38 = Utf8 out #39 = Utf8 Ljava/io/PrintStream; #40 = Utf8 java/io/PrintStream #41 = Utf8 println #42 = Utf8 (Ljava/lang/String;)V { public com.imooc.basic.learn_switch.SwitchTest3(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: aconst_null // 省略中间 80: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 83: return LineNumberTable: line 5: 0 line 6: 2 line 8: 64 line 9: 72 line 11: 75 line 13: 83 StackMapTable: number_of_entries = 5 frame_type = 254 /* append */ offset_delta = 32 locals = [ class java/lang/String, class java/lang/String, int ] frame_type = 10 /* same */ frame_type = 20 /* same */ frame_type = 10 /* same */ frame_type = 249 /* chop */ offset_delta = 7 } SourceFile: "SwitchTest.java"
我们不能说参数名没有被编译到类文件中,但是可以肯定的是前面几个反编译软件没有读取实际的参数名,可能是怕麻烦,也能是觉得没必要。
另外我们可以用搜索引擎大法,我们找到了下面的参考(未必权威):
编译之后的class文件默认是不带有参数名称信息的,使用 IDE 时,反编译jar包得到的源代码函数参数名称是 arg0,arg1......这种形式,这是因为编译 jar 包的时候没有把符号表编译进去。
JDK1.7 及以下版本的 API 并不能获取到函数的参数名称,需要使用字节码处理框架,如 ASM、javassist 等来实现,且需要编译器开启输出调试符号信息的参数的-g。这个过程简单描述就是:
编译器javac使用-g输出调试符号信息到class文件
程序通过字节码解析框架解析class文件获取函数参数名称
通过jclasslib 插件我们可以读取到参数名:
因此应该是有能力获取到参数名称的,因此猜测可能是没必要也可能是有点难度,才导致没有获取并使用真实的参数名。
另外如果类文件中编译时没有携带参数名,那么反编译就无能为力了,对于虚拟机的执行来说是否用原始名称其实并没那么重要。
那么 Spring是如何获取参数名的呢?
其实也是通过读取类文件然后借助 asm 字节码工具来解析的。
那么 MyBatis是如何获取参数名的呢?
回想一下MyBatis的参数注解,是不是豁然开朗?
(知识只有关联在一起,才更容易被理解和记住)
大家还可以思考更多问题。
4 总结
希望大家可以从本质和更宏观的角度来思考问题,希望大家多积累思维方式解决问题的方法,而不是局限于学习具体知识点。
学习是一种能力,有些人只重视学习某个知识点,而不重视培养这种能力,导致后面自主学习就非常困难。
希望大家能够尝试自己去思考问题,然后通过书本,通过源码,通过和别人交流等去验证,而不是遇到问题就问,而且只重视答案而不重视过程和方法。
很多人学不好的原因是意识不到方法的重要性,另外急功近利,导致进步缓慢。
希望本文能对大家有所启发。
------------------------------
如果本文对你有帮助,欢迎点赞、评论和转发,你的支持是我创作的最大动力。
另外想学习,更多开发和避坑技巧,少走弯路,请关注《阿里巴巴Java 开发手册》详解专栏
共同学习,写下你的评论
评论加载中...
作者其他优质文章