通過反編譯深入理解Java String及intern

liuxiaopeng發表於2016-04-05

一、字串問題

  字串在我們平時的編碼工作中其實用的非常多,並且用起來也比較簡單,所以很少有人對其做特別深入的研究。倒是面試或者筆試的時候,往往會涉及比較深入和難度大一點的問題。我在招聘的時候也偶爾會問應聘者相關的問題,倒不是說一定要回答的特別正確和深入,通常問這些問題的目的有兩個,第一是考察對 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指向的字串),所以直接返回該字串的引用,因此str1==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,具體原因參考第二部分內容。

 

相關文章