新聞中心
本文基于jdk8版本中的String進行討論,文章例子中的代碼運行結(jié)果基于Java 1.8.0_261-b12

創(chuàng)新互聯(lián)建站基于分布式IDC數(shù)據(jù)中心構(gòu)建的平臺為眾多戶提供四川電信機房托管 四川大帶寬租用 成都機柜租用 成都服務(wù)器租用。
第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);
}
}
揭曉答案,看一下運行結(jié)果,打印了nullnull:
在分析這個結(jié)果之前,先扯點別的,說一下為空null的字符串的打印原理。查看一下PrintStream類的源碼,print方法在打印null前進行了處理:
public void print(String s) {
if (s == null) {
s = "null";
}
write(s);
}
因此,一個為null的字符串就可以被打印在我們的控制臺上了。
再回頭看上面這道題,s1和s2沒有經(jīng)過初始化所以都是空對象null,需要注意這里不是字符串的"null",打印結(jié)果的產(chǎn)生我們可以看一下字節(jié)碼文件:
編譯器會對String字符串相加的操作進行優(yōu)化,會把這一過程轉(zhuǎn)化為StringBuilder的append方法。那么,讓我們再看看append方法的源碼:
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
//...
}
如果append方法的參數(shù)字符串為null,那么這里會調(diào)用其父類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類型數(shù)組,到這里我們就可以明白了,其實StringBuilder也對null的字符串進行了特殊處理,在append的過程中如果碰到是null的字符串,那么就會以"null"的形式被添加進字符數(shù)組,這也就導(dǎo)致了兩個為空null的字符串相加后會打印為"nullnull"。
第2題,改變String的值
如何改變一個String字符串的值,這道題可能看上去有點太簡單了,像下面這樣直接賦值不就可以了嗎?
String s="Hydra";
s="Trunks";
恭喜你,成功掉進了坑里!在回答這道題之前,我們需要知道String是不可變的,打開String的源碼在開頭就可以看到:
private final char value[];
可以看到,String的本質(zhì)其實是一個char類型的數(shù)組,然后我們再看兩個關(guān)鍵字。先看final,我們知道final在修飾引用數(shù)據(jù)類型時,就像這里的數(shù)組時,能夠保證指向該數(shù)組地址的引用不能修改,但是數(shù)組本身內(nèi)的值可以被修改。
是不是有點暈,沒關(guān)系,我們看一個例子:
final char[] one={'a','b','c'};
char[] two={'d','e','f'};
one=two;
如果你這樣寫,那么編譯器是會報錯提示Cannot assign a value to final variable 'one',說明被final修飾的數(shù)組的引用地址是不可改變的。但是下面這段代碼卻能夠正常的運行:
final char[] one={'a','b','c'};
one[1]='z';
也就是說,即使被final修飾,但是我直接操作數(shù)組里的元素還是可以的,所以這里還加了另一個關(guān)鍵字private,防止從外部進行修改。此外,String類本身也被添加了final關(guān)鍵字修飾,防止被繼承后對屬性進行修改。
到這里,我們就可以理解為什么String是不可變的了,那么在上面的代碼進行二次賦值的過程中,發(fā)生了什么呢?答案很簡單,前面的變量s只是一個String對象的引用,這里的重新賦值時將變量s指向了新的對象。
上面白話了一大頓,其實是我們可以通過比較hashCode的方式來看一下引用指向的對象是否發(fā)生了改變,修改一下上面的代碼,打印字符串的hashCode:
public static void main(String[] args) {
String s="Hydra";
System.out.println(s+": "+s.hashCode());
s="Trunks";
System.out.println(s+": "+s.hashCode());
}
查看結(jié)果,發(fā)生了改變,證明指向的對象發(fā)生了改變:
那么,回到上面的問題,如果我想要改變一個String的值,而又不想把它重新指向其他對象的話,應(yīng)該怎么辦呢?答案是利用反射修改char數(shù)組的值:
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,修改后和之前一樣,對象沒有發(fā)生任何變化:
最后,再啰嗦說一點題外話,這里看的是jdk8中String的源碼,到這為止還是使用的char類型數(shù)組來存儲字符,但是在jdk9中這個char數(shù)組已經(jīng)被替換成了byte數(shù)組,能夠使String對象占用的內(nèi)存減少。
第3題,創(chuàng)建了幾個對象?
相信不少小伙伴在面試中都遇到過這道經(jīng)典面試題,下面這段代碼中到底創(chuàng)建了幾個對象?
String s = new String("Hydra");
其實真正想要回答好這個問題,要鋪墊的知識點還真是不少。首先,我們需要了解3個關(guān)于常量池的概念,下面還是基于jdk8版本進行說明:
- class文件常量池:在class文件中保存了一份常量池(Constant Pool),主要存儲編譯時確定的數(shù)據(jù),包括代碼中的字面量(literal)和符號引用
- 運行時常量池:位于方法區(qū)中,全局共享,class文件常量池中的內(nèi)容會在類加載后存放到方法區(qū)的運行時常量池中。除此之外,在運行期間可以將新的變量放入運行時常量池中,相對class文件常量池而言運行時常量池更具備動態(tài)性
- 字符串常量池:位于堆中,全局共享,這里可以先粗略的認為它存儲的是String對象的直接引用,而不是直接存放的對象,具體的實例對象是在堆中存放
可以用一張圖來描述它們各自所處的位置:
接下來,我們來細說一下字符串常量池的結(jié)構(gòu),其實在Hotspot JVM中,字符串常量池StringTable的本質(zhì)是一張HashTable,那么當(dāng)我們說將一個字符串放入字符串常量池的時候,實際上放進去的是什么呢?
以字面量的方式創(chuàng)建String對象為例,字符串常量池以及堆棧的結(jié)構(gòu)如下圖所示(忽略了jvm中的各種OopDesc實例):
實際上字符串常量池HashTable采用的是數(shù)組加鏈表的結(jié)構(gòu),鏈表中的節(jié)點是一個個的HashTableEntry,而HashTableEntry中的value則存儲了堆上String對象的引用。
那么,下一個問題來了,這個字符串對象的引用是什么時候被放到字符串常量池中的?具體可為兩種情況:
- 使用字面量聲明String對象時,也就是被雙引號包圍的字符串,在堆上創(chuàng)建對象,并駐留到字符串常量池中(注意這個用詞)
- 調(diào)用intern()方法,當(dāng)字符串常量池沒有相等的字符串時,會保存該字符串的引用
注意!我們在上面用到了一個詞駐留,這里對它進行一下規(guī)范。當(dāng)我們說駐留一個字符串到字符串常量池時,指的是創(chuàng)建HashTableEntry,再使它的value指向堆上的String實例,并把HashTableEntry放入字符串常量池,而不是直接把String對象放入字符串常量池中。簡單來說,可以理解為將String對象的引用保存在字符串常量池中。
我們把intern()方法放在后面細說,先主要看第一種情況,這里直接整理引用R大的結(jié)論:
在類加載階段,JVM會在堆中創(chuàng)建對應(yīng)這些class文件常量池中的字符串對象實例,并在字符串常量池中駐留其引用。
這一過程具體是在resolve階段(個人理解就是resolution解析階段)執(zhí)行,但是并不是立即就創(chuàng)建對象并駐留了引用,因為在JVM規(guī)范里指明了resolve階段可以是lazy的。CONSTANT_String會在第一次引用該項的ldc指令被第一次執(zhí)行到的時候才會resolve。
就HotSpot VM的實現(xiàn)來說,加載類時字符串字面量會進入到運行時常量池,不會進入全局的字符串常量池,即在StringTable中并沒有相應(yīng)的引用,在堆中也沒有對應(yīng)的對象產(chǎn)生。
這里大家可以暫時先記住這個結(jié)論,在后面還會用到。
在弄清楚上面幾個概念后,我們再回過頭來,先看看用字面量聲明String的方式,代碼如下:
public static void main(String[] args) {
String s = "Hydra";
}
反編譯生成的字節(jié)碼文件:
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
解釋一下上面的字節(jié)碼指令:
- 0: ldc,查找后面索引為#2對應(yīng)的項,#2表示常量在常量池中的位置。在這個過程中,會觸發(fā)前面提到的lazy resolve,在resolve過程如果發(fā)現(xiàn)StringTable已經(jīng)有了內(nèi)容匹配的String引用,則直接返回這個引用,反之如果StringTable里沒有內(nèi)容匹配的String對象的引用,則會在堆里創(chuàng)建一個對應(yīng)內(nèi)容的String對象,然后在StringTable駐留這個對象引用,并返回這個引用,之后再壓入操作數(shù)棧中
- 2: astore_1,彈出棧頂元素,并將棧頂引用類型值保存到局部變量1中,也就是保存到變量s中
- 3: return,執(zhí)行void函數(shù)返回
可以看到,在這種模式下,只有堆中創(chuàng)建了一個"Hydra"對象,在字符串常量池中駐留了它的引用。并且,如果再給字符串s2、s3也用字面量的形式賦值為"Hydra",它們用的都是堆中的唯一這一個對象。
好了,再看一下以構(gòu)造方法的形式創(chuàng)建字符串的方式:
public static void main(String[] args) {
String s = new String("Hydra");
}
同樣反編譯這段代碼的字節(jié)碼文件:
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
看一下和之前不同的字節(jié)碼指令部分:
- 0: new,在堆上創(chuàng)建一個String對象,并將它的引用壓入操作數(shù)棧,注意這時的對象還只是一個空殼,并沒有調(diào)用類的構(gòu)造方法進行初始化
- 3: dup,復(fù)制棧頂元素,也就是復(fù)制了上面的對象引用,并將復(fù)制后的對象引用壓入棧頂。這里之所以要進行復(fù)制,是因為之后要執(zhí)行的構(gòu)造方法會從操作數(shù)棧彈出需要的參數(shù)和這個對象引用本身(這個引用起到的作用就是構(gòu)造方法中的this指針),如果不進行復(fù)制,在彈出后會無法得到初始化后的對象引用
- 4: ldc,在堆上創(chuàng)建字符串對象,駐留到字符串常量池,并將字符串的引用壓入操作數(shù)棧
- 6: invokespecial,執(zhí)行String的構(gòu)造方法,這一步執(zhí)行完成后得到一個完整對象
到這里,我們可以看到一共創(chuàng)建了兩個String對象,并且兩個都是在堆上創(chuàng)建的,且字面量方式創(chuàng)建的String對象的引用被駐留到了字符串常量池中。而棧里的s只是一個變量,并不是實際意義上的對象,我們不把它包括在內(nèi)。
其實想要驗證這個結(jié)論也很簡單,可以使用idea中強大的debug功能來直觀的對比一下對象數(shù)量的變化,先看字面量創(chuàng)建String方式:
這個對象數(shù)量的計數(shù)器是在debug時,點擊下方右側(cè)Memory的Load classes彈出的。對比語句執(zhí)行前后可以看到,只創(chuàng)建了一個String對象,以及一個char數(shù)組對象,也就是String對象中的value。
再看看構(gòu)造方法創(chuàng)建String的方式:
可以看到,創(chuàng)建了兩個String對象,一個char數(shù)組對象,也說明了兩個String中的value指向了同一個char數(shù)組對象,符合我們上面從字節(jié)碼指令角度解釋的結(jié)果。
最后再看一下下面的這種情況,當(dāng)字符串常量池已經(jīng)駐留過某個字符串引用,再使用構(gòu)造方法創(chuàng)建String時,創(chuàng)建了幾個對象?
public static void main(String[] args) {
String s = "Hydra";
String s2 = new String("Hydra");
}
答案是只創(chuàng)建一個對象,對于這種重復(fù)字面量的字符串,看一下反編譯后的字節(jié)碼指令:
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
可以看到兩次執(zhí)行l(wèi)dc指令時后面索引相同,而ldc判斷是否需要創(chuàng)建新的String實例的依據(jù)是根據(jù)在第一次執(zhí)行這條指令時,StringTable是否已經(jīng)保存了一個對應(yīng)內(nèi)容的String實例的引用。所以在第一次執(zhí)行l(wèi)dc時會創(chuàng)建String實例,而在第二次ldc就會直接返回而不需要再創(chuàng)建實例了。
第4題,燒腦的 intern
上面我們在研究字符串對象的引用如何駐留到字符串常量池中時,還留下了調(diào)用intern方法的方式,下面我們來具體分析。
從字面上理解intern這個單詞,作為動詞時它有禁閉、關(guān)押的意思,通過前面的介紹,與其說是將字符串關(guān)押到字符串常量池StringTable中,可能將它理解為緩存它的引用會更加貼切。
String的intern()是一個本地方法,可以強制將String駐留進入字符串常量池,可以分為兩種情況:
- 如果字符串常量池中已經(jīng)駐留了一個等于此String對象內(nèi)容的字符串引用,則返回此字符串在常量池中的引用
- 否則,在常量池中創(chuàng)建一個引用指向這個String對象,然后返回常量池中的這個引用
好了,我們下面看一下這段代碼,它的運行結(jié)果應(yīng)該是什么?
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
用一張圖來描述它們的關(guān)系,就很容易明白了:
其實有了第三題的基礎(chǔ),了解這個結(jié)構(gòu)已經(jīng)很簡單了:
- 在創(chuàng)建s1的時候,其實堆里已經(jīng)創(chuàng)建了兩個字符串對象StringObject1和StringObject2,并且在字符串常量池中駐留了StringObject2
- 當(dāng)執(zhí)行s1.intern()方法時,字符串常量池中已經(jīng)存在內(nèi)容等于"Hydra"的字符串StringObject2,直接返回這個引用并賦值給s2
- s1和s2指向的是兩個不同的String對象,因此返回 fasle
- s2指向的就是駐留在字符串常量池的StringObject2,因此s2=="Hydra"為 true,而s1指向的不是常量池中的對象引用所以返回false
上面是常量池中已存在內(nèi)容相等的字符串駐留的情況,下面再看看常量池中不存在的情況,看下面的例子:
public static void main(String[] args) {
String s1 = new String("Hy") + new String("dra");
s1.intern();
String s2 = "Hydra";
System.out.println(s1 == s2);
}
執(zhí)行結(jié)果:
true
簡單分析一下這個過程,第一步會在堆上創(chuàng)建"Hy"和"dra"的字符串對象,并駐留到字符串常量池中。
接下來,完成字符串的拼接操作,前面我們說過,實際上jvm會把拼接優(yōu)化成StringBuilder的append方法,并最終調(diào)用toString方法返回一個String對象。在完成字符串的拼接后,字符串常量池中并沒有駐留一個內(nèi)容等于"Hydra"的字符串。
所以,執(zhí)行s1.intern()時,會在字符串常量池創(chuàng)建一個引用,指向前面StringBuilder創(chuàng)建的那個字符串,也就是變量s1所指向的字符串對象。在《深入理解Java虛擬機》這本書中,作者對這進行了解釋,因為從jdk7開始,字符串常量池就已經(jīng)移到了堆中,那么這里就只需要在字符串常量池中記錄一下首次出現(xiàn)的實例引用即可。
最后,當(dāng)執(zhí)行String s2 = "Hydra"時,發(fā)現(xiàn)字符串常量池中已經(jīng)駐留這個字符串,直接返回對象的引用,因此s1和s2指向的是相同的對象。
第5題,還是創(chuàng)建了幾個對象
?解決了前面數(shù)String對象個數(shù)的問題,那么我們接著加點難度,看看下面這段代碼,創(chuàng)建了幾個對象?
String s="a"+"b"+"c";
先揭曉答案,只創(chuàng)建了一個對象! 可以直觀的對比一下源代碼和反編譯后的字節(jié)碼文件:
如果使用前面提到過的debug小技巧,也可以直觀的看到語句執(zhí)行完后,只增加了一個String對象,以及一個char數(shù)組對象。并且這個字符串就是駐留在字符串常量池中的那一個,如果后面再使用字面量"abc"的方式聲明一個字符串,指向的仍是這一個,堆中String對象的數(shù)量不會發(fā)生變化。
至于為什么源代碼中字符串拼接的操作,在編譯完成后會消失,直接呈現(xiàn)為一個拼接后的完整字符串,是因為在編譯期間,應(yīng)用了編譯器優(yōu)化中一種被稱為常量折疊(Constant Folding)的技術(shù)。
常量折疊會將編譯期常量的加減乘除的運算過程在編譯過程中折疊。編譯器通過語法分析,會將常量表達式計算求值,并用求出的值來替換表達式,而不必等到運行期間再進行運算處理,從而在運行期間節(jié)省處理器資源。
而上邊提到的編譯期常量的特點就是它的值在編譯期就可以確定,并且需要完整滿足下面的要求,才可能是一個編譯期常量:
- 被聲明為final
- 基本類型或者字符串類型
- 聲明時就已經(jīng)初始化
- 使用常量表達式進行初始化
下面我們通過幾段代碼加深對它的理解:
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"));
}
執(zhí)行結(jié)果:
true
false
代碼中字符串h1和h2都使用常量賦值,區(qū)別在于是否使用了final進行修飾,對比編譯后的代碼,s1進行了折疊而s2沒有,可以印證上面的理論,final修飾的字符串變量才有可能是編譯期常量。
再看一段代碼,執(zhí)行下面的程序,結(jié)果會返回什么呢?
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官網(wǎng)的文檔中,列舉了很多種情況,下面對常見的情況進行列舉(除了下面這些之外官方文檔上還列舉了不少情況,如果有興趣的話,可以自己查看):
- 基本類型和String類型的字面量
- 基本類型和String類型的強制類型轉(zhuǎn)換
- 使用+或-或!等一元運算符(不包括++和--)進行計算
- 使用加減運算符+、-,乘除運算符*、 / 、%進行計算
- 使用移位運算符 >>、 <<、 >>>進行位移操作
- ……
至于我們從文章一開始就提到的字面量(literals),是用于表達源代碼中一個固定值的表示法,在Java中創(chuàng)建一個對象時需要使用new關(guān)鍵字,但是給一個基本類型變量賦值時不需要使用new關(guān)鍵字,這種方式就可以被稱為字面量。Java中字面量主要包括了以下類型的字面量:
//整數(shù)型字面量:
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類型,并且在聲明時就已經(jīng)初始化,但使用的不是常量表達式,因此不屬于編譯期常量,這一類型的常量被稱為運行時常量。
再看一下編譯后的字節(jié)碼文件中的常量池區(qū)域:
可以看到常量池中只有一個String類型的常量hello Hydra,而s2對應(yīng)的字符串常量則不在此區(qū)域。對編譯器來說,運行時常量在編譯期間無法進行折疊,編譯器只會對嘗試修改它的操作進行報錯處理。
總結(jié)
最后再強調(diào)一下,本文是基于jdk8進行測試,不同版本的jdk可能會有很大差異。例如jdk6之前,字符串常量池存儲的是String對象實例,而在jdk7以后字符串常量池就改為存儲引用,做了非常大的改變。
至于最后一題,其實Hydra在以前單獨拎出來寫過一篇文章,這次總結(jié)面試題把它歸納在了里面,省略了一些不重要的部分,大家如果覺得不夠詳細可以移步看看這篇:String s="a"+"b"+"c",到底創(chuàng)建了幾個對象?
那么,這次的分享就寫到這里,我是Hydra,我們下篇再見~
參考資料:《深入理解Java虛擬機(第三版)》
https://www.zhihu.com/question/55994121
??https://www.iteye.com/blog/rednaxelafx-774673#???
分享標題:5道面試題,拿捏String底層原理!
URL網(wǎng)址:http://m.fisionsoft.com.cn/article/dhsigcs.html


咨詢
建站咨詢
