深入理解String與new String
前言
字串是最常用的型別之一,趁此機會準備探索下它的原始碼。有關該類的註釋作一個總結:
String-字串,是個常量,它們被建立後
其值
就不允許被改變,由於它是不可變的,所以它們可以被共享,在內部提供了多個方法來操作字串。
探索之前我曾看過其他人寫的有關於此的文章,發現JDK1.7版本前後的記憶體模型
不一樣,而這部分的內容還沒有排上行程,簡單來說,我還不懂...所以不敢妄下結論,這篇文章的內容也不會從這方面展開來講,此次探索是基於JDK1.8
。
原理
字串是存放在常量池
中,至於什麼是常量池不準備討論它。先來個簡單的示例方便分析:
public class TestString { public static void main(String[] args) { String testOne = new String("testOne"); } }
示例就是這麼簡單...先讓我們來看看僅僅是這麼一句話,而它的底層都做了什麼。緊接著反編譯它的位元組碼檔案,命令是javap -v TestString
,輸出的資訊內容比較多,咱們只要常量池的內容,如圖所示:
實際上我也看的不是很懂,但有幾個點咱們是能確定的,也就是說常量池中有testOne字串
,而此時的位元組碼檔案是編譯而來的,並未實際上的執行,所以該字串物件在堆記憶體中還未建立,這就說明了在編譯階段時字串就存在於常量池中,同時也驗證了一點:常量池中的字串物件
與執行時在堆記憶體中建立的字串物件
完全是兩個物件,因為在編譯時期堆記憶體中的物件還未出生呢,所以不可能引用到它...不知道我講明白了沒有。接著我們從另外一個角度去分析這段示例,因為後續的其他示例可能會羞澀難懂,所以先從簡單的示例開始逐漸熟悉起來,最終也是希望讀者能夠理解這方面的知識。
Constant pool: // 編號 型別 值 註釋 --------> 這裡的註釋不一定對,只是作一個標記方便理解 #1 = Methodref #6.#22 // java/lang/Object."<init>":()V #2 = Class #23 // java/lang/String #3 = String #18 // testOne #4 = Methodref #2.#24 // java/lang/String."<init>":(Ljava/lang/String;)V #5 = Class #25 // test20190820/TestString #6 = Class #26 // java/lang/Object #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Ltest20190820/TestString; #14 = Utf8 main #15 = Utf8 ([Ljava/lang/String;)V #16 = Utf8 args #17 = Utf8 [Ljava/lang/String; #18 = Utf8 testOne #19 = Utf8 Ljava/lang/String; #20 = Utf8 SourceFile #21 = Utf8 TestString.java #22 = NameAndType #7:#8 // "<init>":()V #23 = Utf8 java/lang/String #24 = NameAndType #7:#27 // "<init>":(Ljava/lang/String;)V #25 = Utf8 test20190820/TestString #26 = Utf8 java/lang/Object #27 = Utf8 (Ljava/lang/String;)V
執行javap -c TestString
獲取底層程式碼執行邏輯,如圖所示:
public class test20190820.TestString {
public test20190820.TestString(); //這裡是構造器的執行邏輯,咱們忽略它,畢竟不是重點
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]); //下面的內容涉及到位元組碼的指令,網上很多資料,可自行查閱
Code:
// #2:對應常量池中編號#2,加上註釋我們得出這裡是建立字串物件 -> new String();
0: new #2 // class java/lang/String
// dup:複製0序號中的引用並壓入棧中 -> 也就是將字串物件的引用放入到棧中
3: dup
// ldc:從常量池中載入指定項的引用到棧 #3:同上,對應著常量池的編號#3 -> 將"testOne"字串的引用載入到棧中
4: ldc #3 // String testOne
// invokespecial:初始化常量池中的指定項 -> 呼叫字串物件的初始化並將4序號中的引用作為引數傳入,形成new String("testOne")
6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
// astore_n:將引用賦值給第n個區域性變數 -> 將6序號的引用賦值給第一個區域性變數,String testOne = new String("testOne"),jvm中是有記錄區域性變數的資訊
9: astore_1
// 退出方法的標誌
10: return
}
從上面的分析可以知道:常量池中的字串物件與在堆記憶體中建立的字串物件是兩個物件
,這一點很重要!接下來是各種示例,我們將採用上面的方式進行分析。
示例一
public class TestString {
public static void main(String[] args) {
String testOne = new String("testOne");
String testOneAnother = "testOne";
System.out.println(testOne == testOneAnother); //false -> 上面說了testOne和testOneAnother是兩個物件,所以很容易得出結果
}
}
示例二
public class TestString {
public static void main(String[] args) {
String s1 = "abc";
String s2 = "a";
String s3 = "bc";
String s4 = s2 + s3; // StringBuilder.append(a).append(bc) -> StringBuilder.toString() -> new String("abc")
System.out.println(s1 == s4); //false, s1指向了常量池中的字串abc,s4相當於生成了新的物件
}
}
/**
* 這裡就不貼常量池的資訊了,反正我們能確定的是在編譯時期字串已經載入到常量池中了,所以常量池中應該存在abc、a、bc字串
* 貼出程式碼的執行邏輯:
* public static void main(java.lang.String[]);
* Code:
* 0: ldc #2 // String abc -> ldc:從常量池中載入指定項的引用到棧
* 2: astore_1 -> abstor_1:將引用賦值給第1個區域性變數, 即 s1 = "abc"
* 3: ldc #3 // String a
* 5: astore_2 -> s2 = "a"
* 6: ldc #4 // String bc
* 8: astore_3 -> s3 = "bc"
* 9: new #5 // class java/lang/StringBuilder -> 建立StringBuilder物件
* 12: dup -> 複製StringBuilder物件的引用並壓入棧中
* 13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V -> 初始化StringBuilder物件
* 16: aload_2 -> 載入第二個區域性變數的值
* 17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; -> 呼叫常量池中指定項的方法,即呼叫StringBuilder物件中的append方法,並傳入序號 * 16中的引用,所以最終是StringBuilder#append(a)
* 20: aload_3
* 21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
* 24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; -> StringBuilder#toString, 可以看下該類的toString方法,生成了一個新的物件,該物件中的字串不會再* 常量池中生成
* 27: astore 4 -> 將序號24中的引用賦值給第4個區域性變數 s4 = StringBuilder.toString = new String()
* 29: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream; -> 下面的就討論了,執行的是System.out.println程式碼片段了
* 32: aload_1
* 33: aload 4
* 35: if_acmpne 42
* 38: iconst_1
* 39: goto 43
* 42: iconst_0
* 43: invokevirtual #10 // Method java/io/PrintStream.println:(Z)V
* 46: return
*
*
* 從上面的分析中我們得出結論:s1指向常量池中的字串物件abc,而從序號16-27我們知道生成了新的物件,相當於是執行了new String("abc"),而這不就又回到了示例一了嗎,結果自然是false
*/
示例三
public class TestString {
public static void main(String[] args) {
String s1 = "abc";
final String s2 = "a";
final String s3 = "bc";
String s4 = s2 + s3; //由於s2、s3被final修飾了,故而直接替換變數的值,最後s4 = "abc",直接使用了常量池中的字串物件abc
System.out.println(s1 == s4); //true
}
}
/**
* 同樣貼出程式碼的執行邏輯:
* public static void main(java.lang.String[]);
* Code:
* 0: ldc #2 // String abc -> ldc:從常量池中載入指定項的引用到棧
* 2: astore_1 -> s1 = "abc"
* 3: ldc #3 // String a
* 5: astore_2 -> s2 = "a"
* 6: ldc #4 // String bc
* 8: astore_3 -> s3 = "bc"
* 9: ldc #2 // String abc
* 11: astore 4 -> s4 = "abc" 示例三與示例二的區別在於加了final修飾,被final修飾的變數會在編譯階段直接替換成對應的值,即"a" + "bc",而這個在示例四中我們也會分析到,是直接採用
* 字串合併,而合併後的字串abc在常量池中已經存在了,故直接使用
* 13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
* 16: aload_1
* 17: aload 4
* 19: if_acmpne 26
* 22: iconst_1
* 23: goto 27
* 26: iconst_0
* 27: invokevirtual #6 // Method java/io/PrintStream.println:(Z)V
* 30: return
* 從上面的分析中我們得出結論:s1指向了常量池中的字串物件abc,s4也是指向了常量池中的字串物件abc
*/
示例四
public class TestString {
public static void main(String[] args) {
String s1 = "abc";
String s2 = "a" + "bc"; //s1、s2指向同一個字串
String s3 = "test" + "One"; //說明常量池是直接儲存合併後的字串
System.out.println(s1 == s2); //true
}
}
/**
* 這裡多貼出常量池的資訊,為了說明s3的行為
* Constant pool:
* ...
* #30 = Utf8 abc
* #31 = Utf8 testOne
* ...
* 省略了一部分資訊,說明常量池是直接儲存合併後的字串,而並分開儲存,所以常量池中只會有"testOne",並沒有"test"或"One"
*
* public static void main(java.lang.String[]);
* Code:
* 0: ldc #2 // String abc
* 2: astore_1 -> s1 = "abc" 指向常量池中#2的引用
* 3: ldc #2 // String abc
* 5: astore_2 -> s2 = "abc" 從常量池中#2的引用,可以看到引用的字串物件是同一個
* 6: ldc #3 // String testOne
* 8: astore_3
* 9: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
* 12: aload_1
* 13: aload_2
* 14: if_acmpne 21
* 17: iconst_1
* 18: goto 22
* 21: iconst_0
* 22: invokevirtual #5 // Method java/io/PrintStream.println:(Z)V
* 25: return
* 從上面的分析中我們得出結論:s1、s2指向了常量池中的同一個字串物件
*/
示例五
public class TestString {
public static void main(String[] args) {
String s1 = "abc";
String s2 = "a";
String s3 = s2 + "bc"; // StringBuilder.append(a).append(bc) -> StringBuilder.toString() -> new String("abc")
System.out.println(s1 == s3);//false
}
}
/**
* public static void main(java.lang.String[]);
* Code:
* 0: ldc #2 // String abc
* 2: astore_1 -> s1 = "abc"
* 3: ldc #3 // String a
* 5: astore_2 -> s2 = "a"
* 6: new #4 // class java/lang/StringBuilder -> 建立StringBuilder物件
* 9: dup -> 複製StringBuilder物件的引用並壓入棧中
* 10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V -> 初始化StringBuilder
* 13: aload_2 -> 載入第二個區域性變數的值
* 14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; -> StringBuilder.append("a")
* 17: ldc #7 // String bc
* 19: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; -> StringBuilder.append("bc")
* 22: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; -> StringBuilder.toString
* 25: astore_3 -> s3 = "abc"
* 26: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
* 29: aload_1
* 30: aload_3
* 31: if_acmpne 38
* 34: iconst_1
* 35: goto 39
* 38: iconst_0
* 39: invokevirtual #10 // Method java/io/PrintStream.println:(Z)V
* 42: return
* 從上面的分析中我們得出結論:s1指向常量池中的字串物件abc,而從序號9-25我們知道生成了新的物件,相當於是執行了new String("abc"),結果自然是false
*/
示例六
先來看下String#intern方法作了什麼動作,還是採用此分析方式。
public class TestString {
public static void main(String[] args) {
String testOne = new String("a") + new String("bc"); //常量池中儲存字串a、bc,最終testOne指向堆記憶體中的物件,而該物件對應的字串是不會在常量池中存在
String testOneAnother = testOne.intern(); //先去常量池中查詢是否已經存在該字串,如果存在,則返回常量池中的引用,若不存在則不會將該物件的字串拷貝到常量池中,而是在常量池中持有對該物件的引用
//這裡的引用沒辦法從該方式得出,可能需要看下native的方法,反正我是看了別人的分析,雖然我是知道原理但還是忍不住看了以下底層實現
System.out.println(testOne == testOneAnther) //true
}
}
/**
* Constant pool:
* ...
* #31 = Utf8 a
* ...
* #34 = Utf8 bc
*/
示例七
public class TestString {
public static void main(String[] args) {
String s1 = new String("a") + new String("bc");
s1.intern(); //執行方法前我們知道常量池中並未有abc字串,執行該方法後,常量池中已經存在指向s1物件的引用,即"abc"字串的引用
String s2 = "abc"; // 常量池中已經存在"abc"字串的引用,即為s1物件的引用
System.out.println(s1 == s2);//true
}
}
/**
* public static void main(java.lang.String[]);
* Code:
* 0: new #2 // class java/lang/StringBuilder -> 建立StringBuilder物件
* 3: dup -> 複製StringBuilder物件的引用並壓入棧中
* 4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V -> 初始化StringBuilder
* 7: new #4 // class java/lang/String -> 建立字串物件 new String()
* 10: dup -> 複製String物件的引用並壓入棧中
* 11: ldc #5 // String a
* 13: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V -> new String("a")
* 16: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; -> StringBuilder.append()
* 19: new #4 // class java/lang/String -> 建立字串物件 new String()
* 22: dup -> 複製String物件的引用並壓入棧中
* 23: ldc #8 // String bc
* 25: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V -> new String("bc")
* 28: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; -> StringBuilder.append()
* 31: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; -> StringBuilder.toString()
* 34: astore_1 -> s1 = "abc", 由StringBuilder#toString建立的字串物件
* 35: aload_1 -> 載入第一個區域性變數的值
* 36: invokevirtual #10 // Method java/lang/String.intern:()Ljava/lang/String; -> s1.intern()
* 39: pop -> pop:移除棧頂的值
* 40: ldc #11 // String abc
* 42: astore_2 -> s2 = "abc"
* 43: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
* 46: aload_1
* 47: aload_2
* 48: if_acmpne 55
* 51: iconst_1
* 52: goto 56
* 55: iconst_0
* 56: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
* 59: return
*/
示例八
public class TestString {
public static void main(String[] args) {
String s1 = new String("abc"); //生成兩個物件,堆記憶體一個,常量池一個
s1.intern(); //常量池中已經存在該字串物件,故而直接返回
String s2 = "abc"; //指向常量池的字串物件
System.out.println(s1 == s2);//false -> s1指向堆記憶體中的物件,s2指向常量池的物件
}
}
總結
-
在編譯階段,字串已經被儲存與常量池中。
-
new String("abc"):一共有兩個不同的物件,一個在堆記憶體、一個在常量池。
-
s2 + s3拼接(s2、s3未被final修飾):底層建立StringBuilder物件,通過append拼接起來,最終呼叫toString生成一個新的物件。
-
"a" + "bc"直接拼接:直接將拼接後的字串儲存於常量池中。
-
s2 + "bc"拼接(s2未被final修飾):底層建立StringBuilder物件,通過append拼接起來,最終呼叫toString生成一個新的物件。
-
s.intern:若常量池中存在字串,則直接返回引用,若不存在,則在常量池中生成指向該字串物件的引用,後續若有宣告此字串,會返回指向該字串物件的引用,也就是同一個引用(參考示例七、八)。
重點
new String與String的區別
(s1 + s2)與("a" + "bc")的區別
intern