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

碼農談IT發表於2022-12-08

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

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虛擬機器(第三版)》

https://www.zhihu.com/question/55994121

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

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

相關文章