[smali]String/StringBuilder字符串拼接操做

相關demo源碼;java

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

1. 原因

這兩天在看 smali, 偶然看到 log 語句中的 String 拼接被優化爲了 StringBuilder, 代碼以下;git

// 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 編寫;github

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, 效率差距明顯;app

3. smali 循環拼接代碼分析

既然 String 拼接會轉化爲 StringBuilder, 理論上來講應該差距不大才對,但實際差距明顯, 猜測可能跟for循環有關,咱們看下 methodForStr(int loop, String base) 方法的smali代碼:ide

.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 代碼,能夠逆推出其源碼應該爲:oop

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
複製代碼
相關文章
相關標籤/搜索