赞
踩
这是why的第 65 篇原创文章
大家好,我是 why,欢迎来到我连续周更优质原创文章的第 65 篇。老规矩,先荒腔走板聊聊技术之外的东西。
上面这图是去年的成都马拉松赛道上,摄影师抓拍的我。哎,真是阳光向上的 95 后帅小伙啊。
今年由于疫情原因,上半年的马拉松比赛全部停摆了。今年可能也没有机会再跑一次马拉松了。只有回味一下去年的成都马拉松了。
去年成都马拉松我跑的是半程,只有 21 公里,女朋友也报名跑了一个 5 公里的欢乐跑,所以前 5 公里都是陪着她边跑边玩。
过了 10 公里后,赛道两边的观众越来越多,成都的叔叔阿姨们特别的热情。老远看到我跑过来了,就用四川话大声的喊:帅哥,加油。
还有很多老年人,手上拿着个小型国旗,在那里手舞足蹈的挥舞着。
当然还有很多三五成群的小朋友,伸长了手臂,极力张开着五指。那是他们要和你击掌的意思。
每击一次,跑过之后都能听到小朋友那特有的一连串的笑声。他们收获了欢乐,而我收获了力量。
有一个转弯的地方,路边站着的男女老少都伸长着手臂,张开着五指,延绵几十米,每个人嘴里喊着鼓劲的话。
我放慢脚步,一个个的轻轻击掌过去。这个时候耳机里面传来的是我循环播放的成都宣传曲《I love this city》。
我不知道应该怎样去描述那种氛围带给我的激励和感动,感觉自己就是奔跑在星光大道上,我很怀恋。
每跑完一次马拉松,都能带给我爆棚的正能量。
当然了,成都马拉松的官方补给我也是吹爆的。但是给我印象深刻的是大概在 16 公里的地方,有一处私人补给站,我居然在这里喝了到几口乌苏啤酒,吃了几口豆花,几根凉面,几块冒烤鸭。逗留了大概 5 分钟的样子。
哎呀,那感觉,难以忘怀,简直是巴适的板。
好了,说回文章。
阿里巴巴出品的《码出高效 Java 开发手册》你知道吧?
前段时间我发现书的最后还有两道 Java 基础的面试题。其中有一道,非常的基础,可以说是入门级的题,但是都把我干懵了。
居然通过眼神编译,看不出输出结果是啥。
最后猜了个答案,结果还错了。
这篇文章就带着大家一起看看这题,分析分析他背后的故事。
首先看题:
public class SwitchTest {
public static void main(String[] args) {
//当default在中间时,且看输出是什么?
int a = 1;
switch (a) {
case 2:
System.out.println("print 2");
case 1:
System.out.println("print 1");
default:
System.out.println("first default print");
case 3:
System.out.println("print 3");
}
//当switch括号内的变量为String类型的外部参数时,且看输出是什么?
String param = null;
switch (param) {
case "param":
System.out.println("print param");
break;
case "String":
System.out.println("print String");
break;
case "null":
System.out.println("print null");
break;
default:
System.out.println("second default print");
}
}
}
这题主要是考的 switch 控制语句,你能通过眼神编译,在心里输出运行结果吗?
先看看答案:
怎么样,这个答案是不是和你自己给出来的答案一致呢?
反正我之前是被它那个 default 写在中间的操作给迷惑了。
我寻思这玩意还有这种操作?能这样写吗?
至于下面那个空指针,问题不大,一眼看出问题。
所以在我看来,这题一共两个考点:
前一个 switch 考的是其流程控制语言。
后一个 switch 考的是其底层技术实现。
我们一个个剥丝抽茧,扒光示众的说。一起把这个 switch 一顿爆学。
先看看考流程控制语句的:
这个程序的迷惑点在于第 5 行的注释,导致我主要关注这个 default 的位置了,忽略了每个 case 并没有 break。
没有 break 导致这个程序的输出结果是这样的:
那么 switch 是怎么控制流程的呢?
带着这个问题我们去权威资料里面寻找答案。
什么权威资料呢?
https://docs.oracle.com/javase/specs/jls/se8/html/jls-14.html#jls-14.11
怎么样?
The Java® Language Specification,《Java 语言规范》,你就告诉我权不权威?
打开我上面给的链接,在这个页面那么轻轻的一搜:
这就是我们要找的东西。
点击过去之后,在这个页面里面的信息量非常大。我一会都会讲到。
现在我们先关注执行流程这块:
看到这么多英语,不要慌,why 哥这种暖男作者,肯定是给你翻译的巴巴适适的。但是建议大家也看看英文原文,有的时候翻译出来的可能就差点意思。
接下来我就给大家翻译一下官方的话:
来,第一句:
当 switch 语句执行的时候,首先需要计算表达式。
等等,表达式(Expression)是什么?
表达式就是 switch 后面的括号里面的东西。比如说,这个东西可以是一个方法。
那么如果这个表达式的计算结果是 null,那么就抛出空指针异常。这个 switch 语句也就算完事了。
另外,如果这个表达式的结果是一个引用类型,那么还需要进行一个拆箱的处理。
比如就像这样式儿的:
test() 方法就是表达式,返回的是包装类型 Integer,然后 switch 会做拆箱处理。
这个场景下 test 方法返回了 null,所以会抛出空指针异常。
接着往下翻译:
如果表达式的计算或者随后的拆箱操作由于某些原因突然完成,那么这个 switch 语句也就完成了。
突然完成,小样,说的还挺隐晦的。我觉得这里就是在说表达式里面抛出了异常,那么 switch 语句也就不会继续执行了。
就像这样式儿的:
接下来就是流程了:
Otherwise,就是否则的意思。带入上下文也就是说前面的表达式是正常计算出来了一个东西了。
那么就拿着计算出来的这个东西(表达式的值)和每一个 case 里面的常量来对比,会出现以下的情况:
如果表达式的值和其中一个 case 语句中的常量相等了,那么我们就说 case 语句匹配上了。switch 代码块中匹配的 case 语句之后的所有语句 (如果有)就按照顺序执行。如果所有语句都正常完成,或者在匹配的 case 语句之后没有语句,那么整个 switch 代码块就将正常完成。
如果没有和表达式匹配的 case 语句,但是有一个 default 语句,那么 switch 代码块中 default 语句后面的所有语句(如果有)将按顺序执行。如果所有语句都正常完成,或者如果 default 标签之后没有语句了,则整个 switch 代码块就将正常完成。
如果既没有 case 语句和表达式的值匹配上,也没有 default 语句,那就没有什么搞的了,switch 语句执行了个寂寞,也算是正常完成。
其实到这里,上面的情况一不就是阿里巴巴 Java 开发手册的面试题的场景吗?
你看着代码,再看着翻译,仔细的品一品。
为什么那道面试题的输出结果是这样的:
没有为什么,Java 语言规范里面就是这样规定的,按照规定执行就完事了。
除了上面这三种流程,官网上还接着写了三句话:
如果 switch 语句块里面包含任何的表示或者意外导致立即完成的语句,则按如下方式处理:
我先说一下我理解的官方文档中说的:“any statement immediately ... completes abruptly”。
表示立即完成的语句就是每个 case 里面的 break、return。
意外导致突然完成的语句就是在 switch 语句块里面任何会抛出异常的代码。
如果出现了这两种情况,switch 语句块怎么处理呢?
如果语句的执行由于 break 语句而完成,则不会采取进一步的操作(进一步操作是指如果没有 break 代码,则将继续执行后续语句),switch 语句块将正常完成。
如果语句的执行由于任何其他原因突然完成(比如抛出异常),switch 语句块也会因相同的原因而立马完成。
上面就是 switch 语句的执行流程。所以你还别觉得 switch 语句就必须要个 break,别人的设计就是如此,看场景的。
比如看官方给出的两个示例代码:
这是不带 break 的。需求就要求这样输出,你整个 break 干啥。
再看另外一个带 break 的:
实现的又是另外一个需求了。
所以,看场景。
另外,我觉得官网上的这个例子给的不好。最后少了一个 default 语句。看看阿里 Java 开发手册上怎么说的:
这个地方见仁见智吧。
第二个考点是底层技术实现。
也就下面这坨代码:
首先经过前面的一个小节,你知道为什么运行结果是抛出空指针异常了不?
前面讲了哈,官方文档里面有这样的一句话:
规定如此。
所以,这小节的答案是这样的吗?肯定不是的,我们多想一步:
为什么这样规定呢?
这才是这小节想要带大家寻找的东西。
首先你得知道 switch 支持 String 是 Java 的一颗语法糖。既然是语法糖, 我们就看看它的 class 文件:
从 class 文件中,我们尝到了这颗语法糖的味道。原来实际上是有两个 switch 操作的。
switch 支持 String 类型的原因是先取的 String 的 hashCode 进行 case 匹配,然后在每个 case 里面给 var3 这个变量赋值。然后再对 var3 进行一次 switch 操作。
所以,上图中标记的 15 行,如果 String 是 null,那么对 null 取 hashCode ,那可不得抛出空指针异常吗?
所以,你看《Java开发手册》里面的这个建议:
明白为什么这样写了吧?
所以,这小节的答案是这样的吗?肯定不是的,我们再多想一步呢:
为什么要非得把 String 取 hashCode 才进行 switch/case 操作呢?
从 class 文件中我们已经看不出什么有价值的东西了。只能在往下走。
class 再往下走就到哪里了?
对了,需要看看字节码了。
通过 javap 获得字节码文件:
这个字节码很长,大家自己编译后去看一下,我就不全部截取,浪费篇幅了。
在这个字节码里面,就算你什么都不太明白。但是只要你稍微注意一点点,你应该会注意到其中的这两个地方:
结合着 class 文件看:
奇怪了,同样的 switch 语言,却对应两个指令:lookupswitch 和 tableswitch。
所以这两个指令肯定是关键突破点。
我们去哪里找这个两个指令的信息呢?
肯定是得找权威资料的:
怎么样?
The Java® Virtual Machine Specification,Java 虚拟机规范,你就大声的告诉我稳不稳?
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-3.html#jvms-3.10
在上面的链接中,我们轻轻的那么一搜:
发现这两个指令,在 Compiling Switches 这一小节中是挨在一起的。
找到这里了,你就找到正确答案的门了。我带领大家看一下我通过这个门,看到的门后面的世界。
首先还是给大家带着我自己的理解,翻译一下虚拟机规范里面是怎么介绍这两个指令的:
switch 语句的编译使用的是 tableswitch 和 lookupswitch 这两个指令。
我们先说说 tableswitch 是干啥的。
当 switch 里面的 case 可以用偏移量进行有效表示的时候,我们就用 tableswitch 指令。如果 switch 语句的表达式计算出来的值不在这个偏移量的有效范围内,那么就进入 default 语句。
看不太明白对不对?
没关系,我第一次看的时候也不太明白。别急,我们看看官方示例:
因为我们 case 的条件是 0、1、2 这三个挨在一起的数据,挨在一起就是 near 。所以这个方法就叫做 chooseNear 。
而这个 0、1、2 就是三个连在一起的数字,所以我们可以用偏移量直接找到其对应的下一个需要跳转的地址。
这个就有点类似于数组,直接通过索引下标就能定位到数据。而下标,是一串连续的数字。
这个场景下,我们就可以用 tableswitch。
接着往下看:
当 switch 语句里面 case 的值比较“稀疏”(sparse)的时候,用 tableswitch 指令的话空间利用率就会很低下。于是我们就用 lookupswitch 指令来代替 tableswitch。
你注意官网上用的这个词:sparse。
没想到吧,学技术的时候还能学个英语四级单词。
稀疏。翻译过来了,还是读不懂是不是,没有关系。我给你搞个例子:
左边是 java 文件,里面的 case 只有 0、2、4。
右边是字节码文件, tableswitch 里面有0、1、2、3、4。
对应的 class 文件是这样的:
嘿,你说怎么着?莫名其妙多了个 1 和 3 的 case 。你说神奇不神奇?
这是在干嘛?这不就是在填位置嘛。
填位置的目的是什么?不就是为了保证 java 文件里面的 case 对应的值刚好能和偏移量对上吗?
假设这个时候 switch 表达式的值是 2,我直接根据偏移量 2 ,就可以取到 2 对应的接下来需要执行的地方 47,然后接着执行输出语句了:
假设这个时候 switch 表达式的值是 3,我直接根据偏移量 3,就可以取到 3 对应的接下来需要执行的地方 69,然后接着执行 default 语句了:
所以,0,1,2 不叫稀疏,0,2,4 也不叫稀疏。
它们都不 sparse ,缺一点点的情况下,我们可以补位。
所以现在你理解官网上的这句话了吗:
当 switch 语句里面 case 的值比较“稀疏”(sparse)的时候,用 tableswitch 指令的话空间利用率就会很低下。
比较稀疏的时候,假设三个 case 分别是 100,200,300。你不可能把 100 到 300 之间的数,除了 200 都补上吧?
那玩意补上了之后 case 得膨胀成什么样子?
空间占的多了,但是实际要用的就 3 个值,所以空间利用率低下。
那 tableswitch 指令不让用了怎么办呢?
别急,官方说可以用 lookupswitch 指令。
lookupswitch 指令拿着 switch 表达式计算出来的 int 值和一个表中偏移量进行配对(pairs)。
配对的时候,如果表里面一个 key 值与表达式的值配上了,就可以在这个 key 值关联的下一执行语句处继续执行。
如果表里面没有匹配上的键,则在 default 处继续执行。
你看明白了吗?迷迷糊糊的对不对?
什么玩意就出来一个表呢?
没事,别急,官方给了个例子:
这次的例子叫做 chooseFar 。因为 case 里面的值不是挨着的,0 到 100 之间隔得还是有点距离。
我不能像 tableswitch 似的,拿着 100 然后去找偏移量为 100 的位置吧。这里就三个数,根本就找不到 100 。
只能怎么办?
就拿着我传进来的 100 一个个的去和 case 里面的值比了,这就叫 pairs。
其实官网上的这个例子没有给好,你看我给你一个例子:
你看左边的 java 代码,里面的 case 是乱序的,到字节码文件里面后就排好序了。
而官方文档里面说的这个“table”:
就是排好序的这个:
为什么要排序呢?
答案就在虚拟机规范里面:
排序之后的查找比线性查找快。这个没啥说的吧。它这里虽然没有说,但其实它用的是二分查找,时间复杂度为O(log n)。
哦,对了。tableswitch 由于是直接根据偏移量定位,所以时间复杂度是 O(1)。
好了,到这里我就把 tableswitch 和 lookupswitch 这两个指令讲完了。
我不知道你在看的时候有没有产生什么疑问,反正我看到这个地方的时候我就在想:
虚拟机规范里面就说了个 sparse,那什么时候是稀疏,什么时候是不稀疏呢?
说实话,作为程序员,我对“稀疏”这个词还是很敏感的,特别是前面再加上毛发两个字的时候。
不知道为什么说到“稀疏”,我就想起了谢广坤。广坤叔你知道吧,这才叫“稀疏”:
所以,在 switch 里面,我们怎么定义稀疏呢?
文档中没有写。
文档里没有写的,都在源码里面。
于是我搞了个 openJDK,我倒要看看源码里面到底什么是 TMD 稀疏。
经过一番探索,找到了这个方法:
com.sun.tools.javac.jvm.Gen#visitSwitch
这里我不做源码解读,我只是想单纯的知道源码里面到底什么 TMD 是 TMD 稀疏。
所以带大家直接看这个地方:
这里有个三目表达式。如果为真则使用 tableswitch ,为假则使用 lookupswitch。
我们先拿着这个不稀疏的,加上断点调戏一番,呸,调试一番:
断点时候时候各个参数如下:
标号为 ① 的地方是代表我们确实调试的是预期的程序。
标号为 ② 的地方我们带入到上面的表达式中,可以求得最终值:
hi 是 case 里面的表达式对应的最大值,也就是 2。
lo 是 case 里面的表达式对应的最小值,也就是 0。
nlabels 代表的是 case 的个数,也就是 3。
所以带入到上面的代码中,最终算出来的值 16<=18,成立,使用 tablewitch。
这就叫不稀疏。
假设我们把最后一个 case 改为 5:
Debug 时各个参数变成了这样:
最终算出来的值 19<=18,不满足,使用 lookupswitch 。
这叫做稀疏。
所以现在我们知道了到底什么是 TMD 稀疏。
在源码里面有个公式可以知道是不是稀疏的,从而知道使用什么指令。
写到这里我觉得其实我应该可以住手了。
但是我还在《Java 虚拟机规范》的文档里面挖到了一句话。我觉得得讲一下。
在《Java 虚拟机规范》文档中的这一部分,有这样的一句话:
就看第一句我圈起来的话。后面的描述都是围绕着这句话在展开描述。
Java 虚拟机的 tableswitch 和 lookupswitch 指令,只支持 int 类型。
好,那我现在来问你:switch 语句的表达式可以是哪些类型的值?注意我说的是表达式。
这个答案在《Java 语言规范》里面也写着的:
你看,8 种基本类型已经支持了char、byte、short、int 这4 种,而这 4 种都是可以转化为 int 类型的。
而剩下的 4 种:double、float、long、boolean 不支持。
为什么?
你就想,你就结合我前面讲的内容,把你的小脑壳子动起来,为什么这 4 种不支持?
因为 double、float 都是浮点类型的,tableswitch 和 lookupswitch 指令操作不了。
因为 long 类型 64 位了,而tableswitch 和 lookupswitch 指令只能操作 32 位的 int 。这两个指令对于 long 是搞不动的。
而至于 boolean 类型,还需要我说嘛?
你拿着 boolean 类型放到 switch 表达式里面去,你不觉得害臊吗?
你就不能写个 if(boolean) 啥的?
然后你又发动你的小脑壳子想:对于 Character、Byte、Short、Integer 这 4 个包装类型是怎么支持的呢?
上个图,左上是 java 文件,右上是 jad 文件,下面是字节码:
拆了个箱,实际还是用的 int 类型,这个不需要我细讲了吧?
于是你接着想对于 String 类型是怎么支持的呢?
它会先转 hashCode。hashCode 肯定是稀疏的,所以用 lookupswitch。
然后在用 var3 这个变量去做一次 switch,经过转化后 var3 一定不是稀疏的,所以用 tableswitch:
你再多想一步,因为是用的 String 类型的 hashcode,那如果出现了哈希冲突怎么办?
看一下这个例子:
冲突了就再配一个 if-else 。
不用多说了吧。
最后,你再想,这个枚举又是怎么支持的呢?
比如下面这个例子,看字节码,只看到了使用了 tableswitch:
我们再看一下 class 文件,javap 编译之后,变成了这样:
它们分别长这样的:
上面的 SwitchEnumTest.class 文件看不出来什么道道。
但是下面的 SwitchEnumTest$1.class 文件里面还是有点东西的。
可以看到静态代码块里面有个数组,数组里面的参数是枚举的类型,然后调用了枚举的 ordinal 方法。这个方法的返回值是枚举的下标位置。
在 class 文件里面获取的信息有限,需要祭出 jad 文件来瞅一眼来:
上面就是 java 文件对应的 jad 文件。
标号为 ① 的地方是我们传入的 switch 里面的表达式,线程状态枚举中的 RUNNABLE。
标号为 ② 的地方是给 int 数值中的位置赋值为 2。那么是哪个位置呢?
RUNNABLE 在线程状态枚举中的下标位置,如下所示,下标位置是1:
编号为 ③ 的地方是把 int 数值中下标为 1 的元素取出来?
我们前面刚刚放进去的。取出来是 2。
于是走到编号为 ④ 的逻辑中去。执行最终的输出语句。
所以写到这里,我想我更加能明白著名程序员沃·滋基索德的一句话:
相对于 String 类型而言,枚举简直天生就支持 Switch 操作。
再送给你一个我在写这篇文章的时候学到的一个奇怪的知识点。
我们知道 switch 的表达式和 case 里面都是不支持 null 的。
你有没有想过一个问题。case 里面为什么不支持 null?如果表达式为 null ,我们就拿着 null 去 case 里面匹配,这样理论上做也是可以做的。
好吧,应该也没有人想这个问题。当然,除了一些奇奇怪怪的面试官。
这个问题我在《Java 语言规范》里面找到了答案:
the designers of the Java programming language。
我的妈呀,这是啥啊。
Java 编程语言设计者,这是赏饭吃的祖师爷啊!
《Java 语言规范》里面说:根据 Java 编程语言设计者的判断,抛出空指针这样做比静默地跳过整个 switch 语句或选择在 default 标签(如果有)里面继续执行语句要好。
别问,问就是祖师爷觉得这样写就是好的。
一个基本上用不到的知识点送给大家,不必客气:
这篇文章里面还是很多需要翻译的地方。我发现有很多的程序猿比较害怕英语。
之前还有人夸我英语翻译的好:
其实我大学的时候英语四级考了 4 次,最后一次才压线过的。
那为什么现在看英文文档基本上没有什么障碍呢?
其实这个问题真的很好解决的。
你找一个英语六级 572 分,考研英语一考了 89 分的女朋友,她会督促你学英语的。
好了,看到了这里安排个“一键三连”(转发、在看、点赞)吧,周更很累的,不要白嫖我,需要一点正反馈。
才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在留言区提出来,我对其加以修改。 感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。
我是 why,一个被代码耽误的文学创作者,不是大佬,但是喜欢分享,是一个又暖又有料的四川好男人。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。