5道面試題,拿捏String底層原理!

原創:微信公眾號 碼農參上,歡迎分享,轉載請保留出處。

String字元串是我們日常工作中常用的一個類,在面試中也是高頻考點,這裡Hydra精心總結了一波常見但也有點燒腦的String面試題,一共5道題,難度從簡到難,來一起來看看你能做對幾道吧。

本文基於jdk8版本中的String進行討論,文章例子中的程式碼運行結果基於Java 1.8.0_261-b12

第1題,奇怪的 nullnull

下面這段程式碼最終會列印什麼?

public class Test1 {
    private static String s1;
    private static String s2;

    public static void main(String[] args) {
        String s= s1+s2;
        System.out.println(s);
    }
}

揭曉答案,看一下運行結果,列印了nullnull

在分析這個結果之前,先扯點別的,說一下為空null的字元串的列印原理。查看一下PrintStream類的源碼,print方法在列印null前進行了處理:

public void print(String s) {
    if (s == null) {
        s = "null";
    }
    write(s);
}

因此,一個為null的字元串就可以被列印在我們的控制台上了。

再回頭看上面這道題,s1s2沒有經過初始化所以都是空對象null,需要注意這裡不是字元串的"null",列印結果的產生我們可以看一下位元組碼文件:

編譯器會對String字元串相加的操作進行優化,會把這一過程轉化為StringBuilderappend方法。那麼,讓我們再看看append方法的源碼:

public AbstractStringBuilder append(String str) {
    if (str == null)
        return appendNull();
	//...
}

如果append方法的參數字元串為null,那麼這裡會調用其父類AbstractStringBuilderappendNull方法:

private AbstractStringBuilder appendNull() {
    int c = count;
    ensureCapacityInternal(c + 4);
    final char[] value = this.value;
    value[c++] = 'n';
    value[c++] = 'u';
    value[c++] = 'l';
    value[c++] = 'l';
    count = c;
    return this;
}

這裡的value就是底層用來存儲字元的char類型數組,到這裡我們就可以明白了,其實StringBuilder也對null的字元串進行了特殊處理,在append的過程中如果碰到是null的字元串,那麼就會以"null"的形式被添加進字元數組,這也就導致了兩個為空null的字元串相加後會列印為"nullnull"

第2題,改變String的值

如何改變一個String字元串的值,這道題可能看上去有點太簡單了,像下面這樣直接賦值不就可以了嗎?

String s="Hydra";
s="Trunks";

恭喜你,成功掉進了坑裡!在回答這道題之前,我們需要知道String是不可變的,打開String的源碼在開頭就可以看到:

private final char value[];

可以看到,String的本質其實是一個char類型的數組,然後我們再看兩個關鍵字。先看final,我們知道final在修飾引用數據類型時,就像這裡的數組時,能夠保證指向該數組地址的引用不能修改,但是數組本身內的值可以被修改。

是不是有點暈,沒關係,我們看一個例子:

final char[] one={'a','b','c'};
char[] two={'d','e','f'};
one=two;

如果你這樣寫,那麼編譯器是會報錯提示Cannot assign a value to final variable 'one',說明被final修飾的數組的引用地址是不可改變的。但是下面這段程式碼卻能夠正常的運行:

final char[] one={'a','b','c'};
one[1]='z';

也就是說,即使被final修飾,但是我直接操作數組裡的元素還是可以的,所以這裡還加了另一個關鍵字private,防止從外部進行修改。此外,String類本身也被添加了final關鍵字修飾,防止被繼承後對屬性進行修改。

到這裡,我們就可以理解為什麼String是不可變的了,那麼在上面的程式碼進行二次賦值的過程中,發生了什麼呢?答案很簡單,前面的變數s只是一個String對象的引用,這裡的重新賦值時將變數s指向了新的對象。

上面白話了一大頓,其實是我們可以通過比較hashCode的方式來看一下引用指向的對象是否發生了改變,修改一下上面的程式碼,列印字元串的hashCode

public static void main(String[] args) {
    String s="Hydra";
    System.out.println(s+":  "+s.hashCode());
    s="Trunks";
    System.out.println(s+": "+s.hashCode());
}

查看結果,發生了改變,證明指向的對象發生了改變:

那麼,回到上面的問題,如果我想要改變一個String的值,而又不想把它重新指向其他對象的話,應該怎麼辦呢?答案是利用反射修改char數組的值:

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
    String s="Hydra";
    System.out.println(s+":  "+s.hashCode());

    Field field = String.class.getDeclaredField("value");
    field.setAccessible(true);
    field.set(s,new char[]{'T','r','u','n','k','s'});
    System.out.println(s+": "+s.hashCode());
}

再對比一下hashCode,修改後和之前一樣,對象沒有發生任何變化:

最後,再啰嗦說一點題外話,這裡看的是jdk8中String的源碼,到這為止還是使用的char類型數組來存儲字元,但是在jdk9中這個char數組已經被替換成了byte數組,能夠使String對象佔用的記憶體減少。

第3題,創建了幾個對象?

相信不少小夥伴在面試中都遇到過這道經典面試題,下面這段程式碼中到底創建了幾個對象?

String s = new String("Hydra");

其實真正想要回答好這個問題,要鋪墊的知識點還真是不少。首先,我們需要了解3個關於常量池的概念,下面還是基於jdk8版本進行說明:

  • class文件常量池:在class文件中保存了一份常量池(Constant Pool),主要存儲編譯時確定的數據,包括程式碼中的字面量(literal)和符號引用
  • 運行時常量池:位於方法區中,全局共享,class文件常量池中的內容會在類載入後存放到方法區的運行時常量池中。除此之外,在運行期間可以將新的變數放入運行時常量池中,相對class文件常量池而言運行時常量池更具備動態性
  • 字元串常量池:位於堆中,全局共享,這裡可以先粗略的認為它存儲的是String對象的直接引用,而不是直接存放的對象,具體的實例對象是在堆中存放

可以用一張圖來描述它們各自所處的位置:

接下來,我們來細說一下字元串常量池的結構,其實在Hotspot JVM中,字元串常量池StringTable的本質是一張HashTable,那麼當我們說將一個字元串放入字元串常量池的時候,實際上放進去的是什麼呢?

以字面量的方式創建String對象為例,字元串常量池以及堆棧的結構如下圖所示(忽略了jvm中的各種OopDesc實例):

實際上字元串常量池HashTable採用的是數組鏈表的結構,鏈表中的節點是一個個的HashTableEntry,而HashTableEntry中的value則存儲了堆上String對象的引用

那麼,下一個問題來了,這個字元串對象的引用是什麼時候被放到字元串常量池中的?具體可為兩種情況:

  • 使用字面量聲明String對象時,也就是被雙引號包圍的字元串,在堆上創建對象,並駐留到字元串常量池中(注意這個用詞)
  • 調用intern()方法,當字元串常量池沒有相等的字元串時,會保存該字元串的引用

注意!我們在上面用到了一個詞駐留,這裡對它進行一下規範。當我們說駐留一個字元串到字元串常量池時,指的是創建HashTableEntry,再使它的value指向堆上的String實例,並把HashTableEntry放入字元串常量池,而不是直接把String對象放入字元串常量池中。簡單來說,可以理解為將String對象的引用保存在字元串常量池中。

我們把intern()方法放在後面細說,先主要看第一種情況,這裡直接整理引用R大的結論:

在類載入階段,JVM會在堆中創建對應這些class文件常量池中的字元串對象實例,並在字元串常量池中駐留其引用。

這一過程具體是在resolve階段(個人理解就是resolution解析階段)執行,但是並不是立即就創建對象並駐留了引用,因為在JVM規範里指明了resolve階段可以是lazy的。CONSTANT_String會在第一次引用該項的ldc指令被第一次執行到的時候才會resolve。

就HotSpot VM的實現來說,載入類時字元串字面量會進入到運行時常量池,不會進入全局的字元串常量池,即在StringTable中並沒有相應的引用,在堆中也沒有對應的對象產生。

這裡大家可以暫時先記住這個結論,在後面還會用到。

在弄清楚上面幾個概念後,我們再回過頭來,先看看用字面量聲明String的方式,程式碼如下:

public static void main(String[] args) {
    String s = "Hydra";
}

反編譯生成的位元組碼文件:

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=1, locals=2, args_size=1
       0: ldc           #2                  // String Hydra
       2: astore_1
       3: return

解釋一下上面的位元組碼指令:

  • 0: ldc,查找後面索引為#2對應的項,#2表示常量在常量池中的位置。在這個過程中,會觸發前面提到的lazy resolve,在resolve過程如果發現StringTable已經有了內容匹配的String引用,則直接返回這個引用,反之如果StringTable里沒有內容匹配的String對象的引用,則會在堆里創建一個對應內容的String對象,然後在StringTable駐留這個對象引用,並返回這個引用,之後再壓入操作數棧中
  • 2: astore_1,彈出棧頂元素,並將棧頂引用類型值保存到局部變數1中,也就是保存到變數s
  • 3: return,執行void函數返回

可以看到,在這種模式下,只有堆中創建了一個"Hydra"對象,在字元串常量池中駐留了它的引用。並且,如果再給字元串s2s3也用字面量的形式賦值為"Hydra",它們用的都是堆中的唯一這一個對象。

好了,再看一下以構造方法的形式創建字元串的方式:

public static void main(String[] args) {
    String s = new String("Hydra");
}

同樣反編譯這段程式碼的位元組碼文件:

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=3, locals=2, args_size=1
       0: new           #2                  // class java/lang/String
       3: dup
       4: ldc           #3                  // String Hydra
       6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
       9: astore_1
      10: return

看一下和之前不同的位元組碼指令部分:

  • 0: new,在堆上創建一個String對象,並將它的引用壓入操作數棧,注意這時的對象還只是一個空殼,並沒有調用類的構造方法進行初始化
  • 3: dup,複製棧頂元素,也就是複製了上面的對象引用,並將複製後的對象引用壓入棧頂。這裡之所以要進行複製,是因為之後要執行的構造方法會從操作數棧彈出需要的參數和這個對象引用本身(這個引用起到的作用就是構造方法中的this指針),如果不進行複製,在彈出後會無法得到初始化後的對象引用
  • 4: ldc,在堆上創建字元串對象,駐留到字元串常量池,並將字元串的引用壓入操作數棧
  • 6: invokespecial,執行String的構造方法,這一步執行完成後得到一個完整對象

到這裡,我們可以看到一共創建了兩個String對象,並且兩個都是在堆上創建的,且字面量方式創建的String對象的引用被駐留到了字元串常量池中。而棧里的s只是一個變數,並不是實際意義上的對象,我們不把它包括在內。

其實想要驗證這個結論也很簡單,可以使用idea中強大的debug功能來直觀的對比一下對象數量的變化,先看字面量創建String方式:

這個對象數量的計數器是在debug時,點擊下方右側MemoryLoad classes彈出的。對比語句執行前後可以看到,只創建了一個String對象,以及一個char數組對象,也就是String對象中的value

再看看構造方法創建String的方式:

可以看到,創建了兩個String對象,一個char數組對象,也說明了兩個String中的value指向了同一個char數組對象,符合我們上面從位元組碼指令角度解釋的結果。

最後再看一下下面的這種情況,當字元串常量池已經駐留過某個字元串引用,再使用構造方法創建String時,創建了幾個對象?

public static void main(String[] args) {
	String s = "Hydra";
	String s2 = new String("Hydra");
}

答案是只創建一個對象,對於這種重複字面量的字元串,看一下反編譯後的位元組碼指令:

Code:
  stack=3, locals=3, args_size=1
     0: ldc           #2                  // String Hydra
     2: astore_1
     3: new           #3                  // class java/lang/String
     6: dup
     7: ldc           #2                  // String Hydra
     9: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
    12: astore_2
    13: return

可以看到兩次執行ldc指令時後面索引相同,而ldc判斷是否需要創建新的String實例的依據是根據在第一次執行這條指令時,StringTable是否已經保存了一個對應內容的String實例的引用。所以在第一次執行ldc時會創建String實例,而在第二次ldc就會直接返回而不需要再創建實例了。

第4題,燒腦的 intern

上面我們在研究字元串對象的引用如何駐留到字元串常量池中時,還留下了調用intern方法的方式,下面我們來具體分析。

從字面上理解intern這個單詞,作為動詞時它有禁閉關押的意思,通過前面的介紹,與其說是將字元串關押到字元串常量池StringTable中,可能將它理解為快取它的引用會更加貼切。

String的intern()是一個本地方法,可以強制將String駐留進入字元串常量池,可以分為兩種情況:

  • 如果字元串常量池中已經駐留了一個等於此String對象內容的字元串引用,則返回此字元串在常量池中的引用
  • 否則,在常量池中創建一個引用指向這個String對象,然後返回常量池中的這個引用

好了,我們下面看一下這段程式碼,它的運行結果應該是什麼?

public static void main(String[] args) {
    String s1 = new String("Hydra");
    String s2 = s1.intern();
    System.out.println(s1 == s2);
    System.out.println(s1 == "Hydra");
    System.out.println(s2 == "Hydra");
}

輸出列印:

false
false
true

用一張圖來描述它們的關係,就很容易明白了:

其實有了第三題的基礎,了解這個結構已經很簡單了:

  • 在創建s1的時候,其實堆里已經創建了兩個字元串對象StringObject1StringObject2,並且在字元串常量池中駐留了StringObject2
  • 當執行s1.intern()方法時,字元串常量池中已經存在內容等於"Hydra"的字元串StringObject2,直接返回這個引用並賦值給s2
  • s1s2指向的是兩個不同的String對象,因此返回 fasle
  • s2指向的就是駐留在字元串常量池的StringObject2,因此s2=="Hydra"為 true,而s1指向的不是常量池中的對象引用所以返回false

上面是常量池中已存在內容相等的字元串駐留的情況,下面再看看常量池中不存在的情況,看下面的例子:

public static void main(String[] args) {
    String s1 = new String("Hy") + new String("dra");
    s1.intern();
    String s2 = "Hydra";
    System.out.println(s1 == s2);
}

執行結果:

true

簡單分析一下這個過程,第一步會在堆上創建"Hy""dra"的字元串對象,並駐留到字元串常量池中。

接下來,完成字元串的拼接操作,前面我們說過,實際上jvm會把拼接優化成StringBuilderappend方法,並最終調用toString方法返回一個String對象。在完成字元串的拼接後,字元串常量池中並沒有駐留一個內容等於"Hydra"的字元串。

所以,執行s1.intern()時,會在字元串常量池創建一個引用,指向前面StringBuilder創建的那個字元串,也就是變數s1所指向的字元串對象。在《深入理解Java虛擬機》這本書中,作者對這進行了解釋,因為從jdk7開始,字元串常量池就已經移到了堆中,那麼這裡就只需要在字元串常量池中記錄一下首次出現的實例引用即可。

最後,當執行String s2 = "Hydra"時,發現字元串常量池中已經駐留這個字元串,直接返回對象的引用,因此s1s2指向的是相同的對象。

第5題,還是創建了幾個對象?

解決了前面數String對象個數的問題,那麼我們接著加點難度,看看下面這段程式碼,創建了幾個對象?

String s="a"+"b"+"c";

先揭曉答案,只創建了一個對象! 可以直觀的對比一下源程式碼和反編譯後的位元組碼文件:

如果使用前面提到過的debug小技巧,也可以直觀的看到語句執行完後,只增加了一個String對象,以及一個char數組對象。並且這個字元串就是駐留在字元串常量池中的那一個,如果後面再使用字面量"abc"的方式聲明一個字元串,指向的仍是這一個,堆中String對象的數量不會發生變化。

至於為什麼源程式碼中字元串拼接的操作,在編譯完成後會消失,直接呈現為一個拼接後的完整字元串,是因為在編譯期間,應用了編譯器優化中一種被稱為常量摺疊(Constant Folding)的技術。

常量摺疊會將編譯期常量的加減乘除的運算過程在編譯過程中摺疊。編譯器通過語法分析,會將常量表達式計算求值,並用求出的值來替換表達式,而不必等到運行期間再進行運算處理,從而在運行期間節省處理器資源。

而上邊提到的編譯期常量的特點就是它的值在編譯期就可以確定,並且需要完整滿足下面的要求,才可能是一個編譯期常量:

  • 被聲明為final
  • 基本類型或者字元串類型
  • 聲明時就已經初始化
  • 使用常量表達式進行初始化

下面我們通過幾段程式碼加深對它的理解:

public static void main(String[] args) {
    final String h1 = "hello";
    String h2 = "hello";
    String s1 = h1 + "Hydra";
    String s2 = h2 + "Hydra";
    System.out.println((s1 == "helloHydra"));
    System.out.println((s2 == "helloHydra"));
}

執行結果:

true
false

程式碼中字元串h1h2都使用常量賦值,區別在於是否使用了final進行修飾,對比編譯後的程式碼,s1進行了摺疊而s2沒有,可以印證上面的理論,final修飾的字元串變數才有可能是編譯期常量。

再看一段程式碼,執行下面的程式,結果會返回什麼呢?

public static void main(String[] args) {
    String h ="hello";
    final String h2 = h;
    String s = h2 + "Hydra";
    System.out.println(s=="helloHydra");
}

答案是false,因為雖然這裡字元串h2final修飾,但是初始化時沒有使用常量表達式,因此它也不是編譯期常量。那麼,有的小夥伴就要問了,到底什麼才是常量表達式呢?

Oracle官網的文檔中,列舉了很多種情況,下面對常見的情況進行列舉(除了下面這些之外官方文檔上還列舉了不少情況,如果有興趣的話,可以自己查看):

  • 基本類型和String類型的字面量
  • 基本類型和String類型的強制類型轉換
  • 使用+-!等一元運算符(不包括++--)進行計算
  • 使用加減運算符+-,乘除運算符*/% 進行計算
  • 使用移位運算符 >><<>>>進行位移操作
  • ……

至於我們從文章一開始就提到的字面量(literals),是用於表達源程式碼中一個固定值的表示法,在Java中創建一個對象時需要使用new關鍵字,但是給一個基本類型變數賦值時不需要使用new關鍵字,這種方式就可以被稱為字面量。Java中字面量主要包括了以下類型的字面量:

//整數型字面量:
long l=1L;
int i=1;

//浮點類型字面量:
float f=11.1f;
double d=11.1;

//字元和字元串類型字面量:
char c='h';
String s="Hydra";

//布爾類型字面量:
boolean b=true;

再說點題外話,和編譯期常量相對的,另一種類型的常量是運行時常量,看一下下面這段程式碼:

final String s1="hello "+"Hydra";
final String s2=UUID.randomUUID().toString()+"Hydra";

編譯器能夠在編譯期就得到s1的值是hello Hydra,不需要等到程式的運行期間,因此s1屬於編譯期常量。而對s2來說,雖然也被聲明為final類型,並且在聲明時就已經初始化,但使用的不是常量表達式,因此不屬於編譯期常量,這一類型的常量被稱為運行時常量

再看一下編譯後的位元組碼文件中的常量池區域:

可以看到常量池中只有一個String類型的常量hello Hydra,而s2對應的字元串常量則不在此區域。對編譯器來說,運行時常量在編譯期間無法進行摺疊,編譯器只會對嘗試修改它的操作進行報錯處理。

總結

最後再強調一下,本文是基於jdk8進行測試,不同版本的jdk可能會有很大差異。例如jdk6之前,字元串常量池存儲的是String對象實例,而在jdk7以後字元串常量池就改為存儲引用,做了非常大的改變。

至於最後一題,其實Hydra在以前單獨拎出來寫過一篇文章,這次總結面試題把它歸納在了裡面,省略了一些不重要的部分,大家如果覺得不夠詳細可以移步看看這篇:String s=”a”+”b”+”c”,到底創建了幾個對象?

那麼,這次的分享就寫到這裡,我是Hydra,我們下篇再見~

參考資料:

《深入理解Java虛擬機(第三版)》

//www.zhihu.com/question/55994121

//www.iteye.com/blog/rednaxelafx-774673#

作者簡介,碼農參上,一個熱愛分享的公眾號,有趣、深入、直接,與你聊聊技術。個人微信DrHydra9,歡迎添加好友,進一步交流。