一道看了答案都不知道爲什麼的面試題

掃描下方二維碼或者微信搜索公衆號菜鳥飛呀飛,即可關注微信公衆號,閱讀更多Spring源碼分析Java併發編程Netty源碼系列MySQL工作原理文章。

微信公衆號

題目

經常看到一道面試題,題目如下,每次都是猜答案,幾乎每次都猜錯。看到答案後,也無法解釋爲什麼,直到最近學習了 JVM 相關的知識,才理解透徹。

// 運行環境爲JDK版本1.8
public static void main(String[] args) {
    String  s1 = new String("1");
    s1.intern();
    String s2 = "1";
    System.out.println(s1 == s2);   // false

    String s3 = new String("1") + new String("1");
    s3.intern();
    String s4 = "11";
    System.out.println(s3 == s4);   // true
}

這道題在 JDK1.8 的環境下運行(注意:這道題與 JDK 的版本密切相關,不同版本會有不同的答案),結果分別爲 false、true。s1 和 s2 的比較結果,很容易判斷,而對於 s3 和 s4 的比較結果,則就不太好理解了,接下來將從字節碼和 JVM 內存結構的角度來解釋一下運行結果。

intern()

intern()方法是 String 類提供的一個方法,當調用一個字符串對象 s 的 intern()方法時,會先判斷字符串常量池中是否存在 s 所表示的字面量(這個判斷過程使用的是字符串的 equals()進行比較的,即比較的是字符串的內容),如果字符串常量池中存在該字面量,則 intern()方法不做任何操作,直接返回常量池中該字面量的地址;如果字符串常量池中不存在該字面量,那麼就將該字面量放入到字符串常量池中(也就是在常量池中造了一個"對象"),然後返回常量池中該字面量的地址。

這裏的關鍵點在於字符串常量池,在 JVM 虛擬機規範中,字符串常量池是屬於方法區的一部分,而方法區只是 Java 虛擬機規範中的概念,具體如何去實現方法區,是由各個虛擬機廠商自己決定的。並且同一廠商實現的虛擬機,在不同版本中也存在不同的區別。例如 HotSpot 虛擬機,在 JDK1.6 中,整個方法區都是在永久代(PermGem)實現的;到了 JDK1.7 中,方法區也是在永久代(PermGem)實現的,但與 JDK1.6 不同的是,將方法區中的運行時常量池和字符串常量池放到了堆空間,而其他部分在還是在永久代中;再到 JDK1.8 時,則是用元空間(Metaspace)實現的方法區,即用元空間(Metaspace)取代了永久代(PermGem),元空間使用的是直接內存,但是方法區中的運行時常量池和字符串常量池依舊是在堆空間,這和 JDK1.7 是相同的。如下面的示意圖所示。 字符串常量池在不同JDK版本中的變化

從 JDK1.6 到 JDK1.8,字符串常量池從永久代移到堆內存,對於 intern()方法,也產生了一定的變化。

假設現在有字符串對象 s(這個對象 s 是處於堆中的),它的字符串的內容是"aa"(即字面量爲"aa"),並且假設字符創常量池中也不存在字面量"aa" 。那麼在 JDK1.6 中,當調用 s.intern()方法時,由於字符傳常量池中不存在"aa",所以此時會在字符串常量池中(永久代)創建一個字符串"aa",示意圖如下。 JDK1.6中intern()方法

而在 JDK1.7、JDK1.8,或者更高版本中,當調用 s.intern()方法時,由於字符傳常量池中不存在"aa",所以此時也需要在字符串常量池中創建一個字面量"aa",但是注意此時與 JDK1.6 不同的是,字符串常量池被移到了堆內存當中,所以當此時在字符串常量池中創建一個字面量"aa"時,虛擬機發現堆內存中已經存在了字符串的值爲"aa"的對象 s,所以此時只是在字符串常量池中創建一個指針,指針指向的是堆內存當中對象 s 的地址,示意圖如下。(搞清楚這一點非常重要) JDK1.7、1.8中intern()方法

JDK1.7 及以後的版本中,對於 intern()方法爲什麼要這麼設計呢?我覺得原因可能是:節省堆空間。將字符串常量池從永久代移到堆空間後,我們創建的對象和字符串常量池都處於堆中,如果在調用 intern()方法時,再在字符串常量池創建一個字符串對象,這就和堆中的對象重複了,如果直接使用一個指針,指向堆中的對象,這樣就可以節省堆空間了。(對於在字符創常量池中創建指針這個說法,並不一定準確,這裏這是爲了方便描述。對於 intern()方法,在 1.7 及以上版本中,你也可以理解爲是直接將堆中對象 s 移到字符串常量池中,這樣最終的結果同樣是只會有一個"aa")。

解答開篇

前面鋪墊了那麼多,終於可以解釋一下開篇題目的前一半了。

String s1 = new String("1");
s1.intern();
String s2 = "1";
System.out.println(s1 == s2);   // false
  1. 當執行完String s1 = new String("1") 時,會創建出兩個對象:1) 字符串常量池中字面量爲"1"的字面量;2)堆中 s1 字符串對象;
  2. 當執行s1.intern() 時,由於字符串常量池中已經存在了字面量"1",所以 intern()方法不做任何操作,僅僅只是返回字符串常量池中字面量"1"的地址(雖然返回了常量池中字符串"1"的地址,但是我們並沒有用變量去接收這個返回值,所以這一行代碼可以理解爲啥也沒幹);
  3. 執行String s2 = "1" 時,由於字符串常量池中已經存在了字面量"1",所以此時 s2 指向的就是字符串常量池中"1"的地址;
  4. 因此在判斷s1 == s2 的時候,由於 s1 指向的是堆空間的對象,s2 指向的是字符串常量池中的對象,因此最終結果爲 false。
s1==s2
s1==s2

對於 s1 和 s2 的判斷,相對而言比較簡單,也比較好理解。不需要從字節碼角度就能得出正確的答案。而對於 s3 和 s4 的比較,就必須得從字節碼的角度,才能得出正確答案了。

new String("1") + new String("1")的字節碼指令

在解釋 s3 和 s4 之前,需要搞清楚這樣一個問題:String s = new String("1") + new String("1") 在 JVM 底層是如何實現字符串的拼接的。爲了方便說明描述,我定義瞭如下一個方法。

public void append(){
    String s = new String("1") + new String("1");
}

接下來來看一下該方法的字節碼,查看字節碼的方法有很多,可以通過javap -v 文件名 ,也可以通過第三方工具,例如jclasslib,也可以在 IDEA 中安裝該插件。最終看到的字節碼如下圖所示。 字符串拼接字節碼

字節碼的重點部分我用紅色框標記出來了,下面解釋一下。

  1. 首先 new 了一個 StringBuilder 對象,因此可以看出來,對於上面的字符串拼接操作,其底層採用的是 StringBuilder 來進行拼接的。
  2. 創建了一個 String 對象,也就是對應字符串拼接的前半部分;
  3. 然後通過字節碼指令ldc從字符串常量池中加載了一個字面量"1",隨後賦值給 2 中創建的 String 對象;
  4. 調用 StringBuilder 的 append 方法進行拼接;
  5. 接着又創建了一個 String 對象,也就是對應字符串拼接的後半部分;同樣也是通過字節碼指令ldc從字符串常量池中加載了一個字面"1",隨後賦值給剛剛創建的 String 對象;
  6. 接着又調用 StringBuilder 的 append 方法進行拼接;
  7. 最後調用 StringBuilder 的 toString()方法,然後將結果返回。

至此,我們可以看下,這一步一共產生了多少個對象。堆中:1 個 StringBuilder 對象、2 個 String 對象,然後字符串常量池中一個字面量"1",也就是 4 個對象。

然而,真的只有 4 個對象嗎?其實不止,因爲最後還調用了StringBuilder 的 toString() 方法,我們可以看下 StringBuilder.toString()方法的源碼以及字節碼,如下圖所示。

StringBuilder.toString()源碼
StringBuilder.toString()源碼

從圖中可以發現,在 StringBuilder.toString()中,也會創建一個新的 String 對象,因此我們示例中這個字符串操作,最終會產生 5 個對象。從這個結論中,我們也可以理解,爲什麼在進行多個字符串拼接時,儘量不要使用 "加號" 這種連字符,因爲在 JVM 中會 new 很多對象,效率不高。

另外,上面的分析中,其實還隱藏着另外一個結論:兩個"1"進行拼接後,結果爲"11",而這個字符串"11"實際上只是存在於堆空間中的一個對象,在字符串常量池中,並不存在字面量"11",只存在"1" 。 理解這一點是解答開篇中s3==s4結果的關鍵。

解釋

接下來解釋一下爲什麼s3==s4爲什麼在 JDK1.8 下,運行結果爲 true。

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);   // true
  1. 首先執行完String s3 = new String("1") + new String("1") 後,s3 指向的是堆空間對象地址,並且在字符串常量池中並沒有產生字面量"11";
  2. 由於第 1 步中,在字符串常量池中並沒有產生字面量"11",所以調用s3.intern() 方法時,會向字符常量池中嘗試創建一個字面量"11"。又因爲這是在 JDK1.8 環境下,所以此時在字符串常量池中不會真的創建一個字面量"11",而是創建一個指針,指針指向的是堆空間中的上 s3 的對象。(至於爲什麼,前面在介紹 intern()方法時已經解釋了具體原因)
  3. 執行String s4 = "11" 時,發現字符串常量池中存在字面量"11"的指針,這個字面量指針指向的是 s3 對象的地址,因此 s4 也會指向 s3 的對象的地址;
  4. 因此 s3 和 s4 都指向的是堆空間的同一個對象,所以結果爲 true。

示意圖如下。 s3==s4

相似問題

現在把這道題的前提條件修改爲在JDK1.6中運行,結果又會不一樣。輸出結果兩個均爲 false,這又是爲什麼呢?

對於 s1 和 s2 的判斷結果比較好理解,一個指針指向堆空間,一個指針指向永久代,所以結果爲 false。而對於 s3 和 s4 的比較,就有點不一樣了。因爲在 JDK1.6 中,字符串常量池是處於永久代中的,當 s3 調用 intern()方法時,如果字符串常量池中不存在"11",則會創建一個字面量"11",而不像 JDK1.8 中,會讓字符創常量池的指針指向堆中的對象。因此最終 s3 指向的是堆空間中的對象,而 s4 指向的是永久代中字符串常量池中的對象,這兩個地址不一樣,因此結果爲 false。

再把這道題做一下稍微做一下修改,把s3.intern() 這一行代碼向下移動一行,運行環境依然是 JDK1.8,代碼如下,那麼運行結果是多少呢?

String s3 = new String("1") + new String("1");
String s4 = "11";
s3.intern();
System.out.println(s3 == s4);

這個時候,結果就變成了 false。爲什麼?這是因爲 s3 在創建完成時,字符串常量池中還不存在字面量"11",然後我們執行String s4 = "11" 會直接向字符串常量池中添加一個字面量爲"11"的字符串(因爲使用字節碼中使用的是ldc字節碼指令),此時 s4 指向的地址是字符串常量池。當我們再調用s3.intern() 時,由於字符串常量池已經存在了"11",所以 intern()方法什麼事都不會幹。因此最終 s3 指向的是堆空間,而 s4 指向的是字符串常量池,所以最後結果爲 false。

總結

實際上,與這道面試題相似的題目很多,如果要對這類問題準確得出答案,其根本上需要對字符串拼接的原理比較熟悉,需要熟悉字符串拼接符號加號的底層原理,它在字節碼上是如何實現的,另外還需要明白 JVM 中運行時數據區的結構,以及在 JDK 不同版本中,它們有什麼細微的區別。

事實上,這類題目,我們在實際工作中基本不會遇到,也只會在面試時可能會遇到,那搞清楚它又有什麼意義呢?實際上它考察的是一個開發人員的基本功,對 JVM 的瞭解程度。這也是最近筆者在學習 JVM 方面的一點心得,以前碰到這類面試題搞不明白時基本都是背答案,但是題目稍微做一點改動,往往會得出錯誤的答案。而如果從 JVM 的角度去理解,那麼這類題對我們而言,也只是換個不同的殼子。

微信公衆號
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章