面試常備,字符串三劍客 String、StringBuffer、StringBuilder

🎓 盡人事,聽天命。博主東南大學碩士在讀,熱愛健身和籃球,樂於分享技術相關的所見所得,關注公眾號 @ 飛天小牛肉,第一時間獲取文章更新,成長的路上我們一起進步

🎁 本文已收錄於 「CS-Wiki」Gitee 官方推薦項目,現已累計 1.5k+ star,致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習

🍉 如果各位小夥伴春招秋招沒有拿得出手的項目的話,可以參考我寫的一個項目「開源社區系統 Echo」Gitee 官方推薦項目,目前已累計 400+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + … 並提供詳細的開發文檔和配套教程。公眾號後台回復 Echo 可以獲取配套教程,目前尚在更新中


字符串操作毫無疑問是計算機程序設計中最常見的行為之一,在 Java 大展拳腳的 Web 系統中更是如此。

全文脈絡思維導圖如下:

1. 三劍客之首:不可變的 String

概述

Java 沒有內置的字符串類型, 而是在標準 Java 類庫中提供了一個預定義類 String。每個用雙引號括起來的字符串都是 String 類的一個實例

String e = ""; // 空串
String str = "hello";

看一下 String 的源碼,在 Java 8 中,String 內部是使用 char 數組來存儲數據的

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
}

可以看到,String 類是被 final 修飾的,因此 String 類不允許被繼承

而在 Java 9 之後,String 類的實現改用 byte 數組存儲字符串,同時使用 coder 來標識使用了哪種編碼。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final byte[] value;

    /** The identifier of the encoding used to encode the bytes in {@code value}. */
    private final byte coder;
}

不過,無論是 Java 8 還是 Java 9,用來存儲數據的 char 或者 byte 數組 value 都一直是被聲明為 final,這意味着 value 數組初始化之後就不能再引用其它數組了。並且 String 內部沒有改變 value 數組的方法,因此我們就說 String 是不可變的。

所謂不可變,就如同數字 3 永遠是數字 3 —樣,字符串 「hello」 永遠包含字符 h、e、1、1 和 o 的代碼單元序列, 不能修改其中的任何一個字符。當然, 可以修改字符串變量 str, 讓它引用另外一個字符串, 這就如同可以將存放 3 的數值變量改成存放 4 一樣。

我們看個例子:

String str = "asdf";
String x = str.toUpperCase();

toUpperCase 用來將字符串全部轉為大寫字符,進入 toUpperCase 的源碼我們發現,這個看起來會修改 String 值的方法,實際上最後是創建了一個全新的 String 對象,而最初的 String 對象則絲毫未動。

空串與 Null

空串 "" 很好理解,就是長度為 0 的字符串。可以調用以下代碼檢查一個字符串是否為空:

if(str.length() == 0){
    // todo
}

或者

if(str.equals("")){
	// todo
}

空串是一個 Java 對象, 有自己的串長度( 0 ) 和內容(空),也就是 value 數組為空。

String 變量還可以存放一個特殊的值, 名為 null,這表示目前沒有任何對象與該變量關聯。要檢查一個字符串是否為 null,可如下判斷:

if(str == null){
    // todo
}

有時要檢查一個字符串既不是 null 也不為空串,這種情況下就需要使用以下條件:

if(str != null && str.length() != 0){
    // todo
}

有同學就會覺得,這麼簡單的條件判斷還用你說?沒錯,這雖然簡單,但仍然有個小坑,就是我們必須首先檢查 str 是否為 null,因為如果在一個 null 值上調用方法,編譯器會報錯

字符串拼接

上面既然說到 String 是不可變的,我們來看段代碼,為什麼這裡的字符串 a 卻發生了改變?

String a = "hello";
String b = "world";
a = a + b; // a = "helloworld"

實際上,在使用 + 進行字符串拼接的時候,JVM 是初始化了一個 StringBuilder 來進行拼接的。相當於編譯後的代碼如下:

String a = "hello";
String b = "world";
StringBuilder builder = new StringBuilder();
builder.append(a);
builder.append(b);
a = builder.toString();

關於 StringBuilder 下文會詳細講解,大家現在只需要知道 StringBuilder 是可變的字符串類型就 OK 了。我們看下 builder.toString() 的源碼:

顯然,toString方法同樣是生成了一個新的 String 對象,而不是在舊字符串的內容上做更改,相當於把舊字符串的引用指向的新的String對象。這也就是字符串 a 發生變化的原因。

另外,我們還需要了解一個特性,當將一個字符串與一個非字符串的值進行拼接時,後者被自動轉換成字符串(任何一個 Java 對象都可以轉換成字符串)。例如:

int age = 13;
String rating = "PG" + age; // rating = "PG13"

這種特性通常用在輸出語句中。例如:

int a = 12;
System.out.println("a = " + a);

結合上面這兩特性,我們來看個小問題,空串和 null 拼接的結果是啥

String str = null;
str = str + "";
System.out.println(str);

答案是 null 大家應該都能猜出來,但為什麼是 null 呢?上文說過,使用 + 進行拼接實際上是會轉換為 StringBuilder 使用 append 方法進行拼接,編譯後的代碼如下:

String str = null;
str = str + "";
StringBuilder builder = new StringBuilder();
builder.append(str);
builder.append("");
str = builder.toString();

看下 append 的源碼:

可以看出,當傳入的字符串是 null 時,會調用 appendNull 方法,而這個方法會返回 null

檢測字符串是否相等

可以使用 equals 方法檢測兩個字符串是否相等。比如:

String str = "hello";
System.out.println("hello".equals(str)); // true

equals 其實是 Object 類中的一個方法,所有的類都繼承於 Object 類。講解 equals 方法之前,我們先來回顧一下運算符 == 的用法,它存在兩種使用情況:

  • 對於基本數據類型來說, == 比較的是值是否相同;
  • 對於引用數據類型來說, == 比較的是內存地址是否相同。

舉個例子:

String str1 = new String("hello"); 
String str2 = new String("hello");
System.out.println(str1 == str2); // false

對 Java 中數據存儲區域仍然不明白的可以先回去看看第一章《萬物皆對象》。對於上述代碼,str1 和 str2 採用構造函數 new String() 的方式新建了兩個不同字符串,以 String str1 = new String("hello"); 為例,new 出來的對象存放在堆內存中,用一個引用 str1 來指向這個對象的地址,而這個對象的引用 str1 存放在棧內存中。str1 和 str2 是兩個不同的對象,地址不同,因此 == 比較的結果也就為 false

而實際上,Object 類中的原始 equals 方法內部調用的還是運算符 ==判斷的是兩個對象是否具有相同的引用(地址),和 == 的效果是一樣的

也就是說,如果你新建的類沒有覆蓋 equals 方法,那麼這個方法比較的就是對象的地址。而 String 方法覆蓋了 equals 方法,我們來看下源碼:

可以看出,String 重寫的 equals 方法比較的是對象的內容,而非地址。

總結下 equals()的兩種使用情況:

  • 情況 1:類沒有覆蓋 equals() 方法。則通過 equals() 比較該類的兩個對象時,等價於通過 == 比較這兩個對象(比較的是地址)。
  • 情況 2:類覆蓋了 equals() 方法。一般來說,我們都覆蓋 equals() 方法來判斷兩個對象的內容是否相等,比如 String 類就是這樣做的。當然,你也可以不這樣做。

舉個例子:

String a = new String("ab"); // a 為一個字符串引用
String b = new String("ab"); // b 為另一個字符串引用,這倆對象的內容一樣

if (a.equals(b)) // true
    System.out.println("aEQb");
if (a == b) // false,不是同一個對象,地址不同
    System.out.println("a==b");

字符串常量池

字符串 String 既然作為 Java 中的一個類,那麼它和其他的對象分配一樣,需要耗費高昂的時間與空間代價,作為最基礎最常用的數據類型,大量頻繁的創建字符串,將會極大程度的影響程序的性能。為此,JVM 為了提高性能和減少內存開銷,在實例化字符串常量的時候進行了一些優化:

  • 為字符串開闢了一個字符串常量池 String Pool,可以理解為緩存區
  • 創建字符串常量時,首先檢查字符串常量池中是否存在該字符串
  • 若字符串常量池中存在該字符串,則直接返回該引用實例,無需重新實例化;若不存在,則實例化該字符串並放入池中。

舉個例子:

String str1 = "hello";
String str2 = "hello";

System.out.printl("str1 == str2" : str1 == str2 ) //true 

對於上面這段代碼,String str1 = "hello";編譯器首先會在棧中創建一個變量名為 str1 的引用,然後在字符串常量池中查找有沒有值為 “hello” 的引用,如果沒找到,就在字符串常量池中開闢一個地址存放 “hello” 這個字符串,然後將引用 str1 指向 “hello”

需要注意的是,字符串常量池的位置在 JDK 1.7 有所變化:

  • JDK 1.7 之前,字符串常量池存在於常量存儲(Constant storage)中
  • JDK 1.7 之後,字符串常量池存在於堆內存(Heap)中。

另外,我們還可以使用 String intern() 方法在運行過程中手動的將字符串添加到 String Pool 中。具體過程是這樣的:

當一個字符串調用 intern() 方法時,如果 String Pool 中已經存在一個字符串和該字符串的值相等,那麼就會返回 String Pool 中字符串的引用;否則,就會在 String Pool 中添加一個新的字符串,並返回這個新字符串的引用。

看下面這個例子:

String str1 = new String("hello"); 
String str3 = str1.intern();
String str4 = str1.intern();
System.out.println(str3 == str4); // true

對於 str3 來說,str1.intern() 會先在 String Pool 中查看是否已經存在一個字符串和 str1 的值相等,沒有,於是,在 String Pool 中添加了一個新的值和 str1 相等的字符串,並返回這個新字符串的引用。

而對於 str4 來說,str1.intern() 在 String Pool 中找到了一個字符串和 str1 的值相等,於是直接返回這個字符串的引用。因此 s3 和 s4 引用的是同一個字符串,也就是說它們的地址相同,所以 str3 == str4 的結果是 true

🚩 總結:

  • String str = "i" 的方式,java 虛擬機會自動將其分配到常量池中;

  • String str = new String(「i」) 則會被分到堆內存中。可通過 intern 方法手動加入常量池

new String(“hello”) 創建了幾個字符串對象

下面這行代碼到底創建了幾個字符串對象?僅僅只在堆中創建了一個?

String str1 = new String("hello"); 

顯然不是。對於 str1 來說,new String("hello") 分兩步走:

  • 首先,”hello” 屬於字符串字面量,因此編譯時期會在 String Pool 中查找有沒有值為 “hello” 的引用,如果沒找到,就在字符串常量池中開闢地址空間創建一個字符串對象,指向這個 “hello” 字符串字面量;
  • 然後,使用 new 的方式又會在堆中創建一個字符串對象。

因此,使用這種方式一共會創建兩個字符串對象(前提是 String Pool 中還沒有 “hello” 字符串對象)。

2. 雙生子:可變的 StringBuffer 和 StringBuilder

String 字符串拼接問題

有些時候, 需要由較短的字符串構建字符串, 例如, 按鍵或來自文件中的單詞。採用字符串拼接的方式達到此目的效率比較低。由於 String 類的對象內容不可改變,所以每當進行字符串拼接時,總是會在內存中創建一個新的對象。既耗時, 又浪費空間。例如:

String s = "Hello";
s += "World";

這段簡單的代碼其實總共產生了三個字符串,即 "Hello""World""HelloWorld"。”Hello” 和 “World” 作為字符串常量會在 String Pool 中被創建,而拼接操作 + 會 new 一個對象用來存放 “HelloWorld”。

使用 StringBuilder/ StringBuffer 類就可以避免這個問題的發生,畢竟 String 的 + 操作底層都是由 StringBuilder 實現的。StringBuilderStringBuffer 擁有相同的父類:

但是,StringBuilder 不是線程安全的,在多線程環境下使用會出現數據不一致的問題,而 StringBuffer 是線程安全的。這是因為在 StringBuffer 類內,常用的方法都使用了synchronized 關鍵字進行同步,所以是線程安全的。而 StringBuilder 並沒有。這也是運行速度 StringBuilder 大於 StringBuffer 的原因了。因此,如果在單線程下,優先考慮使用 StringBuilder

初始化操作

StringBuilderStringBuffer 這兩個類的 API 是相同的,這裡就以 StringBuilder 為例演示其初始化操作。

StringBuiler/StringBuffer 不能像 String 那樣直接用字符串賦值,所以也不能那樣初始化。它需要通過構造方法來初始化。首先, 構建一個空的字符串構建器:

StringBuilder builder = new StringBuilder();

當每次需要添加一部分內容時, 就調用 append 方法:

char ch = 'a';
builder.append(ch);

String str = "ert"
builder.append(str);

在需要構建字符串 String 時調用 toString 方法, 就能得到一個 String 對象:

String mystr = builder.toString(); // aert

3. String、StringBuffer、StringBuilder 比較

可變性 線程安全
String 不可變 因為不可變,所以是線程安全的
StringBuffer 可變 線程安全的,因為其內部大多數方法都使用 synchronized 進行同步。其效率較低
StringBuilder 可變 不是線程安全的,因為沒有使用 synchronized 進行同步,這也是其效率高於 StringBuffer 的原因。單線程下,優先考慮使用 StringBuilder。

關於 synchronized 保證線程安全的問題,我們後續文章再說。

📚 References

  • 《Java 核心技術 – 卷 1 基礎知識 – 第 10 版》
  • 《Thinking In Java(Java 編程思想)- 第 4 版》

🎉 關注公眾號 | 飛天小牛肉,即時獲取更新

  • 博主東南大學碩士在讀,利用課餘時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專註分享計算機基礎(數據結構 + 算法 + 計算機網絡 + 數據庫 + 操作系統 + Linux)、Java 基礎和面試指南的相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。希望大家多多支持哦,和小牛肉一起成長 😃
  • 並推薦個人維護的開源教程類項目: CS-Wiki(Gitee 推薦項目,現已累計 1.5k+ star), 致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習 ~ 😊
  • 如果各位小夥伴春招秋招沒有拿得出手的項目的話,可以參考我寫的一個項目「開源社區系統 Echo」Gitee 官方推薦項目,目前已累計 400+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + … 並提供詳細的開發文檔和配套教程。公眾號後台回復 Echo 可以獲取配套教程,目前尚在更新中。