JAVA字符串創建及拼接分析


JAVA主要有三種常量池:class文件常量池、運行時常量池、字符串常量池。

一、class文件常量池

  一個.java文件在編譯階段會被編譯成.class文件,.class文件中除了有魔數、副版本號、主版本號等信息外,還有一個class文件常量池,常量池裏主要包含兩種數據:字面量和符號引用。

  字面量:
  1.文本字符串
  2.八中基本類型的值
  3.inal類型常量值

  符號引用:
  1.類和接口的全限定名
  2.字段的名稱和描述符
  3.方法的名稱和描述符

二、運行時常量池

  運行時常量池存在於JVM方法區中,在類加載完成,經過驗證,準備階段之後,JVM會把class文件常量池中除了字符串以外的其他數據放入運行時常量池中,符號引用會替換爲直接引用。

什麼是符號引用和直接引用?
在編譯的時候,JAVA類並不知道引用類的實際內存地址,只能用一些符號來代替。比如要引用org.simple.Tools類,編譯的時候是不知道Tools的實際地址的,只能用個字面符號來代替,等到類裝載的時候,再把這個符號換成實際的地址,也就是直接引用地址。

三、字符串常量池

  剛剛說,類加載完成後,其他進入運行時常量池,字符串則進入字符串常量池。
  字符串常量池在堆中,它是一個哈希表,key是字符串表面字符,value是字符串在堆中的地址。也就是說,對於一個字符串常量,它的實例是在堆中的,字符串常量池保存的只是這個實例的地址

JDK1.7之前,所有常量都在運行時常量池中(包括字符串),也就是在方法區中,此時hotspot虛擬機對方法區的實現爲永久代。
JDK1.7開始,字符串常量被單獨從方法區拿到了堆中, 建立了字符串常量池,剩下其他常量還留在運行時常量池, 也就是hotspot中的永久代。
JDK1.8開始,hotspot移除了永久代用元空間(Metaspace)取而代之, 這時候字符串常量池還在堆, 運行時常量池還在方法區, 只不過方法區的實現從永久代變成了元空間。
JDK1.7之前,實例和引用都保存在運行時常量池中;1.7之後由於把字符串常量提到了堆中,就在堆中創建實例,而字符串常量池只保存引用地址。

四、字符串創建案例分析

  下面讓我們來分析一個經典的面試問題:

String s=new String(“abc”)究竟創建了幾個String對象?

  在回答這個問題之前,我們要先分析字符串創建的過程。

——————————————————————————————————————
例子1:

String t1="abc";

  這句話的具體執行流程是:
  1.因爲有字面量"abc",所以在編譯時"abc"會進入class文件常量池中;
  2.執行類加載後,"abc"會在堆中創建一個實例對象,然後把這個實例對象的地址放到字符串常量池中;
  3.當具體執行到這句語句時,因爲是直接常量賦值,所以就去字符串常量池中查找"abc"的地址並返回。
  結果:只創建了1個String對象,常量池中只有1項
在這裏插入圖片描述
——————————————————————————————————————
例子2:

String t1=new String("abc");

  這句話的具體執行流程是:
  1.因爲有字面量"abc",所以在編譯時"abc"會進入class文件常量池中;
  2.執行類加載後,"abc"會在堆中創建一個實例對象,然後把這個實例對象的地址放到字符串常量池中;
  3.當具體執行到這句語句時,因爲是動態new創建,所以會在堆中新產生一個實例對象,然後直接返回這個對象的地址。
  結果:創建了2個String對象,常量池中只有1項
在這裏插入圖片描述
  這句代碼是犯錯最多的地方,總是看到有人說:
  new創建時會先看常量池有沒有對應的字符,如果有就拷貝一份到堆中,然後返回堆地址;如果沒有就先在堆中創建一個實例,然後拷貝一份放到常量池中。
  這句話錯的原因在於:字面常量在類加載後就進入字符串常量池了,堆中已經有了實例了,而動態創建的實例在具體執行到該語句時纔會創建,這是兩個分開的過程,我們總是混爲一談。
  只需要記住兩個原則就行:
  1.動態創建的實例和常量池沒有任何關係
  2.字符串要想進入常量池只有兩個方法,一個自動一個手動。自動的方法就是用雙引號“”引起來,手動的方法就是調用intern()方法

——————————————————————————————————————
例子3:

String t1=new String("abc");
String t2="abc";
System.out.println(t1==t2);
//>false

  這段代碼的具體執行流程是:
  1.因爲有字面量"abc",兩個都一樣所以就只有一個,在編譯時"abc"會進入class文件常量池中;
  2.執行類加載後,"abc"會在堆中創建一個實例對象,然後把這個實例對象的地址放到字符串常量池中;
  3.具體執行到t1語句時,因爲new動態創建所以在堆中創建一個新實例,然後直接返回地址,跟常量池不發生任何交互;
  4.具體執行到t2語句時,因爲常量賦值,所以去常量池中找對應的字符,返回對應地址。
  結果:創建了2個String對象,常量池中只有1項
在這裏插入圖片描述
  這下你終於明白爲什麼t1和t2不等了吧!
——————————————————————————————————————
例子4:

String t1=new String("abc");
String t2="abc";
System.out.println(t1==t2);
//>false
t1.intern();
System.out.println(t1==t2);
//>false
t1=t1.intern();
System.out.println(t1==t2);
//>true

  這個例子主要是想說明intern()方法的使用。
  intern()方法的執行步驟是:在字符串常量池中查找對應字符是否存在,如果存在就直接返回常量池中存儲的地址;如果不存在就將它的引用保存到常量池中,並返回這個引用。
  所以intern()的方法的返回值纔是字符串在常量池中的地址,而只執行不返回是沒有用的,t1該是多少還是多少,要重新接收返回值纔行。
  結果:創建了2個String對象,常量池中只有1項
在這裏插入圖片描述
——————————————————————————————————————
例子5:

String t1="abc";
String t2=new String("abc");
String t3=new String(t1);
System.out.println(t1==t2);
//>false
System.out.println(t1==t3);
//>false
System.out.println(t2==t3);
//>false

  這段代碼的具體執行流程是:
  1.因爲有字面量"abc",兩個都一樣所以就只有一個,在編譯時"abc"會進入class文件常量池中;
  2.執行類加載後,"abc"會在堆中創建一個實例對象,然後把這個實例對象的地址放到字符串常量池中;
  3.具體執行到t1語句時,因爲常量賦值,所以去常量池中找對應的字符,返回對應地址;
  4.具體執行到t2語句時,因爲new動態創建所以在堆中創建一個新實例,然後直接返回地址,跟常量池不發生任何交互;
  5.具體執行到t3語句時,因爲new動態創建所以在堆中創建一個新實例,然後直接返回地址,跟常量池不發生任何交互。
  結果:創建了3個String對象,常量池中只有1項
在這裏插入圖片描述
——————————————————————————————————————
例子6:

String t1=new String("abc");
String t2=new String("def");

  這段代碼的具體執行流程是:
  1.因爲有字面量"abc"和“def”,在編譯時"abc"和"def"會進入class文件常量池中;
  2.執行類加載後,"abc"會在堆中創建一個實例對象,然後把這個實例對象的地址放到字符串常量池中;"def"也會在堆中創建一個實例對象,然後把這個實例對象的地址放到字符串常量池中;
  3.具體執行到t1語句時,因爲new動態創建所以在堆中創建一個新實例,然後直接返回地址;
  4.具體執行到t2語句時,因爲new動態創建所以在堆中創建一個新實例,然後直接返回地址。
  結果:創建了4個String對象,常量池中有2項
在這裏插入圖片描述
——————————————————————————————————————

五、字符串+號拼接案例分析

  下面讓我們來分析一個經典的面試問題:

String s=new String(“abc”)+new String(“def”)究竟創建了幾個對象?

  在回答這個問題之前,我們還是要先分析字符串創建的過程。
  你應該知道,JAVA的String是一個隱式final修飾的不可變變量,如果有改變就會創建一個新的String對象,所以在涉及到字符串+號的時候,系統會自動創建一個StringBuilder對象,然後每執行一次+號,就會append一次,也就是說上面的代碼其實具體執行的時候是:

String s=new StringBuilder().append(new String("abc")).append(new String("def")).toString();

——————————————————————————————————————
例子1:

String t1="abc";
String t2="a"+"bc";
System.out.println(t1==t2);
//>true

  你可以猜一下,字符串常量池裏有幾個字符串?三個?
  答案是隻有一個,就是"abc"。
  對於字面常量的+號拼接,編譯器在編譯的過程中會進行優化,只保留最終結果,也就是說哪怕你中間有幾萬個字面常量字符串,只要你是以+號拼接的,最終都只有一個最終結果。所以在編譯的過程中這兩個就是同一個字面量"abc",只會往常量池中存一個。
  所以對於字面量的+號拼接,在編譯階段其實就已經完成了,所以在具體執行階段不會產生StringBuilder對象,更不會執行append方法了。
  所以這個過程只在堆中創建了一個String實例,這個實例的地址存到了字符串常量池中,t1指向這個地址,t2也指向這個地址。
  結果:創建了1個String對象,0個StringBuilder對象,常量池中有1項
在這裏插入圖片描述
——————————————————————————————————————
例子2:

String t1="abc";
final String t2="a";
String t3=t2+"bc";
System.out.println(t1==t3);
//>true

  final常量在編譯的時候會被識別爲與字面常量同一類型,也就是t3在編譯的時候就會變成這句:

String t3="a"+"bc";

  所以最終在常量池裏你會看到“a”和"abc",但你找不到"bc",因爲常量+號拼接只保留最終結果。
  所以最終因爲字面量“abc”和“a”會在堆中創建兩個實例,然後地址加到常量池中,t1、t2、t3通通都是常量賦值,直接返回常量池的地址即可。
  結果:創建了2個String對象,0個StringBuilder對象,常量池中有2項
在這裏插入圖片描述
——————————————————————————————————————
例子3:

String t1="abc"+new String("d");

  因爲+左右的不是字面常量,所以不會在編譯階段自動拼接了。所以這條語句的具體執行步驟如下:
  1.因爲有字面量"abc"和“d”,在編譯時"abc"和"d"會進入class文件常量池中;
  2.執行類加載後,“abc"會在堆中創建一個實例對象,然後把這個實例對象的地址放到字符串常量池中;“d"也會在堆中創建一個實例對象,然後把這個實例對象的地址放到字符串常量池中;
  3.具體執行到t1語句時,先創建一個StringBuilder對象初始化;
  4.遇到字面常量"abc”,則去常量池查找對應地址,提出字符內容,然後調用append方法將"abc"拷貝到StringBuilder的底層數組中(StringBuilder底層是用char[]依次保存所有字符);
  5.遇到new動態創建代碼,在堆中創建一個實例對象,然後返回地址;
  6.利用返回的地址提出具體字符內容"d”,然後調用append方法將"d"拷貝到StringBuilder底層數組中;
  7.調用StringBuilder的toString()方法,在堆中創建一個實例對象,然後返回地址給t1。
  所以這個例子等同於:

String t1=new StringBuilder().append("abc").append(new String("d")).toString();

  其中StringBuilder的toString()源代碼如下,,可以看到是動態創建了一個String對象。

@Override
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

  好,這裏有個問題:字符串常量池裏有"abcd"嗎?
  答案是沒有!
  原因就是我之前說過的,動態創建字符串不會和常量池產生任何關係。
  在這裏,StringBuilder調用new String來創建一個實例"abcd",然後就直接返回堆地址了,不會和常量池產生關係。這裏就證實了“動態創建之後會去常量池找對應的字符,沒有就把引用放到常量池中”這句話是不對的!
  結果:創建了4個String對象,1個StringBuilder對象(圖裏沒畫),常量池中有2項
在這裏插入圖片描述
——————————————————————————————————————
例子4:

String t1="abc"+new String("d");
String t2="abcd";
System.out.println(t1==t2);
//>false

  這是例3的擴展,因爲有字面量"abc"、“d”、“abcd”,所以常量池中會有3項,而t2就指向常量池中的地址,而t1是StringBuilder的toString()方法新創建出來的,所以不等。
  結果:創建了5個String對象,1個StringBuilder對象(圖裏沒畫),常量池中有3項
在這裏插入圖片描述
——————————————————————————————————————
例子5:

String s=new String(“abc”)+new String(“def”)究竟創建了幾個對象?

  看到這終於可以回答這個問題,一共幾個對象?
  結果:創建了5個String對象,1個StringBuilder對象,常量池中有2項
  其中字面常量"abc"和"def"創建了2個String對象,new動態創建了2個String對象,StringBuilder最後調用toString()方法還動態創建了1個,所以一共是5個String對象。

六、字符串拼接比較

  字符串的拼接主要有以下幾種方式:
  1.+號
  2.concat()方法
  3.join()方法
  4.StringBuilder的append()方法
  5.StringBuffer的append()方法

——————————————————————————————————————
情況1:

final String t="rstuvw";
String t1="a"+"bcd"+"efghijkl"+"mno"+"p"+t;

  上面說到了,如果只涉及到字面量和final常量的拼接,用+號是最快的,編譯器會在編譯階段就直接優化出最終結果,比其他任何方式都快。
  +號拼接有一個明顯的優勢就是:+幾乎可以拼接一切字符,+可以拼字符串、單個字符、常量、變量、整數、浮點數、數組、對象(只不過這兩是地址)等等。
——————————————————————————————————————
情況2:

String t2="abc";
String t3=t2+"def";

  如果不是純字面常量的拼接,中間涉及到了非final變量的話,如果用+號就會在底層創建一個StringBuilder對象,然後不斷調用append()方法,也就是說上面的代碼和下面的是一樣的:

String t2="abc";
StringBuilder s=new StringBuilder();
s.append(t2);
s.append("def");
String t3=s.toString();

  StringBuilder的append()方法和+一樣可以填入各種類型。
  這裏需要注意的是:在一行+號內只創建一個StringBuilder,如果使用多行就會創建多個StringBuilder,比如下面的例子。
——————————————————————————————————————
情況3:

String t1="abc"+new String("def");
t1+="ghi"+new String("jkl");
t1+="mno";

  比如在這種情況下每一行就會產生一個StringBuilder,同時每一行都會執行一次toString(),也就是說每一行都會額外新建一個String對象,這是非常冗雜的。
  正確的做法應該如下:

StringBuilder s=new StringBuilder();
s.append("abc");
s.append(new String("def"));
s.append("ghi");
s.append(new String("jkl");
s.append("mno");
String t1=s.toString();

——————————————————————————————————————
情況4:
  有一種情況是你需要把一個數組裏的元素拼成字符串,比如[“abc”,“def”,“ghi”]拼成字符串,能想到的做法有以下幾種:

String[] arr=new String[]{"abc","def","ghi"};
String t1="";
for(int i=0;i<arr.length;i++){
	t1+=arr[i];
}

  這種方法上面說了,每循環一次就會創建一個StringBuilder,同時每次還會新建一個String對象,太冗雜。

String[] arr=new String[]{"abc","def","ghi"};
StringBuilder s=new StringBuilder();
for(int i=0;i<arr.length;i++){
	s.append(arr[i]);
}
String t1=s.toString();

  這種方法是可行的,並且不會創建額外的變量。

String[] arr=new String[]{"abc","def","ghi"};
String t1=String.join("-",arr);
//t1="abc-def-ghi"

  String自帶的join函數適用於按需求拼接數組,看源碼會發現join()方法底層用的是StringJoiner,而StringJoiner底層用的還是StringBuilder,所以這種方法雖然簡單,還其實沒有上面那種高效,會創建額外的StringJoiner對象。
  還有一個缺點就是join函數只能拼String[],int[]就拼不了了,必須先轉成String[]。
  最常見的用法還是下面這個:

String[] arr=new String[]{"abc","def","ghi"};
String t1= Arrays.toString(arr);

  Arrays工具包提供的toString()方法可以直接轉換一切類型的數組,而且它的底層是直接用StringBuilder執行的,所以會比較快,當然缺點就是不能在中間插入一些自己想加的字符。
——————————————————————————————————————
情況5:

String s=new String("abc").concat("def").concat("ghi");

  concat()方法底層其實是利用數組拷貝來拼接的,然後new出一個String對象,也就是說如果調用一次concat函數就會申請一段內存數組來存放拼接後的所有字符,然後再把這些字符new出一個String返回,等於你調用了幾次就多了幾個String。
  雖然底層直接數組拷貝比較快,但由於多了String中間量,所以比較冗餘。
  還有一個缺點就是concat只能拼字符串,單個字符、整數、浮點數、數組等等都不能做爲concat的參數。
——————————————————————————————————————
情況6:

StringBuffer s=new StringBuffer();
s.append("abc");
s.append(new String("def"));
String t1=s.toString();

  StringBuffer基本用法跟StringBuilder差不多,差別就在於StringBuffer是線程安全的,它的所有方法都用synchronized修飾,保證了多線程原子性,但同時性能相對StringBuilder也會下降很多,而同時StringBuilder速度快但是線程不安全。
——————————————————————————————————————
  總結一下就是:
  1.如果是單純字面量和final常量拼接,用+號最好;
  2.如果要加的元素能在一行內寫完的話,用+號和用StringBuilder是一樣的,不過+號代碼寫起來簡單;
  3.如果要加的元素要累加多行的話,用StringBuilder逐一append()是最好的;
  4.如果要把一個數組所有元素直接拼成字符串用Arrays.toString()是最簡單最好的;
  5.如要用拼接一個數組元素,但是中間想添加一些自定義字符用String.join()比較好,或者也可以用StringBuilder在每次append()數組內容之前先append()一下自定義元素;
  6.如果要保證線程安全的話必須用StringBuffer
  7.String自帶的concat()方法因爲每次都會新建一個String對象,而且參數限制太大,所以很少用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章