參加了這麼多面試,還是不懂StringBuffer和StringBuilder的區別?
- 2019 年 10 月 7 日
- 筆記


StringBuffer
上一節我們詳細介紹了 String 類的使用:
在實際開發中使用 String 類會存在一個問題,String 對象一旦創建,其值是不能修改的,如果要修改,會重新開闢內存空間來存儲修改之後的對象,即修改了 String 的引用。因為 String 的底層是用數組來存值的,數組長度不可改變這一特性導致了上述問題,所以如果開發中需要對某個字符串進行頻繁的修改,使用 String 就不合適了,會造成內存空間的浪費,如何解決這個問題呢?
可以使用 StringBuffer 類來解決,當對字符串對象進行頻繁修改時,使用 StringBuffer 可以極大提升程序的效率,我們通過下面這個例子一測便知。
分別定義 String 和 StringBuffer 類型的字符串對象,對它們進行值的累加操作,循環執行 50000 次,然後統計各自的耗時,代碼如下所示。
//String long startTime = System.currentTimeMillis(); String str = ""; for(int i = 0;i<50000;i++){ str += i; } long endTime = System.currentTimeMillis(); System.out.println("String類型操作耗時"+(endTime-startTime)+"毫秒"); //StringBuffer long startTime = System.currentTimeMillis(); StringBuffer str = new StringBuffer(); for(int i = 0;i<50000;i++){ str.append(i); } long endTime = System.currentTimeMillis(); System.out.println("StringBuffer類型操作耗時"+(endTime-startTime)+"毫秒");
運行結果分別如下圖所示。


可以看到 String 類型耗時 1345 毫秒,StringBuffer 類型耗時只有 7 毫秒,速度提升了近 200 倍。
接下來我們就來詳細學習 StringBuffer 類。
StringBuffer 和 String 類似,底層也是用一個數組來存儲字符串的值,並且數組的默認長度為 16,即一個空的 StringBuffer 對象,數組長度為 16,如下圖所示。

實例化一個 StringBuffer 對象即創建了一個大小為 16 個字符的字符串緩衝區。
當我們調用有參構造創建一個 StringBuffer 對象時,數組長度就不是 16 了,而是根據當前對象的值來決定數組的長度,「值的長度+16」作為數組的長度,如下圖所示。

創建了一個字符串緩衝區,該緩衝區初始值為指定的字符串。字符串緩衝區的初始容量為字符串參數的長度+16。
我們可以看到帶參構造中依次執行了兩步操作:super(str.length()+16)、append(str),這也就很清楚的說明了 StringBuffer 的創建過程,先創建一個長度為"str長度+16"的字符串緩衝區,然後把 str 的值追加到此字符串序列中。
所以一個 StringBuffer 創建完成之後,有 16 個字符的空間可以對其值進行修改。如果修改的值範圍超出了 16 個字符,則調用 ensureCapacityInternal() 方法檢查 StringBuffer 對象的原 char 數組的容量能不能裝下新的字符串,如果裝不下則對 char 數組進行擴容。

擴容的邏輯就是創建一個新的 char 數組,newCapacity() 方法用於確定新容量大小,將現有容量大小擴大一倍再加上2,如果還是不夠大則直接等於需要的容量大小。

擴容完成之後,再調用 Arrays.copyOf() 方法完成數據拷貝,底層調用 System.arryCopy() 方法將原數組的內容複製到新數組,最後將指針指向新的 char 數組。

StringBuffer 常用方法

具體代碼如下所示。
StringBuffer stringBuffer = new StringBuffer(); System.out.println("StringBuffer:"+stringBuffer); System.out.println("StringBuffer的長度:"+stringBuffer.length()); stringBuffer = new StringBuffer("Hello World"); System.out.println("StringBuffer:"+stringBuffer); System.out.println("下標為2的字符是:"+stringBuffer.charAt(2)); stringBuffer = stringBuffer.append("Java"); System.out.println("append之後的StringBuffer:"+stringBuffer); stringBuffer = stringBuffer.delete(3, 6); System.out.println("delete之後的StringBuffer:"+stringBuffer); stringBuffer = stringBuffer.deleteCharAt(3); System.out.println("deleteCharAt之後的StringBuffer:"+stringBuffer); stringBuffer = stringBuffer.replace(2,3,"StringBuffer"); System.out.println("replace之後的StringBuffer:"+stringBuffer); String str = stringBuffer.substring(2); System.out.println("substring之後的String:"+str); str = stringBuffer.substring(2,8); System.out.println("substring之後的String:"+str); stringBuffer = stringBuffer.insert(6,"six"); System.out.println("insert之後的StringBuffer:"+stringBuffer); System.out.println("e的下標是:"+stringBuffer.indexOf("e")); System.out.println("下標6之後的e的下標是:"+stringBuffer.indexOf("e",6)); stringBuffer = stringBuffer.reverse(); System.out.println("reverse之後的StringBuffer:"+stringBuffer); str = stringBuffer.toString(); System.out.println("StringBuffer對應的String:"+str);
運行結果如下圖所示。

StringBuilder
StringBuilder 和 StringBuffer 是一對兄弟,因為它們擁有同一個父類 AbstractStringBuilder,同時實現的接口也是完全一樣,都實現了 java.io.Serializable, CharSequence 兩個接口,如下圖所示。


那它們有什麼區別呢?最大的區別在於 StringBuffer 對幾乎所有的方法都實現了同步,StringBuilder 沒有實現同步,如同樣是對 AbstractStringBuilder 方法 append 的重寫,StringBuffer 添加了 synchronized 關鍵字修飾,而 StringBuilder 沒有,如下圖所示。


所以 StringBuffer 是線程安全的,在多線程系統中可以保證數據同步,而 StringBuilder 無法保證線程安全,所以多線程系統中不能使用 StringBuilder。
但是方法同步需要消耗一定的系統資源,所以 StringBuffer 雖然安全,但是效率不如 StringBuilder,也就是說使用 StringBuilder 更快,我們還是用上面的例子做一個測試。
分別定義 StringBuffer 和 StringBuilder 類型的字符串對象,對它們進行值的累加操作,循環執行 500000 次,然後統計各自的耗時,代碼如下所示。
//StringBuffer long startTime = System.currentTimeMillis(); StringBuffer str = new StringBuffer(); for(int i = 0;i<500000;i++){ str.append(i); } long endTime = System.currentTimeMillis(); System.out.println("StringBuffer類型操作耗時"+(endTime-startTime)+"毫秒"); //StringBuilder long startTime = System.currentTimeMillis(); StringBuilder str = new StringBuilder(); for(int i = 0;i<500000;i++){ str.append(i); } long endTime = System.currentTimeMillis(); System.out.println("StringBuilder類型操作耗時"+(endTime-startTime)+"毫秒");
運行結果分別如下圖所示。


通過結果可以看到,同樣是執行 50 萬次操作,StringBuffer 耗時 45 毫秒,而 StringBuilder 耗時 34 毫秒,相差雖然不是很大,但是 StringBuilder 效率確實要高於 StringBuffer,但是安全性不如 StringBuffer。
所以,在需要考慮線程安全的場景下我們可以使用 StringBuffer,不需要考慮線程安全,追求效率的場景下可以使用 StringBuilder。
StringBuilder 的具體使用如下所示。
StringBuilder stringBuilder = new StringBuilder(); System.out.println("StringBuilder:"+stringBuilder); System.out.println("StringBuilder的長度:"+stringBuilder.length()); stringBuilder = new StringBuilder("Hello World"); System.out.println("StringBuilder:"+stringBuilder); System.out.println("下標為2的字符是:"+stringBuilder.charAt(2)); stringBuilder = stringBuilder.append("Java"); System.out.println("append之後的StringBuilder:"+stringBuilder); stringBuilder = stringBuilder.delete(3, 6); System.out.println("delete之後的StringBuilder:"+stringBuilder); stringBuilder = stringBuilder.deleteCharAt(3); System.out.println("deleteCharAt之後的StringBuilder:"+stringBuilder); stringBuilder = stringBuilder.replace(2,3,"StringBuilder"); System.out.println("replace之後的StringBuilder:"+stringBuilder); String str = stringBuilder.substring(2); System.out.println("substring之後的String:"+str); str = stringBuilder.substring(2,8); System.out.println("substring之後的String:"+str); stringBuilder = stringBuilder.insert(6,"six"); System.out.println("insert之後的StringBuilder:"+stringBuilder); System.out.println("e的下標是:"+stringBuilder.indexOf("e")); System.out.println("下標6之後的e的下標是:"+stringBuilder.indexOf("e",6)); stringBuilder = stringBuilder.reverse(); System.out.println("reverse之後的StringBuilder:"+stringBuilder); str = stringBuilder.toString(); System.out.println("StringBuilder對應的String:"+str);
運行結果如下圖所示。

StringBuilder 為什麼線程不安全?
我們通過一個例子來測試,代碼如下所示。
StringBuilder stringBuilder = new StringBuilder(); for (int i = 0; i < 10; i++){ new Thread(new Runnable() { @Override public void run() { for (int j = 0; j < 1000; j++){ stringBuilder.append("a"); } } }).start(); } try { Thread.sleep(100); System.out.println(stringBuilder.length()); } catch (InterruptedException e) { e.printStackTrace(); }
開啟 10 個線程,每個現象對 stringBuilder 添加 1000 個 'a',操作完成之後,stringBuilder 的長度應該是 10*1000 = 10000,但是我們看到多次運行的結果如下。


長度比 10000 小(也有可能等於 10000,概率較小),同時也可能會拋出數組下標越界的異常,證明 StringBuilder 確實是線程不安全的,為什麼是這樣呢?我們查看源碼來分析。
StringBuilder 的 append() 方法底層調用 AbstractStringBuilder 的 append() 方法,如下所示。

count 為字符串長度,len 為追加的字符串長度,count += len 這行代碼如果是多線程同時訪問,很可能會出現數據錯誤,比如 count = 0,len = 1,兩個線程同時執行到這一行,獲取的 count 都是 0,執行的結果都是 1,所以最終 count 的值為 1,而不是 2,這就解釋了為什麼最終的長度有可能比預期結果小的原因。
再來說說為什麼會拋出數組下標越界異常?
字符的添加是通過調用 putStringAt(count,str) 方法完成的,count 為當前字符串的長度,通過 ensureCapacityinternal(count+len) 方法對數組進行擴容之後,它一定是小於等於數組最大容量的,putStringAt(count,str) 方法中每添加一個字符,都會給 count 加 1,當到達數組長度上限之後再進行擴容。
但是如果是兩個線程同時執行 putStringAt(count,str),假設此時的 count = 3,數組容量為 4,兩個線程拿到的 count 都為 3,數組容量大於 count,所以並不會進行擴容,這就意味着只剩一個空間,要插入兩個字符,線程 A 執行完畢,count 變為 4,已經佔滿了整個數組,所以線程 B 執行的時候,超出了數組的長度,拋出異常。

高頻面試題
1、StringBuilder 的效率一定比 String 更高嗎?
我們通常會說 StringBuilder 效率要比 String 高,嚴謹一點這句話不完全對,雖然大部分情況下使用 StringBuilder 效率更高,但在某些特定情況下不一定是這樣,比如下面這段代碼:
String str = "Hello"+"World"; StringBuilder stringBuilder = new StringBuilder("Hello"); stringBuilder.append("World");
此時,使用 String 創建 "HelloWorld" 的效率要高於使用 StringBuilder 創建 "HelloWorld",這是為什麼呢?
因為 String 對象的直接相加,JVM 會自動對其進行優化,也就是說 "Hello"+"World" 在編譯期間會自動優化為 "HelloWorld",直接一次性創建完成,所以效率肯定要高於 StringBuffer 的 append 拼接。
但是需要注意的是如果是這樣的代碼:
String str1 = "Hello"; String str2 = "World"; String str3 = str1+str2;
對於這種間接相加的操作,效率要比直接相加低,因為在編譯器不會對引用變量進行優化。
2、下面代碼的運行結果是?
String str1 = "Hello World"; String str2 = "Hello"+" World"; System.out.println(str1 == str2);
true,因為 "Hello"+" World" 在編譯期間會被 JVM 自動優化成 "Hello World",是一個字符串常量,所以和 str1 引用相同。
3、下面代碼的運行結果是?
String str1 = "Hello World"; String str2 = "Hello"; String str3 = str2 + " World"; System.out.println(str1 == str3);
false,JVM 只有在 String 對象直接拼接的時候才會進行優化,如果是對變量進行拼接則不會優化,所以 str2 + " World" 並不會直接優化成字符串常量 "Hello World",同時這種間接拼接的結果是存放在堆內存中的,所以 str1 和 str3 的引用肯定不同。
4、String str = new String("Hello World") 創建了幾個對象?
這是很常見的一道面試題,大部分的答案都是 2 個,"Hello World" 是一個,另一個是指向字符串的變量 str,其實是不準確的。
因為代碼的執行過程和類的加載過程是有區別的,如果只看運行期間,這段代碼只創建了 1 個對象,new 只調用了一次,即在堆上創建的 "Hello World" 對象。
而在類加載的過程中,創建了 2 個對象,一個是字符串字面量 "Hello World" 在字符串常量池中所對應的實例,另一個是通過 new String("Hello World") 在堆中創建並初始化,內容與 "Hello World" 相同的實例。
所以在回答這道題的時候,可以先問清楚面試官是在代碼執行過程中,還是在類加載過程中。這道題目如果換做是 String str = new String("Hello World") 涉及到幾個對象,那麼答案就是 2 個。
5、String、StringBuffer、StringBuilder 有什麼區別?
1、String 一旦創建不可變,如果修改即創建新的對象,StringBuffer 和 StringBuilder 可變,修改之後引用不變。
2、String 對象直接拼接效率高,但是如果執行的是間接拼接,效率很低,而 StringBuffer 和 StringBuilder 的效率更高,同時 StringBuilder 的效率高於 StringBuffer。
3、StringBuffer 的方法是線程安全的,StringBuilder 是線程不安全的,在考慮線程安全的情況下,應該使用 StringBuffer。
6、下面代碼的運行結果是?
public static void main(String[] args) { String str = "Hello"; test(str); System.out.println(str); } public static void test(String str){ str+="World"; }
Hello,因為 String 是不可變的,傳入 test 方法的參數相當於 str 的一個副本,所以方法內只是修改了副本,str 本身的值沒有發生變化。
7、下面代碼的運行結果是?
public static void main(String[] args) { StringBuffer str = new StringBuffer("Hello"); test(str); System.out.println(str); } public static void test(StringBuffer str){ str.append(" World"); }
Hello World,因為 StringBuffer 是可變類型,傳入 test 方法的參數就是 str 的引用,所以方法內修改的就是 str 本身。