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

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

在分析這個結果之前,先扯點別的,說一下為空

null

的字串的列印原理。檢視一下

PrintStream

類的原始碼,

print

方法在列印

null

前進行了處理:

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

因此,一個為

null

的字串就可以被列印在我們的控制檯上了。

再回頭看上面這道題,

s1

s2

沒有經過初始化所以都是空物件

null

,需要注意這裡不是字串的

“null”

,列印結果的產生我們可以看一下位元組碼檔案:

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

編譯器會對

String

字串相加的操作進行最佳化,會把這一過程轉化為

StringBuilder

append

方法。那麼,讓我們再看看

append

方法的原始碼:

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

如果

append

方法的引數字串為

null

,那麼這裡會呼叫其父類

AbstractStringBuilder

appendNull

方法:

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

指向了新的物件。

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

上面白話了一大頓,其實是我們可以透過比較

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());}

檢視結果,發生了改變,證明指向的物件發生了改變:

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

那麼,回到上面的問題,如果我想要改變一個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

,修改後和之前一樣,物件沒有發生任何變化:

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

最後,再囉嗦說一點題外話,這裡看的是

jdk8

中String的原始碼,到這為止還是使用的

char

型別陣列來儲存字元,但是在

jdk9

中這個

char

陣列已經被替換成了

byte

陣列,能夠使String物件佔用的記憶體減少。

第3題,建立了幾個物件?

相信不少小夥伴在面試中都遇到過這道經典面試題,下面這段程式碼中到底

建立

了幾個物件?

String s = new String(“Hydra”);

其實真正想要回答好這個問題,要鋪墊的知識點還真是不少。首先,我們需要了解3個關於

常量池

的概念,下面還是基於

jdk8

版本進行說明:

class檔案常量池:在class檔案中儲存了一份常量池(

Constant Pool

),主要儲存編譯時確定的資料,包括程式碼中的

字面量

literal

)和

符號引用

執行時常量池:位於方法區中,全域性共享,class檔案常量池中的內容會在類載入後存放到方法區的執行時常量池中。除此之外,在執行期間可以將新的變數放入執行時常量池中,相對class檔案常量池而言執行時常量池更具備動態性

字串常量池:位於堆中,全域性共享,這裡可以先粗略的認為它儲存的是String物件的

直接引用

,而不是直接存放的物件,具體的例項物件是在堆中存放

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

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

接下來,我們來細說一下

字串常量池

的結構,其實在Hotspot JVM中,字串常量池

StringTable

的本質是一張

HashTable

,那麼當我們說將一個字串放入字串常量池的時候,實際上放進去的是什麼呢?

以字面量的方式建立String物件為例,字串常量池以及堆疊的結構如下圖所示(忽略了jvm中的各種

OopDesc

例項):

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

實際上字串常量池

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”

物件,在字串常量池中駐留了它的引用。並且,如果再給字串

s2

s3

也用字面量的形式賦值為

“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。“”:(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方式:

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

這個物件數量的計數器是在debug時,點選下方右側

Memory

Load classes

彈出的。對比語句執行前後可以看到,只建立了一個String物件,以及一個char陣列物件,也就是String物件中的

value

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

5道面試題,拿捏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。“”:(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”);}

輸出列印:

falsefalsetrue

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

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

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

在建立

s1

的時候,其實堆裡已經建立了兩個字串物件

StringObject1

StringObject2

,並且在字串常量池中駐留了

StringObject2

當執行

s1。intern()

方法時,字串常量池中已經存在內容等於

“Hydra”

的字串

StringObject2

,直接返回這個引用並賦值給

s2

s1

s2

指向的是兩個不同的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會把拼接最佳化成

StringBuilder

append

方法,並最終呼叫

toString

方法返回一個String物件。在完成字串的拼接後,字串常量池中並沒有駐留一個內容等於

“Hydra”

的字串。

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

所以,執行

s1。intern()

時,會在字串常量池建立一個引用,指向前面

StringBuilder

建立的那個字串,也就是變數

s1

所指向的字串物件。在《深入理解Java虛擬機器》這本書中,作者對這進行了解釋,因為從jdk7開始,字串常量池就已經移到了堆中,那麼這裡就只需要在字串常量池中記錄一下首次出現的例項引用即可。

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

最後,當執行

String s2 = “Hydra”

時,發現字串常量池中已經駐留這個字串,直接返回物件的引用,因此

s1

s2

指向的是相同的物件。

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

第5題,還是建立了幾個物件?

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

String s=“a”+“b”+“c”;

先揭曉答案,

只建立了一個物件!

可以直觀的對比一下原始碼和反編譯後的位元組碼檔案:

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

如果使用前面提到過的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”));}

執行結果:

truefalse

程式碼中字串

h1

h2

都使用常量賦值,區別在於是否使用了

final

進行修飾,對比編譯後的程式碼,

s1

進行了摺疊而

s2

沒有,可以印證上面的理論,

final

修飾的字串變數才有可能是編譯期常量。

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

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

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

答案是

false

,因為雖然這裡字串

h2

final

修飾,但是初始化時沒有使用常量表達式,因此它也不是編譯期常量。那麼,有的小夥伴就要問了,到底什麼才是常量表達式呢?

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

型別,並且在宣告時就已經初始化,但使用的不是常量表達式,因此不屬於編譯期常量,這一型別的常量被稱為

執行時常量

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

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

可以看到常量池中只有一個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#

作者:

碼農參上