1. 程式人生 > >why哥被阿里一道基礎面試題給幹懵了,一氣之下寫出萬字長文。

why哥被阿里一道基礎面試題給幹懵了,一氣之下寫出萬字長文。

這是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,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。

還有,重要的事情說三遍:歡迎關注我呀。歡迎關注我呀。歡迎關注我呀。