原創:微信公眾號
碼農參上
,歡迎分享,轉載請保留出處。
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
的字符串就可以被打印在我們的控制台上了。
再回頭看上面這道題,s1
和s2
沒有經過初始化所以都是空對象null
,需要注意這里不是字符串的"null"
,打印結果的產生我們可以看一下字節碼文件:
編譯器會對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
指向了新的對象。
上面白話了一大頓,其實是我們可以通過比較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"
對象,在字符串常量池中駐留了它的引用。並且,如果再給字符串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."<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時,點擊下方右側Memory
的Load 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
的時候,其實堆里已經創建了兩個字符串對象StringObject1
和StringObject2
,並且在字符串常量池中駐留了StringObject2
- 當執行
s1.intern()
方法時,字符串常量池中已經存在內容等於"Hydra"
的字符串StringObject2
,直接返回這個引用並賦值給s2
s1
和s2
指向的是兩個不同的String對象,因此返回 fasles2
指向的就是駐留在字符串常量池的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"
的字符串。
所以,執行s1.intern()
時,會在字符串常量池創建一個引用,指向前面StringBuilder
創建的那個字符串,也就是變量s1
所指向的字符串對象。在《深入理解Java虛擬機》這本書中,作者對這進行了解釋,因為從jdk7開始,字符串常量池就已經移到了堆中,那么這里就只需要在字符串常量池中記錄一下首次出現的實例引用即可。
最后,當執行String s2 = "Hydra"
時,發現字符串常量池中已經駐留這個字符串,直接返回對象的引用,因此s1
和s2
指向的是相同的對象。
第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
代碼中字符串h1
和h2
都使用常量賦值,區別在於是否使用了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
,因為雖然這里字符串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
類型,並且在聲明時就已經初始化,但使用的不是常量表達式,因此不屬於編譯期常量,這一類型的常量被稱為運行時常量。
再看一下編譯后的字節碼文件中的常量池區域:
可以看到常量池中只有一個String類型的常量hello Hydra
,而s2
對應的字符串常量則不在此區域。對編譯器來說,運行時常量在編譯期間無法進行折疊,編譯器只會對嘗試修改它的操作進行報錯處理。
總結
最后再強調一下,本文是基於jdk8
進行測試,不同版本的jdk
可能會有很大差異。例如jdk6
之前,字符串常量池存儲的是String對象實例,而在jdk7
以后字符串常量池就改為存儲引用,做了非常大的改變。
至於最后一題,其實Hydra在以前單獨拎出來寫過一篇文章,這次總結面試題把它歸納在了里面,省略了一些不重要的部分,大家如果覺得不夠詳細可以移步看看這篇:String s="a"+"b"+"c",到底創建了幾個對象?
那么,這次的分享就寫到這里,我是Hydra,我們下篇再見~
參考資料:
《深入理解Java虛擬機(第三版)》
作者簡介,碼農參上,一個熱愛分享的公眾號,有趣、深入、直接,與你聊聊技術。個人微信DrHydra9,歡迎添加好友,進一步交流。