計算機程式的思維邏輯 (30) – 剖析StringBuilder

swiftma發表於2019-02-25

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (30) – 剖析StringBuilder

上節介紹了String,提到如果字串修改操作比較頻繁,應該採用StringBuilder和StringBuffer類,這兩個類的方法基本是完全一樣的,它們的實現程式碼也幾乎一樣,唯一的不同就在於,StringBuffer是執行緒安全的,而StringBuilder不是。

執行緒以及執行緒安全的概念,我們在後續章節再詳細介紹。這裡需要知道的就是,執行緒安全是有成本的,影響效能,而字串物件及操作,大部分情況下,沒有執行緒安全的問題,適合使用StringBuilder。所以,本節就只討論StringBuilder。

StringBuilder的基本用法也是很簡單的,我們來看下。

基本用法

建立StringBuilder

StringBuilder sb = new StringBuilder();
複製程式碼

新增字串,通過append方法

sb.append("老馬說程式設計");
sb.append(",探索程式設計本質");
複製程式碼

獲取構建後的字串,通過toString方法

System.out.println(sb.toString());
複製程式碼

輸出為:

老馬說程式設計,探索程式設計本質
複製程式碼

大部分情況,使用就這麼簡單,通過new新建StringBuilder,通過append新增字串,然後通過toString獲取構建完成的字串。

StringBuilder是怎麼實現的呢?

基本實現原理

內部組成和構造方法

與String類似,StringBuilder類也封裝了一個字元陣列,定義如下:

char[] value;
複製程式碼

與String不同,它不是final的,可以修改。另外,與String不同,字元陣列中不一定所有位置都已經被使用,它有一個例項變數,表示陣列中已經使用的字元個數,定義如下:

int count;
複製程式碼

StringBuilder繼承自AbstractStringBuilder,它的預設構造方法是:

public StringBuilder() {
    super(16);
}
複製程式碼

呼叫父類的構造方法,父類對應的構造方法是:

AbstractStringBuilder(int capacity) {
    value = new char[capacity];
}
複製程式碼

也就是說,new StringBuilder()這句程式碼,內部會建立一個長度為16的字元陣列,count的預設值為0。

append的實現

來看append的程式碼:

public AbstractStringBuilder append(String str) {
    if (str == null) str = "null";
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}
複製程式碼

append會直接拷貝字元到內部的字元陣列中,如果字元陣列長度不夠,會進行擴充套件,實際使用的長度用count體現。具體來說,ensureCapacityInternal(count+len)會確保陣列的長度足以容納新新增的字元,str.getChars會拷貝新新增的字元到字元陣列中,count+=len會增加實際使用的長度。

ensureCapacityInternal的程式碼如下:(如果用掘金app看,可能會有亂碼,是掘金bug,可以通過掘金PC版檢視,或者關注我的微信公眾號”老馬說程式設計”檢視)

private void ensureCapacityInternal(int minimumCapacity) {
    // overflow-conscious code
    if (minimumCapacity - value.length > 0)
        expandCapacity(minimumCapacity);
}
複製程式碼

如果字元陣列的長度小於需要的長度,則呼叫expandCapacity進行擴充套件,expandCapacity的程式碼是:

void expandCapacity(int minimumCapacity) {
    int newCapacity = value.length * 2 + 2;
    if (newCapacity - minimumCapacity < 0)
        newCapacity = minimumCapacity;
    if (newCapacity < 0) {
        if (minimumCapacity < 0) // overflow
            throw new OutOfMemoryError();
        newCapacity = Integer.MAX_VALUE;
    }
    value = Arrays.copyOf(value, newCapacity);
}
複製程式碼

擴充套件的邏輯是,分配一個足夠長度的新陣列,然後將原內容拷貝到這個新陣列中,最後讓內部的字元陣列指向這個新陣列,這個邏輯主要靠下面這句程式碼實現:

value = Arrays.copyOf(value, newCapacity);
複製程式碼

下節我們討論Arrays類,本節就不介紹了,我們主要看下newCapacity是怎麼算出來的。

引數minimumCapacity表示需要的最小長度,需要多少分配多少不就行了嗎?不行,因為那就跟String一樣了,每append一次,都會進行一次記憶體分配,效率低下。這裡的擴充套件策略,是跟當前長度相關的,當前長度乘以2,再加上2,如果這個長度不夠最小需要的長度,才用minimumCapacity。

比如說,預設長度為16,長度不夠時,會先擴充套件到16*2+2即34,然後擴充套件到34*2+2即70,然後是70*2+2即142,這是一種指數擴充套件策略。為什麼要加2?大概是因為在原長度為0時也可以一樣工作吧。

為什麼要這麼擴充套件呢?這是一種折中策略,一方面要減少記憶體分配的次數,另一方面也要避免空間浪費。在不知道最終需要多長的情況下,指數擴充套件是一種常見的策略,廣泛應用於各種記憶體分配相關的計算機程式中。

那如果預先就知道大概需要多長呢?可以呼叫StringBuilder的另外一個構造方法:

public StringBuilder(int capacity)
複製程式碼

toString實現

字串構建完後,我們來看toString程式碼:

public String toString() {
    // Create a copy, don`t share the array
    return new String(value, 0, count);
}
複製程式碼

基於內部陣列新建了一個String,注意,這個String構造方法不會直接用value陣列,而會新建一個,以保證String的不可變性。

更多構造方法和append方法

String還有兩個構造方法,分別接受String和CharSequence引數,它們的程式碼分別如下:

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

public StringBuilder(CharSequence seq) {
    this(seq.length() + 16);
    append(seq);
}
複製程式碼

邏輯也很簡單,額外多分配16個字元的空間,然後呼叫append將引數字元新增進來。

append有多種過載形式,可以接受各種型別的引數,將它們轉換為字元,新增進來,這些過載方法有:

public StringBuilder append(boolean b)
public StringBuilder append(char c)
public StringBuilder append(double d)
public StringBuilder append(float f)
public StringBuilder append(int i)
public StringBuilder append(long lng)
public StringBuilder append(char[] str)
public StringBuilder append(char[] str, int offset, int len)
public StringBuilder append(Object obj)
public StringBuilder append(StringBuffer sb)
public StringBuilder append(CharSequence s)
public StringBuilder append(CharSequence s, int start, int end)
複製程式碼

具體實現比較直接,就不贅述了。

還有一個append方法,可以新增一個Code Point:

public StringBuilder appendCodePoint(int codePoint) 
複製程式碼

如果codePoint為BMP字元,則新增一個char,否則新增兩個char。如果不清楚Code Point的概念,請參見剖析包裝類 (下)

其他修改方法

除了append, StringBuilder還有一些其他修改方法,我們來看下。

插入

public StringBuilder insert(int offset, String str)
複製程式碼

在指定索引offset處插入字串str,原來的字元後移,offset為0表示在開頭插,為length()表示在結尾插,比如說:

StringBuilder sb = new StringBuilder();
sb.append("老馬說程式設計");
sb.insert(0, "關注");
sb.insert(sb.length(), "老馬和你一起探索程式設計本質");
sb.insert(7, ",");
System.out.println(sb.toString());
複製程式碼

輸出為

關注老馬說程式設計,老馬和你一起探索程式設計本質
複製程式碼

來看下insert的實現程式碼:

public AbstractStringBuilder insert(int offset, String str) {
    if ((offset < 0) || (offset > length()))
        throw new StringIndexOutOfBoundsException(offset);
    if (str == null)
        str = "null";
    int len = str.length();
    ensureCapacityInternal(count + len);
    System.arraycopy(value, offset, value, offset + len, count - offset);
    str.getChars(value, offset);
    count += len;
    return this;
}
複製程式碼

這個實現思路是,在確保有足夠長度後,首先將原陣列中offset開始的內容向後挪動n個位置,n為待插入字串的長度,然後將待插入字串拷貝進offset位置。

挪動位置呼叫了System.arraycopy方法,這是個比較常用的方法,它的宣告如下:

public static native void arraycopy(Object src,  int  srcPos,
                                        Object dest, int destPos,
                                        int length);
複製程式碼

將陣列src中srcPos開始的length個元素拷貝到陣列dest中destPos處。這個方法有個優點,即使src和dest是同一個陣列,它也可以正確的處理,比如說,看下面程式碼:

int[] arr = new int[]{1,2,3,4};
System.arraycopy(arr, 1, arr, 0, 3);
System.out.println(arr[0]+","+arr[1]+","+arr[2]);
複製程式碼

這裡,src和dest都是arr,srcPos為1,destPos為0,length為3,表示將第二個元素開始的三個元素移到開頭,所以輸出為:

2,3,4
複製程式碼

arraycopy的宣告有個修飾符native,表示它的實現是通過Java本地介面實現的,Java本地介面是Java提供的一種技術,用於在Java中呼叫非Java語言實現的程式碼,實際上,arraycopy是用C++語言實現的。為什麼要用C++語言實現呢?因為這個功能非常常用,而C++的實現效率要遠高於Java。

其他插入方法

與append類似,insert也有很多過載的方法,如下列舉一二

public StringBuilder insert(int offset, double d)
public StringBuilder insert(int offset, Object obj)
複製程式碼

刪除

刪除指定範圍內的字元

public StringBuilder delete(int start, int end) 
複製程式碼

其實現程式碼為:

public AbstractStringBuilder delete(int start, int end) {
    if (start < 0)
        throw new StringIndexOutOfBoundsException(start);
    if (end > count)
        end = count;
    if (start > end)
        throw new StringIndexOutOfBoundsException();
    int len = end - start;
    if (len > 0) {
        System.arraycopy(value, start+len, value, start, count-end);
        count -= len;
    }
    return this;
}
複製程式碼

也是通過System.arraycopy實現的,System.arraycopy被大量應用於StringBuilder的內部實現中,後文就不再贅述了。

刪除一個字元

public StringBuilder deleteCharAt(int index)
複製程式碼

替換

public StringBuilder replace(int start, int end, String str)
複製程式碼

StringBuilder sb = new StringBuilder();
sb.append("老馬說程式設計");
sb.replace(3, 5, "Java");
System.out.println(sb.toString());
複製程式碼

程式輸出為:

老馬說Java
複製程式碼

替換一個字元

public void setCharAt(int index, char ch)
複製程式碼

翻轉字串

public StringBuilder reverse()
複製程式碼

這個方法不只是簡單的翻轉陣列中的char,對於增補字元,簡單翻轉後字元就無效了,這個方法能保證其字元依然有效,這是通過單獨檢查增補字元,進行二次翻轉實現的。比如說:

StringBuilder sb = new StringBuilder();
sb.append("a");
sb.appendCodePoint(0x2F81A);//增補字元:?
sb.append("b");
sb.reverse();
System.out.println(sb.toString()); 
複製程式碼

即使內含增補字元”?”,輸出也是正確的,為:

b?a 
複製程式碼

長度方法

StringBuilder中有一些與長度有關的方法

確保字元陣列長度不小於給定值

public void ensureCapacity(int minimumCapacity)
複製程式碼

返回字元陣列的長度

public int capacity() 
複製程式碼

返回陣列實際使用的長度

public int length()
複製程式碼

注意capacity()方法與length()方法的的區別,capacity返回的是value陣列的長度,length返回的是實際使用的字元個數,是count例項變數的值。

直接修改長度

public void setLength(int newLength) 
複製程式碼

程式碼為:

public void setLength(int newLength) {
    if (newLength < 0)
        throw new StringIndexOutOfBoundsException(newLength);
    ensureCapacityInternal(newLength);

    if (count < newLength) {
        for (; count < newLength; count++)
            value[count] = ` `;
    } else {
        count = newLength;
    }
}
複製程式碼

count設為newLength,如果原count小於newLength,則多出來的字元設定預設值為` `。

縮減使用的空間

public void trimToSize()
複製程式碼

程式碼為:

public void trimToSize() {
    if (count < value.length) {
        value = Arrays.copyOf(value, count);
    }
}
複製程式碼

減少value佔用的空間,新建了一個剛好夠用的空間。

與String類似的方法

StringBuilder中也有一些與String類似的方法,如:

查詢子字串

public int indexOf(String str)
public int indexOf(String str, int fromIndex)
public int lastIndexOf(String str)
public int lastIndexOf(String str, int fromIndex) 
複製程式碼

取子字串

public String substring(int start)
public String substring(int start, int end)
public CharSequence subSequence(int start, int end)
複製程式碼

獲取其中的字元或Code Point

public char charAt(int index)
public int codePointAt(int index)
public int codePointBefore(int index)
public int codePointCount(int beginIndex, int endIndex)
public void getChars(int srcBegin, int srcEnd, char[] dst, int dstBegin)
複製程式碼

以上這些方法與String中的基本一樣,本節就不再贅述了。

String的+和+=運算子

Java中,String可以直接使用+和+=運算子,這是Java編譯器提供的支援,背後,Java編譯器會生成StringBuilder,+和+=操作會轉換為append。比如說,如下程式碼:

String hello = "hello";
hello+=",world";
System.out.println(hello);
複製程式碼

背後,Java編譯器會轉換為:

StringBuilder hello = new StringBuilder("hello");
hello.append(",world");
System.out.println(hello.toString());
複製程式碼

既然直接使用+和+=就相當於使用StringBuilder和append,那還有什麼必要直接使用StringBuilder呢?在簡單的情況下,確實沒必要。不過,在稍微複雜的情況下,Java編譯器沒有那麼智慧,它可能會生成很多StringBuilder,尤其是在有迴圈的情況下,比如說,如下程式碼:

String hello = "hello";
for(int i=0;i<3;i++){
    hello+=",world";    
}
System.out.println(hello);
複製程式碼

Java編譯器轉換後的程式碼大概如下所示:

String hello = "hello";
for(int i=0;i<3;i++){
    StringBuilder sb = new StringBuilder(hello);
    sb.append(",world");
    hello = sb.toString();
}
System.out.println(hello);
複製程式碼

在迴圈內部,每一次+=操作,都會生成一個StringBuilder。

所以,結論是,對於簡單的情況,可以直接使用String的+和+=,對於複雜的情況,尤其是有迴圈的時候,應該直接使用StringBuilder。

小結

本節介紹了StringBuilder,介紹了其用法,實現原理,陣列長度擴充套件策略,以及String的+和+=操作符的實現原理。

字串操作是計算機程式中最常見的操作,理解了String和StringBuilder的用法及實現原理,我們就對字串操作建立了一個堅實的基礎。

上節和本節,我們都提到了一個類Arrays,它包括很多陣列相關的方法,陣列操作也是非常常見的操作,讓我們下節來詳細討論。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (30) – 剖析StringBuilder

相關文章