這是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 一頓爆學。
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 虛擬機器規範》的文件裡面挖到了一句話。我覺得得講一下。
switch表示式支援的型別
在《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,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。
還有,重要的事情說三遍:歡迎關注我呀。歡迎關注我呀。歡迎關注我呀。