為什麼StringBuilder是執行緒不安全的?

2020-09-22 12:00:46

在前面的面試題講解中我們對比了String、StringBuilder和StringBuffer的區別,其中一項便提到StringBuilder是非執行緒安全的,那麼是什麼原因導致了StringBuilder的執行緒不安全呢?

原因分析

如果你看了StringBuilder或StringBuffer的原始碼會說,因為StringBuilder在append操作時並未使用執行緒同步,而StringBuffer幾乎大部分方法都使用了synchronized關鍵字進行方法級別的同步處理。

上面這種說法肯定是正確的,對照一下StringBuilder和StringBuffer的部分原始碼也能夠看出來。

StringBuilder的append方法原始碼:

@Override
public StringBuilder append(String str) {
    super.append(str);
    return this;
}

StringBuffer的append方法原始碼:

@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

對於上面的結論肯定是沒什麼問題的,但並沒有解釋是什麼原因導致了StringBuilder的執行緒不安全?為什麼要使用synchronized來保證執行緒安全?如果不是用會出現什麼異常情況?

下面我們來逐一講解。

異常範例

我們先來跑一段程式碼範例,看看出現的結果是否與我們的預期一致。

@Test
public void test() throws InterruptedException {
	StringBuilder sb = new StringBuilder();
	for (int i = 0; i < 10; i++) {
		new Thread(() -> {
			for (int j = 0; j < 1000; j++) {
				sb.append("a");
			}
		}).start();
	}
	// 睡眠確保所有執行緒都執行完
	Thread.sleep(1000);
	System.out.println(sb.length());
}

上述業務邏輯比較簡單,就是構建一個StringBuilder,然後建立10個執行緒,每個執行緒中拼接字串「a」1000次,理論上當執行緒執行完成之後,列印的結果應該是10000才對。

但多次執行上面的程式碼列印的結果是10000的概率反而非常小,大多數情況都要少於10000。同時,還有一定的概率出現下面的異常資訊「

Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException
	at java.lang.System.arraycopy(Native Method)
	at java.lang.String.getChars(String.java:826)
	at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:449)
	at java.lang.StringBuilder.append(StringBuilder.java:136)
	at com.secbro2.strings.StringBuilderTest.lambda$test$0(StringBuilderTest.java:18)
	at java.lang.Thread.run(Thread.java:748)
9007

執行緒不安全的原因

StringBuilder中針對字串的處理主要依賴兩個成員變數char陣列value和count。StringBuilder通過對value的不斷擴容和count對應的增加來完成字串的append操作。

// 儲存的字串(通常情況一部分為字串內容,一部分為預設值)
char[] value;

// 陣列已經使用數量
int count;

上面的這兩個屬性均位於它的抽象父類別AbstractStringBuilder中。

如果檢視構造方法我們會發現,在建立StringBuilder時會設定陣列value的初始化長度。

public StringBuilder(String str) {
    super(str.length() + 16);
    append(str);
}

預設是傳入字串長度加16。這就是count存在的意義,因為陣列中的一部分內容為預設值。

當呼叫append方法時會對count進行增加,增加值便是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;
}

我們所說的執行緒不安全的發生點便是在append方法中count的「+=」操作。我們知道該操作是執行緒不安全的,那麼便會發生兩個執行緒同時讀取到count值為5,執行加1操作之後,都變成6,而不是預期的7。這種情況一旦發生便不會出現預期的結果。

拋異常的原因

回頭看異常的堆疊資訊,回發現有這麼一行內容:

at java.lang.String.getChars(String.java:826)

對應的程式碼就是上面AbstractStringBuilder中append方法中的程式碼。對應方法的原始碼如下:

public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
    if (srcBegin < 0) {
        throw new StringIndexOutOfBoundsException(srcBegin);
    }
    if (srcEnd > value.length) {
        throw new StringIndexOutOfBoundsException(srcEnd);
    }
    if (srcBegin > srcEnd) {
        throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
    }
    System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}

其實異常是最後一行arraycopy時JVM底層發生的。arraycopy的核心操作就是將傳入的String物件copy到value當中。

而異常發生的原因是明明value的下標只到6,程式卻要存取和操作下標為7的位置,當然就跑異常了。

那麼,為什麼會超出這麼一個位置呢?這與我們上面講到到的count被少加有關。在執行str.getChars方法之前還需要根據count校驗一下當前的value是否使用完畢,如果使用完了,那麼就進行擴容。append中對應的方法如下:

ensureCapacityInternal(count + len);

ensureCapacityInternal的具體實現:

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

count本應該為7,value長度為6,本應該觸發擴容。但因為並行導致count為6,假設len為1,則傳遞的minimumCapacity為7,並不會進行擴容操作。這就導致後面執行str.getChars方法進行復制操作時存取了不存在的位置,因此丟擲異常。

這裡我們順便看一下擴容方法中的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。把計算所得的新長度作為Arrays.copyOf的引數進行擴容。

小結

經過上面的分析,是不是真正瞭解了StringBuilder的執行緒不安全的原因?我們在學習和實踐的過程中,不僅要知道一些結論,還要知道這些結論的底層原理,更重要的是學會分析底層原理的方法。

原文連結:《為什麼StringBuilder是執行緒不安全的?


程式新視界

公眾號「 程式新視界」,一個讓你軟實力、硬技術同步提升的平臺,提供海量資料

微信公眾號:程式新視界

二師兄-公眾號-程式新視界 CSDN認證部落格專家 Spring 架構 Java
個人公眾號【程式新視界】,一個硬技術,軟實力同步提升的平臺。《Spring Boo技術內幕:架構設計與實現原理》作者,《深入以太坊智慧合約開發》聯合作者之一。主要從事於三方支付行業。