【扯皮系列】一篇與眾不同的 String、StringBuilder 和 StringBuffer 詳解

程式設計師cxuan發表於2020-06-06

碎碎念

這是一道老生常談的問題了,字串是不僅是 Java 中非常重要的一個物件,它在其他語言中也存在。比如 C++、Visual Basic、C# 等。字串使用 String 來表示,字串一旦被建立出來就不會被修改,當你想修改 StringBuffer 或者是 StringBuilder,出於效率的考量,雖然 String 可以通過 + 來建立多個物件達到字串拼接的效果,但是這種拼接的效率相比 StringBuffer 和 StringBuilder,那就是心有餘而力不足了。本篇文章我們一起來深入瞭解一下這三個物件。

簡單認識這三個物件

String

String 表示的就是 Java 中的字串,我們日常開發用到的使用 "" 雙引號包圍的數都是字串的例項。String 類其實是通過 char 陣列來儲存字串的。下面是一個典型的字串的宣告

String s = "abc";

上面你建立了一個名為 abc 的字串。

字串是恆定的,一旦建立出來就不會被修改,怎麼理解這句話?我們可以看下 String 原始碼的宣告

告訴我你看到了什麼?String 物件是由final 修飾的,一旦使用 final 修飾的類不能被繼承、方法不能被重寫、屬性不能被修改。而且 String 不只只有類是 final 的,它其中的方法也是由 final 修飾的,換句話說,Sring 類就是一個典型的 Immutable 類。也由於 String ?的不可變性,類似字串拼接、字串擷取等操作都會產生新的 Strign 物件。

所以請你告訴我下面

String s1 = "aaa";
String s2 = "bbb" + "ccc";
String s3 = s1 + "bbb";
String s4 = new String("aaa");

分別建立了幾個物件?

  • 首先第一個問題,s1 建立了幾個物件。字串在建立物件時,會在常量池中看有沒有 aaa 這個字串;如果沒有此時還會在常量池中建立一個;如果有則不建立。我們預設是沒有的情況,所以會建立一個物件。下同。
  • 那麼 s2 建立了幾個物件呢?是兩個物件還是一個物件?我們可以使用 javap -c 看一下反彙編程式碼
public class com.sendmessage.api.StringDemo {
  public com.sendmessage.api.StringDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // 執行物件的初始化方法
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // 將 String aaa 執行入棧操作
       2: astore_1													# pop出棧引用值,將其(引用)賦值給區域性變數表中的變數 s1
       3: ldc           #3                  // String bbbccc
       5: astore_2
       6: return
}

編譯器做了優化 String s2 = "bbb" + "ccc" 會直接被優化為 bbbccc。也就是直接建立了一個 bbbccc 物件。

javap 是 jdk 自帶的反彙編工具。它的作用就是根據 class 位元組碼檔案,反彙編出當前類對應的 code 區(彙編指令)、本地變數表、異常表和程式碼行偏移量對映表、常量池等等資訊。

javap -c 就是對程式碼進行反彙編操作。

  • 下面來看 s3,s3 建立了幾個物件呢?是一個還是兩個?還是有其他選項?我們使用 javap -c 來看一下

我們可以看到,s3 執行 + 操作會建立一個 StringBuilder 物件然後執行初始化。執行 + 號相當於是執行 new StringBuilder.append() 操作。所以

String s3 = s1 + "bbb";

==
  
String s3 = new StringBuilder().append(s1).append("bbb").toString();

// Stringbuilder.toString() 方法也會建立一個 String 

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

所以 s3 執行完成後,相當於建立了 3 個物件。

  • 下面來看 s4 建立了幾個物件,在建立這個物件時因為使用了 new 關鍵字,所以肯定會在堆中建立一個物件。然後會在常量池中看有沒有 aaa 這個字串;如果沒有此時還會在常量池中建立一個;如果有則不建立。所以可能是建立一個或者兩個物件,但是一定存在兩個物件。

說完了 String 物件,我們再來說一下 StringBuilder 和 StringBuffer 物件。

上面的 String 物件竟然和 StringBuilder 產生了千絲萬縷的聯絡。不得不說 StringBuilder 是一個牛逼的物件。String 物件底層是使用了 StringBuilder 物件的 append 方法進行字串拼接的,不由得對 StringBuilder 心生敬意。

不由得我們想要真正認識一下這個 StringBuilder 大佬,但是在認識大佬前,還有一個大 boss 就是 StringBuffer 物件,這也是你不得不跨越的鴻溝。

StringBuffer

StringBuffer 物件 代表一個可變的字串序列,當一個 StringBuffer 被建立以後,通過 StringBuffer 的一系列方法可以實現字串的拼接、擷取等操作。一旦通過 StringBuffer 生成了最終想要的字串後,就可以呼叫其 toString 方法來生成一個新的字串。例如

StringBuffer b = new StringBuffer("111");
b.append("222");
System.out.println(b);

我們上面提到 + 操作符連線兩個字串,會自動執行 toString() 方法。那你猜 StringBuffer.append 方法會自動呼叫嗎?直接看一下反彙編程式碼不就完了麼?

上圖左邊是手動呼叫 toString 方法的程式碼,右圖是沒有呼叫 toString 方法的程式碼,可以看到,toString() 方法不像 + 一樣自動被呼叫。

StringBuffer 是執行緒安全的,我們可以通過它的原始碼可以看出

StringBuffer 在字串拼接上面直接使用 synchronized 關鍵字加鎖,從而保證了執行緒安全性。

StringBuilder

最後來認識大佬了,StringBuilder 其實是和 StringBuffer 幾乎一樣,只不過 StringBuilder 是非執行緒安全的。並且,為什麼 + 號操作符使用 StringBuilder 作為拼接條件而不是使用 StringBuffer 呢?我猜測原因是加鎖是一個比較耗時的操作,而加鎖會影響效能,所以 String 底層使用 StringBuilder 作為字串拼接。

深入理解 String、StringBuilder、StringBuffer

我們上面說到,使用 + 連線符時,JVM 會隱式建立 StringBuilder 物件,這種方式在大部分情況下並不會造成效率的損失,不過在進行大量迴圈拼接字串時則需要注意。如下這段程式碼

String s = "aaaa";
for (int i = 0; i < 100000; i++) {
    s += "bbb";
}

這是一段很普通的程式碼,只不過對字串 s 進行了 + 操作,我們通過反編譯程式碼來看一下。

// 經過反編譯後
String s = "aaa";
for(int i = 0; i < 10000; i++) {
     s = (new StringBuilder()).append(s).append("bbb").toString();    
}

你能看出來需要注意的地方了嗎?在每次進行迴圈時,都會建立一個 StringBuilder 物件,每次都會把一個新的字串元素 bbb 拼接到 aaa 的後面,所以,執行幾次後的結果如下

每次都會建立一個 StringBuilder ,並把引用賦給 StringBuilder 物件,因此每個 StringBuilder 物件都是強引用, 這樣在建立完畢後,記憶體中就會多了很多 StringBuilder 的無用物件。瞭解更多關於引用的知識,請看

https://mp.weixin.qq.com/s/ZflBpn2TBzTNv_-G-zZxNg

這樣由於大量 StringBuilder 建立在堆記憶體中,肯定會造成效率的損失,所以在這種情況下建議在迴圈體外建立一個 StringBuilder 物件呼叫 append() 方法手動拼接。

例如

StringBuilder builder = new StringBuilder("aaa");
for (int i = 0; i < 10000; i++) {
    builder.append("bbb");
}
builder.toString();

這段程式碼中,只會建立一個 builder 物件,每次迴圈都會使用這個 builder 物件進行拼接,因此提高了拼接效率。

從設計角度理解

我們前面說過,String 類是典型的 Immutable 不可變類實現,保證了執行緒安全性,所有對 String 字串的修改都會構造出一個新的 String 物件,由於 String 的不可變性,不可變物件在拷貝時不需要額外的複製資料。

String 在 JDK1.6 之後提供了 intern() 方法,intern 方法是一個 native 方法,它底層由 C/C++ 實現,intern 方法的目的就是為了把字串快取起來,在 JDK1.6 中卻不推薦使用 intern 方法,因為 JDK1.6 把方法區放到了永久代(Java 堆的一部分),永久代的空間是有限的,除了 Fullgc 外,其他收集並不會釋放永久代的儲存空間。JDK1.7 將字串常量池移到了堆記憶體 中,

下面我們來看一段程式碼,來認識一下 intern 方法

public static void main(String[] args) {

  String a = new String("ab");
  String b = new String("ab");
  String c = "ab";
  String d = "a";
  String e = new String("b");
  String f = d + e;

  System.out.println(a.intern() == b);
  System.out.println(a.intern() == b.intern());
  System.out.println(a.intern() == c);
  System.out.println(a.intern() == f);

}

上述的執行結果是什麼呢?我們先把答案貼出來,以防心急的同學想急於看到結果,他們的答案是

false
true
true
false

和你預想的一樣嗎?為什麼會這樣呢?我們先來看一下 intern 方法的官方解釋

這裡你需要知道 JVM 的記憶體模型

  • 虛擬機器棧 : Java 虛擬機器棧是執行緒私有的資料區,Java 虛擬機器棧的生命週期與執行緒相同,虛擬機器棧也是區域性變數的儲存位置。方法在執行過程中,會在虛擬機器棧種建立一個 棧幀(stack frame)
  • 本地方法棧: 本地方法棧也是執行緒私有的資料區,本地方法棧儲存的區域主要是 Java 中使用 native 關鍵字修飾的方法所儲存的區域
  • 程式計數器:程式計數器也是執行緒私有的資料區,這部分割槽域用於儲存執行緒的指令地址,用於判斷執行緒的分支、迴圈、跳轉、異常、執行緒切換和恢復等功能,這些都通過程式計數器來完成。
  • 方法區:方法區是各個執行緒共享的記憶體區域,它用於儲存虛擬機器載入的 類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。
  • : 堆是執行緒共享的資料區,堆是 JVM 中最大的一塊儲存區域,所有的物件例項都會分配在堆上
  • 執行時常量池:執行時常量池又被稱為 Runtime Constant Pool,這塊區域是方法區的一部分,它的名字非常有意思,它並不要求常量一定只有在編譯期才能產生,也就是並非編譯期間將常量放在常量池中,執行期間也可以將新的常量放入常量池中,String 的 intern 方法就是一個典型的例子。

在 JDK 1.6 及之前的版本中,常量池是分配在方法區中永久代(Parmanent Generation)內的,而永久代和 Java 堆是兩個完全分開的區域。如果字串常量池中已經包含一個等於此 String 物件的字串,則返回常量池中這個字串的 String 物件;否則,將此 String 物件包含的字串新增到常量池中,並且返回此 String 物件的引用。

一些人把方法區稱為永久代,這種說法不準確,僅僅是 Hotspot 虛擬機器設計團隊選擇使用永久代來實現方法區而已。

從JDK 1.7開始去永久代,字串常量池已經被轉移至 Java 堆中,開發人員也對 intern 方法做了一些修改。因為字串常量池和 new 的物件都存於 Java 堆中,為了優化效能和減少記憶體開銷,當呼叫 intern 方法時,如果常量池中已經存在該字串,則返回池中字串;否則直接儲存堆中的引用,也就是字串常量池中儲存的是指向堆裡的物件。

所以我們對上面的結論進行分析

String a = new String("ab");
String b = new String("ab");

System.out.println(a.intern() == b);

輸出什麼? false,為什麼呢?畫一張圖你就明白了(圖畫的有些問題,棧應該是後入先出,所以 b 應該在 a 上面,不過不影響效果)

a.intern 返回的是常量池中的 ab,而 b 是直接返回的是堆中的 ab。地址不一樣,肯定輸出 false

所以第二個

System.out.println(a.intern() == b.intern());

也就沒問題了吧,它們都返回的是字串常量池中的 ab,地址相同,所以輸出 true

然後來看第三個

System.out.println(a.intern() == c);

圖示如下

a 不會變,因為常量池中已經有了 ab ,所以 c 不會再建立一個 ab 字串,這是編譯器做的優化,為了提高效率。

下面來看最後一個

System.out.println(a.intern() == f);

String

首先來看一下 String 類在繼承樹的什麼位置、實現了什麼介面、父類是誰,這是原始碼分析的幾大重要因素。

String 沒有繼承任何介面,不過實現了三個介面,分別是 **Serializable、Comparable、CharSequence **介面

  • Serializable :這個序列化介面沒有任何方法和域,僅用於標識序列化的語意。
  • Comparable:實現了 Comparable 的介面可用於內部比較兩個物件的大小
  • CharSequence:字串序列介面,CharSequence 是一個可讀的 char 值序列,提供了 length(), charAt(int index), subSequence(int start, int end) 等介面,StringBuilder 和 StringBuffer 也繼承了這個介面

重要屬性

字串是什麼,字!符!串! 你品,你細品。你會發現它就是一連串字元組成的串。

也就是說

String str = "abc"; 

// === 

char data[] = {'a', 'b', 'c'};
String str = new String(data);

原來這麼回事啊!

所以,String 中有一個用於儲存字元的 char 陣列 value[],這個陣列儲存了每個字元。另外一個就是 hash 屬性,它用於快取字串的雜湊碼。因為 String 經常被用於比較,比如在 HashMap 中。如果每次進行比較都重新計算其 hashcode 的值的話,那無疑是比較麻煩的,而儲存一個 hashcode 的快取無疑能優化這樣的操作。

String 可以通過許多途徑建立,也可以根據 Stringbuffer 和 StringBuilder 進行建立。

畢竟我們本篇文章探討的不是原始碼分析的文章,所以涉及到的原始碼不會很多。

除此之外,String 還提供了一些其他方法

  • charAt :返回指定位置上字元的值

  • getChars: 複製 String 中的字元到指定的陣列

  • equals: 用於判斷 String 物件的值是否相等

  • indexOf : 用於檢索字串

  • substring: 對字串進行擷取

  • concat: 用於字串拼接,效率高於 +

  • replace:用於字串替換

  • match:正規表示式的字串匹配

  • contains: 是否包含指定字元序列

  • split: 字串分割

  • join: 字串拼接

  • trim: 去掉多餘空格

  • toCharArray: 把 String 物件轉換為字元陣列

  • valueOf: 把物件轉換為字串

StringBuilder

StringBuilder 類表示一個可變的字元序列,我們知道,StringBuilder 是非執行緒安全的容器,一般適用於單執行緒場景中的字串拼接操作,下面我們就來從原始碼角度看一下 StringBuilder

首先我們來看一下 StringBuilder 的定義

public final class StringBuilder
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence {...}

StringBuilder 被 final 修飾,表示 StringBuilder 是不可被繼承的,StringBuilder 類繼承於 AbstractStringBuilder類。實際上,AbstractStringBuilder 類具體實現了可變字元序列的一系列操作,比如:append()、insert()、delete()、replace()、charAt() 方法等。

StringBuilder 實現了 2 個介面

  • Serializable 序列化介面,表示物件可以被序列化。
  • CharSequence 字元序列介面,提供了幾個對字元序列進行只讀訪問的方法,例如 ength()、charAt()、subSequence()、toString() 方法等。

StringBuilder 使用 AbstractStringBuilder 類中的兩個變數作為元素

char[] value; // 儲存字元陣列

int count; // 字串使用的計數

StringBuffer

StringBuffer 也是繼承於 AbstractStringBuilder ,使用 value 和 count 分別表示儲存的字元陣列和字串使用的計數,StringBuffer 與 StringBuilder 最大的區別就是 StringBuffer 可以在多執行緒場景下使用,StringBuffer 內部有大部分方法都加了 synchronized 鎖。在單執行緒場景下效率比較低,因為有鎖的開銷。

StringBuilder 和 StringBuffer 的擴容問題

我相信這個問題很多同學都沒有注意到吧,其實 StringBuilder 和 StringBuffer 存在擴容問題,先從 StringBuilder 開始看起

首先先注意一下 StringBuilder 的初始容量

public StringBuilder() {
  super(16);
}

StringBuilder 的初始容量是 16,當然也可以指定 StringBuilder 的初始容量。

在呼叫 append 拼接字串,會呼叫 AbstractStringBuilder 中的 append 方法

public AbstractStringBuilder append(String str) {
  if (str == null)
    return appendNull();
  int len = str.length();
  ensureCapacityInternal(count + len);
  str.getChars(0, len, value, count);
  count += len;
  return this;
}

上面程式碼中有一個 ensureCapacityInternal 方法,這個就是擴容方法,我們跟進去看一下

private void ensureCapacityInternal(int minimumCapacity) {
  // overflow-conscious code
  if (minimumCapacity - value.length > 0) {
    value = Arrays.copyOf(value,
                          newCapacity(minimumCapacity));
  }
}

這個方法會進行判斷,minimumCapacity 就是字元長度 + 要拼接的字串長度,如果拼接後的字串要比當前字元長度大的話,會進行資料的複製,真正擴容的方法是在 newCapacity

private int newCapacity(int minCapacity) {
  // overflow-conscious code
  int newCapacity = (value.length << 1) + 2;
  if (newCapacity - minCapacity < 0) {
    newCapacity = minCapacity;
  }
  return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
    ? hugeCapacity(minCapacity)
    : newCapacity;
}

擴容後的字串長度會是原字串長度增加一倍 + 2,如果擴容後的長度還比拼接後的字串長度小的話,那就直接擴容到它需要的長度 newCapacity = minCapacity,然後再進行陣列的拷貝。

總結

本篇文章主要描述了 String 、StringBuilder 和 StringBuffer 的主要特性,String、StringBuilder 和 StringBuffer 的底層構造是怎樣的,以及 String 常量池的優化、StringBuilder 和 StringBuffer 的擴容特性等。

如果有錯誤的地方,還請大佬們提出寶貴意見。

相關文章