一、字符串問題
字符串在我們平時的編碼工作中其實用的非常多,並且用起來也比較簡單,所以很少有人對其做特別深入的研究。倒是面試或者筆試的時候,往往會涉及比較深入和難度大一點的問題。我在招聘的時候也偶爾會問應聘者相關的問題,倒不是說一定要回答的特別正確和深入,通常問這些問題的目的有兩個,第一是考察對 JAVA 基礎知識的了解程度,第二是考察應聘者對技術的態度。
我們看看以下程序會輸出什麼結果?如果你能正確的回答每一道題,並且清楚其原因,那本文對你就沒什麼太大的意義。如果回答不正確或者不是很清楚其原理,那就仔細看看以下的分析,本文應該能幫助你清楚的理解每段程序的結果及輸出該結果的深層次原因。
代碼段一:
package com.paddx.test.string; public class StringTest { public static void main(String[] args) { String str1 = "string"; String str2 = new String("string"); String str3 = str2.intern(); System.out.println(str1==str2);//#1 System.out.println(str1==str3);//#2 } }
代碼段二:
package com.paddx.test.string;
public class StringTest01 { public static void main(String[] args) { String baseStr = "baseStr"; final String baseFinalStr = "baseStr"; String str1 = "baseStr01"; String str2 = "baseStr"+"01"; String str3 = baseStr + "01"; String str4 = baseFinalStr+"01"; String str5 = new String("baseStr01").intern(); System.out.println(str1 == str2);//#3 System.out.println(str1 == str3);//#4 System.out.println(str1 == str4);//#5 System.out.println(str1 == str5);//#6 } }
代碼段三(1):
package com.paddx.test.string;
public class InternTest { public static void main(String[] args) { String str2 = new String("str")+new String("01"); str2.intern(); String str1 = "str01"; System.out.println(str2==str1);//#7 } }
代碼段三(2):
package com.paddx.test.string; public class InternTest01 { public static void main(String[] args) { String str1 = "str01"; String str2 = new String("str")+new String("01"); str2.intern(); System.out.println(str2 == str1);//#8 } }
為了方便描述,我對上述代碼的輸出結果由#1~#8進行了編碼,下文中藍色字體部分即為結果。
二、字符串深入分析
1、代碼段一分析
字符串不屬於基本類型,但是可以像基本類型一樣,直接通過字面量賦值,當然也可以通過new來生成一個字符串對象。不過通過字面量賦值的方式和new的方式生成字符串有本質的區別:
通過字面量賦值創建字符串時,會優先在常量池中查找是否已經存在相同的字符串,倘若已經存在,棧中的引用直接指向該字符串;倘若不存在,則在常量池中生成一個字符串,再將棧中的引用指向該字符串。而通過new的方式創建字符串時,就直接在堆中生成一個字符串的對象(備注,JDK 7 以後,HotSpot 已將常量池從永久代轉移到了堆中。詳細信息可參考《JDK8內存模型-消失的PermGen》一文),棧中的引用指向該對象。對於堆中的字符串對象,可以通過 intern() 方法來將字符串添加的常量池中,並返回指向該常量的引用。
現在我們應該能很清楚代碼段一的結果了:
結果 #1:因為str1指向的是字符串中的常量,str2是在堆中生成的對象,所以str1==str2返回false。
結果 #2:str2調用intern方法,會將str2中值(“string”)復制到常量池中,但是常量池中已經存在該字符串(即str1指向的字符串),所以直接返回該字符串的引用,因此str3==str2返回true。
以下運行代碼段一的代碼的結果:
2、代碼段二分析
對於代碼段二的結果,還是通過反編譯StringTest01.class文件比較容易理解:
常量池內容(部分):
執行指令(部分,第二列#+序數對應常量池中的項):
在解釋上述執行過程之前,先了解兩條指令:
ldc:Push item from run-time constant pool,從常量池中加載指定項的引用到棧。
astore_<n>:Store reference into local variable,將引用賦值給第n個局部變量。
現在我們開始解釋代碼段二的執行過程:
0: ldc #2:加載常量池中的第二項("baseStr")到棧中。
2: astore_1 :將1中的引用賦值給第一個局部變量,即String baseStr = "baseStr";
3: ldc #2:加載常量池中的第二項("baseStr")到棧中。
5: astore_2 :將3中的引用賦值給第二個局部變量,即 final String baseFinalStr="baseStr";
6: ldc #3:加載常量池中的第三項("baseStr01")到棧中。
8: astore_3 :將6中的引用賦值給第三個局部變量,即String str1="baseStr01";
9: ldc #3:加載常量池中的第三項("baseStr01")到棧中。
11: astore 4:將9中的引用賦值給第四個局部變量:即String str2="baseStr01";
結果#3:str1==str2 肯定會返回true,因為str1和str2都指向常量池中的同一引用地址。所以其實在JAVA 1.6之後,常量字符串的“+”操作,編譯階段直接會合成為一個字符串。
13: new #4:生成StringBuilder的實例。
16: dup :復制13生成對象的引用並壓入棧中。
17: invokespecial #5:調用常量池中的第五項,即StringBuilder.<init>方法。
以上三條指令的作用是生成一個StringBuilder的對象。
20: aload_1 :加載第一個參數的值,即"baseStr"
21: invokevirtual #6 :調用StringBuilder對象的append方法。
24: ldc #7:加載常量池中的第七項("01")到棧中。
26: invokevirtual #6:調用StringBuilder.append方法。
29: invokevirtual #8:調用StringBuilder.toString方法。
32: astore 5:將29中的結果引用賦值改第五個局部變量,即對變量str3的賦值。
結果 #4:因為str3實際上是stringBuilder.append()生成的結果,所以與str1不相等,結果返回false。
34: ldc #3:加載常量池中的第三項("baseStr01")到棧中。
36: astore 6:將34中的引用賦值給第六個局部變量,即str4="baseStr01";
結果 #5 :因為str1和str4指向的都是常量池中的第三項,所以str1==str4返回true。這裡我們還能發現一個現象,對於final字段,編譯期直接進行了常量替換,而對於非final字段則是在運行期進行賦值處理的。
38: new #9:創建String對象
41: dup :復制引用並壓如棧中。
42: ldc #3:加載常量池中的第三項("baseStr01")到棧中。
44: invokespecial #10:調用String."<init>"方法,並傳42步驟中的引用作為參數傳入該方法。
47: invokevirtual #11:調用String.intern方法。
從38到41的對應的源碼就是new String("baseStr01").intern()。
50: astore 7:將47步返回的結果賦值給變量7,即str5指向baseStr01在常量池中的位置。
結果 #6 :因為str5和str1都指向的都是常量池中的同一個字符串,所以str1==str5返回true。
運行代碼段二,輸出結果如下:
3、代碼段三解析:
對於代碼段三,在 JDK 1.6 和 JDK 1.7中的運行結果不同。我們先看一下運行結果,然後再來解釋其原因:
JDK 1.6 下的運行結果:
JDK 1.7 下的運行結果:
根據對代碼段一的分析,應該可以很簡單得出 JDK 1.6 的結果,因為 str2 和 str1本來就是指向不同的位置,理應返回false。
比較奇怪的問題在於JDK 1.7後,對於第一種情況返回true,但是調換了一下位置返回的結果就變成了false。這個原因主要是從JDK 1.7後,HotSpot 將常量池從永久代移到了元空間,正因為如此,JDK 1.7 後的intern方法在實現上發生了比較大的改變,JDK 1.7後,intern方法還是會先去查詢常量池中是否有已經存在,如果存在,則返回常量池中的引用,這一點與之前沒有區別,區別在於,如果在常量池找不到對應的字符串,則不會再將字符串拷貝到常量池,而只是在常量池中生成一個對原字符串的引用。所以:
結果 #7:在第一種情況下,因為常量池中沒有“str01”這個字符串,所以會在常量池中生成一個對堆中的“str01”的引用,而在進行字面量賦值的時候,常量池中已經存在,所以直接返回該引用即可,因此str1和str2都指向堆中的字符串,返回true。
結果 #8:調換位置以後,因為在進行字面量賦值(String str1 = "str01")的時候,常量池中不存在,所以str1指向的常量池中的位置,而str2指向的是堆中的對象,再進行intern方法時,對str1和str2已經沒有影響了,所以返回false。
三、常見面試題解答
有了對以上的知識的了解,我們現在再來看常見的面試或筆試題就很簡單了:
Q:String s = new String("xyz"),創建了幾個String Object?
A:兩個,常量池中的"xyz"和堆中對象。
Q:下列程序的輸出結果:
String s1 = “abc”;
String s2 = “abc”;
System.out.println(s1 == s2);
A:true,均指向常量池中對象。
Q:下列程序的輸出結果:
String s1 = new String(“abc”);
String s2 = new String(“abc”);
System.out.println(s1 == s2);
A:false,兩個引用指向堆中的不同對象。
Q:下列程序的輸出結果:
String s1 = “abc”;
String s2 = “a”;
String s3 = “bc”;
String s4 = s2 + s3;
System.out.println(s1 == s4);
A:false,因為s2+s3實際上是使用StringBuilder.append來完成,會生成不同的對象。
Q:下列程序的輸出結果:
String s1 = “abc”;
final String s2 = “a”;
final String s3 = “bc”;
String s4 = s2 + s3;
System.out.println(s1 == s4);
A:true,因為final變量在編譯後會直接替換成對應的值,所以實際上等於s4="a"+"bc",而這種情況下,編譯器會直接合並為s4="abc",所以最終s1==s4。
Q:下列程序的輸出結果:
String s = new String("abc");
String s1 = "abc";
String s2 = new String("abc");
System.out.println(s == s1.intern());
System.out.println(s == s2.intern());
System.out.println(s1 == s2.intern());
A:false,false,true,具體原因參考第二部分內容。