基於: 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
複製程式碼
想起以前根深蒂固的 "大量字串拼接時 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
複製程式碼