String/StringBuilder字串拼接操作

我啥時候說啦jj發表於2019-04-16

相關demo原始碼;

基於: macOs:10.13/AS:3.3.2/Android build-tools:28.0.0/jdk: 1.8

1. 緣由

這兩天在看 smali, 偶然看到 log 語句中的 String 拼接被優化為了 StringBuilder, 程式碼如下;

// MainActivity.java
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    private static final String TAG = "MainActivity";
    private void methodBoolean(boolean showLog) {
        Log.d(TAG, "methodBoolean: " + showLog);
    }
}
複製程式碼
# 對應的 smali 程式碼
.method private methodBoolean(Z)V
    .locals 3
    .param p1, "showLog"    # Z

    .line 51
    const-string v0, "MainActivity" # 定義 TAG 變數值
    new-instance v1, Ljava/lang/StringBuilder; # 建立了一個 StringBuilder
    invoke-direct {v1}, Ljava/lang/StringBuilder;-><init>()V

    # 定義 Log msg引數中第一部分字串字面量值
    const-string v2, "methodBoolean: "

    # 拼接並輸出 String 存入 v1 暫存器中
    invoke-virtual {v1, v2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    invoke-virtual {v1, p1}, Ljava/lang/StringBuilder;->append(Z)Ljava/lang/StringBuilder;
    invoke-virtual {v1}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
    move-result-object v1

    # 呼叫 Log 方法列印日誌
    invoke-static {v0, v1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
    .line 52
    return-void
.end method
複製程式碼

想起以前根深蒂固的 "大量字串拼接時 StringBuilderString 效能更好" 的說法, 頓時好奇是否真是那樣, 是否所有場景都那樣, 所以想探究下, 簡單起見, 原始碼用 Java 而非 Kotlin 編寫;

2. 測試

既然底層會優化為 StringBuilder 那拼接還會有效率差距嗎? 測試下

public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    /**
     * String迴圈拼接測試
     *
     * @param loop 迴圈次數
     * @param base 拼接字串
     * @return 耗時, 單位: ms
     */
    private long methodForStr(int loop, String base) {
        long startTs = System.currentTimeMillis();
        String result = "";
        for (int i = 0; i < loop; i++) {
            result += base;
        }
        return System.currentTimeMillis() - startTs;
    }

    /**
     * StringBuilder迴圈拼接測試
     */
    @Keep
    private long methodForSb(int loop, String base) {
        long startTs = System.currentTimeMillis();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < loop; i++) {
            sb.append(base);
        }
        String result = sb.toString();
        return System.currentTimeMillis() - startTs;
    }
}
複製程式碼

在三星s8+ 上迴圈拼接 5000 次 smali 字串,得到兩者的耗時大概為 460ms:1ms, 效率差距明顯;

3. smali 迴圈拼接程式碼分析

既然 String 拼接會轉化為 StringBuilder, 理論上來說應該差距不大才對,但實際差距明顯, 猜想可能跟for迴圈有關,我們看下 methodForStr(int loop, String base) 方法的smali程式碼:

.method private methodForStr(ILjava/lang/String;)J
    .locals 5
    .param p1, "loop"    # I 表示引數 loop
    .param p2, "base"    # Ljava/lang/String;

    .line 73
    invoke-static {}, Ljava/lang/System;->currentTimeMillis()J # 獲取迴圈起始時間戳

    move-result-wide v0

    .line 74
    .local v0, "startTs":J # v0表示 區域性變數 startTs ,型別為 long
    const-string v2, ""

    .line 75
    .local v2, "result":Ljava/lang/String; # v2 表示區域性變數 result
    const/4 v3, 0x0 # 定義for迴圈變數 i 的初始化

    .local v3, "i":I
    :goto_0  # for迴圈體起始處
    if-ge v3, p1, :cond_0  # 若 i >= loop 值,則跳轉到 cond_0 標籤處,退出迴圈,否則繼續執行下面的程式碼

    # 以下為for迴圈體邏輯:
    # 1. 建立 StringBuilder 物件
    # 2. 拼接 result + base 字串, 然後通過 toString() 得到拼接結果
    # 3. 將結果再賦值給 result 變數
    # 4. 進入下一輪迴圈
    .line 76
    new-instance v4, Ljava/lang/StringBuilder;
    invoke-direct {v4}, Ljava/lang/StringBuilder;-><init>()V

    invoke-virtual {v4, v2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    invoke-virtual {v4, p2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    invoke-virtual {v4}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object v2

    # for 迴圈變數i自加1,然後進行下一輪迴圈
    .line 75
    add-int/lit8 v3, v3, 0x1 #  將第二個暫存器v3中的值加上0x1,然後放入第一個暫存器v3中, 實現自增長

    goto :goto_0 # 跳轉到 goto_0 標籤,即: 重新計算迴圈條件, 執行迴圈體

    .line 78
    .end local v3    # "i":I
    :cond_0 # 定義標籤 cond_0

    # 迴圈結束後,獲取當前時間戳, 並計算耗時
    invoke-static {}, Ljava/lang/System;->currentTimeMillis()J
    move-result-wide v3
    sub-long/2addr v3, v0

    return-wide v3
.end method
複製程式碼

根據上面的 smali 程式碼,可以逆推出其原始碼應該為:

private long methodForStr(int loop, String base) {
    long startTs = System.currentTimeMillis();
    String result = "";
    for (int i = 0; i < loop; i++) {
        // 每次都在迴圈體中將 String 的拼接改成了 StringBuilder
        // 這算是負優化嗎?
        StringBuilder sb = new StringBuilder();
        sb.append(result);
        sb.append(base);
        result = sb.toString();
    }
    return System.currentTimeMillis() - startTs;
}
複製程式碼

4. 原始碼分析

4.1 String.java

/*
 * Strings are constant; their values cannot be changed after they
 * are created. String buffers support mutable strings.
 * Because String objects are immutable they can be shared
 * */
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
        // String實際也是char陣列,但由於其用private final修飾,所以不可變(當然,還有其他措施共同保證"不可變")
        private final char value[];
    }
複製程式碼

類註釋描述了其為 immutable ,每個字面量都是一個物件,修改string時,不會在原記憶體處進行修改,而是重新指向一個新物件:

String str = "a"; // String物件 "a"
str = "a" + "a"; // String物件 "aa"
複製程式碼

每次進行 + 運算時,都會生成一個新的 String 物件:

string追加

// 結合第3部分的smali分析,可以發現:
// 每次for迴圈體中,都會建立一個 `StringBuilder`物件,並生成拼接結果的 `String` 物件;
private long methodForStr(int loop, String base) {
    long startTs = System.currentTimeMillis();
    String result = "";
    for (int i = 0; i < loop; i++) {
        result += base;
    }
    return System.currentTimeMillis() - startTs;
}
複製程式碼

在迴圈體中頻繁的建立物件,還會導致大量物件被廢棄,觸發GC,頻繁 stop the world 自然也會導致拼接耗時加長, 如下圖:

string拼接gc

4.2 StringBuilder.java

/**
 * A mutable sequence of characters.  This class provides an API compatible
 * with {@code StringBuffer}, but with no guarantee of synchronization.
 * */
public final class StringBuilder
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence{}

// StringBuilder 的類註釋指明瞭其實際為一個可變字元陣列, 核心邏輯其實都實現在 AbstractStringBuilder 中了
// 我們看下 stringBuilder.append("str") 是怎麼實現的
abstract class AbstractStringBuilder implements Appendable, CharSequence {
    char[] value; // 用於實際儲存字串對應的字元序列
    int count; // 已儲存的字元個數

    AbstractStringBuilder() {
    }

    // 提供一個合理的初始化容量大小, 有助於減小擴容次數,提高效率
    AbstractStringBuilder(int capacity) {
        value = new char[capacity];
    }

    @Override
    public AbstractStringBuilder append(CharSequence s) {
        if (s == null)
            return appendNull();
        if (s instanceof String)
            return this.append((String)s);
        if (s instanceof AbstractStringBuilder)
            return this.append((AbstractStringBuilder)s);

        return this.append(s, 0, s.length());
    }

    public AbstractStringBuilder append(String str) {
        if (str == null)
            return appendNull();
        int len = str.length();
        ensureCapacityInternal(count + len); // 確保value陣列有足夠的空間可以儲存變數str的所有字元
        str.getChars(0, len, value, count); // 提取變數str中的所有字元,並追加複製到value陣列的最後
        count += len;
        return this;
    }

    // 如果當前value陣列容量不夠,進行自動擴容: 建立新陣列,並複製原陣列資料
    private void ensureCapacityInternal(int minimumCapacity) {
        if (minimumCapacity - value.length > 0) {
            value = Arrays.copyOf(value,
                    newCapacity(minimumCapacity));
        }
    }
}

// String.java
public final String{
    // 從當前字串中複製指定區間的字元到陣列dst dstBegin位後
    public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
        // 省略部分判斷程式碼
        getCharsNoCheck(srcBegin, srcEnd, dst, dstBegin);
    }

    @FastNative
    native void getCharsNoCheck(int start, int end, char[] buffer, int index);
}
複製程式碼

從上面原始碼可以看出 StringBuilder 每次 append 字串時,都是在操作同一個 char[] 陣列(無需擴容時),不涉及物件的建立;

stringBuilder陣列操作

5. 是不是所有字串拼接場景都該首選 StringBuilder ?

也不盡然, 比如有些是編譯時常量, 直接用 String 就可以, 即使用 StringBuilder , AS也會提示改為 String 不然反倒浪費;

對於非迴圈拼接字串的場景, 原始碼是用 String 或者 StringBuilder 沒啥區別, 位元組碼中都轉換成 StringBuilder 了;

建議StringBuilder轉String

    //  編譯時常量測試
    private String methodFixStr() {
        return "a" + "a" + "a" + "a" + "a" + "a";
    }

    private String methodFixSb() {
        StringBuilder sb = new StringBuilder();
        sb.append("a");
        sb.append("a");
        sb.append("a");
        sb.append("a");
        sb.append("a");
        return sb.toString();
    }
複製程式碼

對應的smali程式碼:

.method private methodFixStr()Ljava/lang/String;
    .locals 1

    .line 100
    const-string v0, "aaaaaa" # 編譯器直接優化成最終結果了

    return-object v0
.end method

# stringBuilder就沒有優化,還是要一步一步進行拼接
# 這也就是 IDE 提示使用 String 的原因吧
.method private methodFixSb()Ljava/lang/String;
    .locals 2

    .line 108
    new-instance v0, Ljava/lang/StringBuilder;
    invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V

    .line 109
    .local v0, "sb":Ljava/lang/StringBuilder;
    const-string v1, "a"

    invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    .line 110
    const-string v1, "a"
    invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    .line 111
    const-string v1, "a"
    invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    .line 112
    const-string v1, "a"
    invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    .line 113
    const-string v1, "a"
    invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    .line 114
    invoke-virtual {v0}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object v1
    return-object v1
.end method
複製程式碼

相關文章