String/StringBuilder字串拼接操作
這兩天在看 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 複製程式碼
想起以前根深蒂固的 "大量字串拼接時 StringBuilder
比 String
效能更好" 的說法, 頓時好奇是否真是那樣, 是否所有場景都那樣, 所以想探究下, 簡單起見, 原始碼用 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
物件:

// 結合第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
自然也會導致拼接耗時加長, 如下圖:

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[]
陣列(無需擴容時),不涉及物件的建立;

5. 是不是所有字串拼接場景都該首選 StringBuilder
?
也不盡然, 比如有些是編譯時常量, 直接用 String
就可以, 即使用 StringBuilder
, AS也會提示改為 String
不然反倒浪費;
對於非迴圈拼接字串的場景, 原始碼是用 String
或者 StringBuilder
沒啥區別, 位元組碼中都轉換成 StringBuilder
了;

//編譯時常量測試 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 複製程式碼