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

掘金博客链接

相关demo源码;

基于:
macOs:10.13/AS:3.3.2/Android build-tools:28.0.0/jdk: 1.8/apktool: 2.3.3

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 自然也会导致拼接耗时加长, 如下图:

gc.png

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[] 数组(无需扩容时),不涉及对象的创建;

sb.png

5. 是不是所有字符串拼接场景都该首选 StringBuilder ?

也不尽然, 比如有些是编译时常量, 直接用 String 就可以, 即使用 StringBuilder , AS也会提示改为 String 不然反倒浪费;

对于非循环拼接字符串的场景, 源码是用 String 或者 StringBuilder 没啥区别, 字节码中都转换成 StringBuilder 了;

tip.png
    //  编译时常量测试
    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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容