还在无脑用 StringBuilder?来重温一下字符串拼接吧

7,155 阅读12分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言:误区

前段时间看到了这篇文章《写出漂亮代码的45个小技巧 - zzyang90 - 掘金》。 文章提出我们应该把 a + b + cstringBuilder.append(a).append(b).append(c).toString() 来代替。 从“漂亮代码”的层面上,当然是前者更友好;而从性能层面上,后者真的效率有提高吗?


本文涉及的版本特性:

  • Java 9: Indify String Concatenation 以及字符串压缩
  • Java 15: 削减了一些 Indify String Concatenation 的一些性能不佳的策略
  • Java 17: String::join 以及 StringJoiner 性能优化
  • Java 19: StringBuilder 在开启字符串压缩时,对含不可压缩(如中文)字符串的部分优化

本文将主要包含以下内容:

  • 性能测试工具部分
  • StringBuilder 重复使用的性能测试
  • Java 9 JEP 280: Indify String Concatenation 的介绍
    • StringBuilder, String::join 的性能对比测试以及原因分析
    • Java 9 字符串压缩的简单介绍

本文用到的代码都放在了 github.com/yesh0/strin…

JMH 介绍

JMH 是用来在 JVM 上进行微型性能测试(microbenchmark)的工具。想看看 i % 2i & 1 速度到底差多少?想知道 StringBuilder 到底性能如何?不要再手动 System.nanoTime() 了,请用JMH。

按官方步骤,我们先生成一个项目:

$ mvn archetype:generate \
  -DinteractiveMode=false \
  -DarchetypeGroupId=org.openjdk.jmh \
  -DarchetypeArtifactId=jmh-java-benchmark-archetype \
  -DgroupId=org.sample \
  -DartifactId=stringbuilder-test \
  -Dversion=1.0

进入生成的 stringbuilder-test 目录后,我们使用 mvn clean verify 来生成用于测试的 target/benchmarks.jar。 当然,现在我们什么测试代码都没写。运行 java -jar target/benchmarks.jar 你会看到一个与 CPU 主频数量级相当的结果。

这是因为生成的项目中自带一个空白测例:

@Benchmark
public void testMethod() {}

Java 编译器对字符串拼接的编译结果

在进行测试之前,我们需要知道编译器到底是怎么实现字符串拼接的。

给定下面的代码,我们分别在不同的 JDK 版本下进行编译。

public class Concat {
    public void main(String[] args) {
        String a = "A";
        String b = "B";
        String c = "C";
        System.out.println(a + b + c);
    }
}

Java 8

在 Java 8 的环境下获取编译结果

$ javac Concat.java
$ javap -c Concat
      ...
      17: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
      20: aload_2
      21: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      24: aload_3
      25: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: aload         4
      30: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      33: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      ...

只需关注最后几行,可以看出编译器的确使用了 StringBuilder, 而字节码对应的代码逻辑也就是 new StringBuilder().append(a).append(b).append(c).toString()

也就是说,Java 8 下,下面的代码和上面的例子在字节码层面上完全一致的:

public class Concat {
    public void main(String[] args) {
        String a = "A";
        String b = "B";
        String c = "C";
        System.out.println(new StringBuilder()
            .append(a).append(b).append(c).toString());
    }
}

Java 11

下文里对应的特性是在 Java 9 实现的 JEP 280: Indify String Concatenation 引入的。 但是 Java 9 并不是 LTS,我也懒得再去安装了,所以下面使用 Java 11 进行编译。

和上面的步骤相同,这里在 Java 11 下进行编译

$ javac Concat.java
$ javap -c Concat
      ...
      13: aload_2
      14: aload_3
      15: aload         4
      17: invokedynamic #6,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
      ...

生成的指令明显和 Java 8 的结果不同。它用到了一条特殊的指令——InvokeDynamic,这部分的解释我们留到后文再说。

StringBuilder 的重复使用

上面提到的文章中说要把 a + b + cstringBuilder.append(a).append(b).append(c).toString() 来代替。 如上文 Java 8 的反汇编结果所说,这种写法和 Java 8 编译器生成的没有任何区别,那我只能猜测: 也许作者想要说的是我们可以重复使用一个 StringBuilder 实例?

我们可以对重复使用与不重复使用这两种情况进行性能测试。 (测试代码在这里。)

mvn clean verify 后使用 java -jar target/benchmarks.jar -jvmArgs "-Xms1024m" -jvmArgs "-Xmx1024m" -prof gc 运行测试, 这里我们指定了内存大小,并加上了 -prof gc 选项用于统计不同情况下的 GC 负载。

stringbuilder.png

字符串拼接方法数值单位
1. 使用 char[] 手动拼接字符串:速度814517.943±18736.55ops/s
2. 不重复使用的 StringBuilder:速度306762.701±3448.232ops/s
3. 重复使用的 StringBuilder:速度787552.767±58811.11ops/s
1. 使用 char[] 手动拼接字符串:累积分配内存量16424.004±0.001B/op
2. 不重复使用的 StringBuilder:累积分配内存量39240.010±0.001B/op
3. 重复使用的 StringBuilder:累积分配内存量16424.005±0.001B/op

这部分性能测试的结果非常符合直觉:

  • 不重复使用的 GC 平均内存分配是重复使用的两倍多

    • 不重复使用时:需要分配的内存有 nnString 的空间和 nnStringBuilder 内部的空间。
    • 重复使用时:需要的分配内存则是 nnString 的空间和 11StringBuilder 的空间。
    • 因为 StringBuilder::toString() 会进行复制操作,所以可以认为 StringStringBuilder 最终空间占用相同。
      • 不重复使用需要分配 2n2n 的空间,而重复使用只需要 n+1n + 1
    • 因为 StringBuilder 内部数组在增长过程中会不断重新创建,所以每个 StringBuilder 需要申请的内存量实际大于 nn。 这可以解释为什么不重复使用时请求的内存是两倍多,而不是近似两倍。
  • 拼接的耗时与内存分配量看起来是正相关的:不重复使用的耗时是重复使用的两倍多。 所以这些主要是复制的操作的性能最后可能受到内存分配性能的限制。 但每次额外内存分配也都伴随着额外的内存拷贝,这方面也会影响性能。

  • 重复利用一个手动创建的 char[] 来进行字符串拼接的性能与重复使用 StringBuilder 的性能相当。

    因为 Java 9 后 JVM 可能会有字符串压缩,所以 char[] 和实际的 StringBuilder 用起来可能还是会有性能差异。 在这里的测试里,我们当然可以关闭字符串压缩,也可以通过在字符串里引入一些中文字符来取消压缩。

    在实际应用中,请不要这样自己手写字符串拼接。

另外,说是“重复使用”,但是重复使用的范围也可以有变化: 函数内重复使用,一个对象内重复使用,还是说用一个 ThreadLocal 来大张旗鼓地重复使用? 不同的调用频率、可用堆内存情况以及字符串数据的不同特点都会使最优策略发生变化,这个时候微型测试就帮不上忙了——请在实际环境下压测。

Java 9: Indify String Concatenation

但是,相信大多数同学都可以想到,new StringBuilder().append(a).append(b).toString() 并不是性能最优的。 此时,在 StringBuilderappend 过程中可能会发生两次内存分配:

  • builder.append(a) 时内部空间不足,需要扩容;
  • builder.append(b) 时内部空间又不足了,需要再次扩容。

如下面代码所示,我们只要给 StringBuilder 指定一个初始容量即可克服这一点。

new StringBuilder(a.length() + b.length())
    .append(a)
    .append(b)
    .toString();

但与之相应的,我们的代码是不是越写越难看了?明明我们只是想要实现 a + b 而已……

Java 9 里实现的 JEP 280 正是对这种字符串拼接的优化。JEP 280 可以总结为下面的改变:

  • Java 8 里的字符串拼接:a + b + c 编译为 new StringBuilder()...
  • Java 9 里的字符串拼接:a + b + c 编译为 动态生成的拼接函数(a, b, c)

也就是,Java 9 及往后的字节码我们不再直接使用 StringBuilder,而是直接告诉 JVM 我们想要进行一次拼接操作。 此时 JVM 会生成对应的负责拼接字符串的函数,函数内部可以进行各种优化,不必受到原来字节码的限制。 这样,JVM 就可以想怎么优化就怎么优化,而字节码方面这样也实现了逻辑上的“一次编译到处运行”。

InvokeDynamic 原理

Java 9 里字符串拼接的字节码使用了 InvokeDynamic

      17: invokedynamic #6,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

了解过 Java 8 Lambda 实现的同学大概已经听过这个字节码了。 我们这里与其花时间做些晦涩的说明,不如直接来看一下上面字节码里指向的 makeConcatWithConstants 到底是怎么用的。

CallSite callSite = StringConcatFactory.makeConcatWithConstants(
    MethodHandles.lookup(),
    "",
    MethodType.methodType(String.class, String.class, String.class, String.class),
    "[[[ \u0001\u0002 \u0001\u0002 \u0001 ]]]",
    "!",
    "~"
);
System.out.println((String) callSite.dynamicInvoker().invokeExact("Hello", "World", "Invoke Dynamic"));

上面这段代码输出的结果是 [[[ Hello! World~ Invoke Dynamic ]]]。 可以看出来,上面的 makeConcatWithConstants 其实就是以 [[[ \u0001\u0002 \u0001\u0002 \u0001 ]]] 作为了一个模板。 第一次调用的时候可以理解成它把模板填充了一半,把两处的 \u0002 分别用 !~ 这两个参数替换了, 变成 [[[ \u0001, \u0001~ \u0001 ]]]。这个时候它返回的 CallSite 包含了一个 MethodHandle,可以进一步调用。 而再次调用这个 MethodHandle 时,它就把剩下的三处 \u0001 给用参数给填充上了,生成了我们看到的输出字符串。

graph TD
    D["[[[ ▢▥ ▢▥ ▢ ]]]"]
    -->|"[&quot;!&quot;, &quot;~&quot;]"| E["[[[ ▢! ▢~ ▢ ]]]"]
    -->|"[&quot;Hello&quot;, &quot;World&quot;, &quot;Invoke Dynamic&quot;]"| F["[[[ Hello! World~ Invoke Dynamic ]]]"]

而实际上,字节码里用到的 InvokeDynamic 所做的也就是我们上面做的。str1 + str2 + str3 对应的模板是 \u0001\u0001\u0001。 JVM 在执行到这一条 InvokeDynamic 指令时会自动使用 makeConcatWithConstants 对三个字符串的拼接操作进行优化,生成一个对应的 CallSite 对象, 最后用优化过的 CallSite 对象对字符串进行拼接。此后此处所有的字符串拼接都会使用同一个 CallSite 对象。

graph TD
    D["▢▢▢"]
    -->|makeConcatWithConstants<br>只执行一次| E["CallSite(▢▢▢)"]
    -->|"接收三个字符串参数<br>[str1, str2, str3]<br>输出其按模板拼接的结果"| F["str1 + str2 + str3"]

看起来好复杂!为什么要这样做?JEP 280 给出了几点解释:

  • 这里编译器只需要生成一句 InvokeDynamic 指令(以及对应的 makeConcatWithConstants 的初始参数),不需要担心性能优化。 这样使得在编译器升级时我们不必为了性能而重新编译之前的代码。

    想想看,如果编译器生成了 new StringBuilder(a.length() + b.length())... 的代码, 那么在我们开启字符串压缩并且确认自己要处理 CJK 字符的时候,我们岂不是需要用某种奇妙的编译器选项让上面的代码重新编译为 new StringBuilder((a.length() + b.length()) * 2)...?因为目前的实现里 StringBuilder 默认按压缩处理,预分配的空间是 Nbyte 而不是 Nchar

  • 使用 InvokeDynamic 可以让 JVM 避免 boxing 和 unboxing 的开销。

    我们上面手动的 MethodHandle 是必须把 int 变为 Integer 再传递进参数的。但是 InvokeDynamic 作为 JVM 直接支持的字节码, 它可以直接接受 int 之类的类型作为参数。(是的,Java 9 后 "Hello" + 123 这种拼接其实也是由 InvokeDynamic 实现的。)

性能测试

上面的重复使用 StringBuilder 的性能测试也许会让你以为还是直接使用 StringBuilder 性能更优。 但是……我们先来看看测试结果。

Latin-1 字符集以及分析

(测试代码在这里。)

indy-latin.png

字符串拼接方式数值单位
1. Indy 普通 a + b + c9220938.932±72935.317ops/s
2. 使用 String::join8680897.528±76117.299ops/s
3. 重复使用 StringBuilder8324764.305±297070.23ops/s
1. Indy 普通 a + b + c:累积分配内存量1376.000±0.001B/op
2. 使用 String::join:累积分配内存量1408.000±0.001B/op
3. 重复使用 StringBuilder:累积分配内存量1376.000±0.001B/op

上面这部分测试用的数据用的都是英文字符,用中英文掺杂时 StringBuilder 可能会慢一些。这部分在下面的测试中有体现。

可以看到 String::join 的性能也非常好。这是因为测试是在 Java 19 环境下运行的。(想试 Loom 嘛,懒得换回去了。) 在 Java 17 之前,String::join 内部直接使用了 StringJoiner,原理和不重复使用 StringBuilder 一样。 Java 17 之后,String::join 被优化了(JDK-8265237),其实现与下面介绍的策略基本一致。

可以看到这三种方式的性能较为相近,甚至 a + b + c 会更快一些。

从上面的 InvokeDynamic 的介绍可知,a + b + c 具体的算法与 makeConcatWithConstants 的实现有关。例如:

  • 可能性一:直接用 new StringBuilder().append(a).append(b).append(c).toString() 实现。
  • 可能性二:预估分配内存的大小 new StringBuilder(a.length() + b.length() + c.length()).append(a).append(b).append(c).toString() 实现。

明显这两种策略都达不到重复使用 StringBuilder 的性能。所以尽管 Java 9 时用户可以通过一些选项来使用上述的策略, 但在 Java 15 后(JDK-8245455),我们只剩下了下图右侧这种几乎是最优的策略。我们来对比一下:

graph TD
subgraph String::join
    D["▢, ▢, ▢"]
    -->|分配好空间<br>复制| E["字符数组A[▢▢▢]"]
    -->|String 的内部构造函数| F["String(字符数组A[▢▢▢])"]
end
subgraph StringBuilder
    A["▢, ▢, ▢"]
    -->|复制| B["StringBuilder(字符数组A[▢▢▢])"]
    -->|分配空间<br>复制| C["String(字符数组B[▢▢▢])"]
end
  1. 两倍复制的 StringBuilder: 我们先把 a, b, c 三个字符串内容给复制到 StringBuilder 内部的数组中, 最后 toString() 的时候,我们再把 StringBuilder 内部的数组给复制到新的字符串中。
  2. 只需要一次复制的策略:我们先准确分配好 a + b + c 所需的空间,把 a, b, c 的内容复制到我们分配的空间里, 最后直接把我们分配的空间通过内部 API 传给字符串。

这两种策略现在的内存分配状况是几乎近似的,但是其复制量却相差一倍。这也就是前两种方法与 StringBuilder 方法的差异之一。

含中文的测试以及字符串压缩

有趣的是,使用了中文字符的测试结果会更富戏剧性一些。 (测试代码在这里。)

indy.png

字符串拼接方法数值单位
1. 不重复使用的 StringBuilder306762.701±3448.232ops/s
2. 重复使用的 StringBuilder787552.767±58811.11ops/s
3. Indy 普通 a + b + c + ...1029127.196±62281.63ops/s
1. 不重复使用的 StringBuilder:累积分配内存量39240.010±0.001B/op
2. 重复使用的 StringBuilder:累积分配内存量16424.005±0.001B/op
3. Indy 普通 a + b + c + ...:累积分配内存量10952.003±0.001B/op

可以看到,从不重复使用 StringBuilder 到重复使用 StringBuilder 到最普通的 a + b + c + ..., 它们的累积分配内存量依次减少,而其性能依次提升。让我们来比较一下:

  1. Latin-1 字符集:StringBuilder 内存分配和 a + b + c + ... 相当,性能 StringBuilder 略低。
  2. 混入中文:StringBuilder 内存分配明显增多,性能相比起来也进一步降低了。

为什么?我们知道 Java 9 同时也引入了字符串压缩的特性:

  • char 占两字节的空间,但我们熟悉的 ASCII 字符其实只需要一个字节即可。
  • 对于全部是 ASCII 字符的字符串,通过字符串压缩,我们可以直接节省一半的存储空间

但是字符串压缩也为 StringBuilder 引入了新的(可能还不是最优的)逻辑:

  • 如果加入的字符串全部都是可以压缩的 Latin-1 字符集,那么我们可以直接创建压缩的字符串。
  • 如果加入的字符串有不能压缩的,那么我们知道我们要创建非压缩的字符串。
  • 如果我们加入了不能压缩的字符串,但是我们对 StringBuilder 进行了删除操作, 那么我们就不知道字符串需不需要压缩了。

看一看我们重复使用 StringBuilder 的逻辑:

  1. StringBuilder 里加入含有中文的字符串,这个时候 StringBuilder 还是知道我们需要非压缩的字符串的。
  2. 我们在重复使用时用 builder.setLength(0) 对其重置。StringBuilder 没有对 builder.setLength(0) 作任何特殊处理, 也就是说,这个重置相当于对其进行删除操作,现在 StringBuilder 处于一种不知道要不要压缩的状态。

在不知道要不要压缩的状态时,StringBuilder 的做法非常简单粗暴:

  1. 在加入字符串的时候,所有字符串都当作无法压缩来处理;
  2. 在进行 builder.toString() 的时候,
    1. 先当作可以压缩来处理,以能够压缩为前提分配一次内存,尝试进行一次复制
    2. 如果最后发现不能压缩,那么再以没有压缩的内存量再分配一次内存

在 Java 19 之前,只要只要涉及到中文字符,那么 builder.toString() 都会进行上面的两次内存分配。 Java 19 (JDK-8282429)虽然稍微改进了一下,但 builder.setLength(0) 之后,builder.toString() 还是会进行两次内存分配。

当然,你可以说 StringBuilder 的处理太不合理了:后续加入中文字符串的时候,它就应该可以推测出最后不能进行压缩。 但遗憾的是,截至 Java 19,StringBuilder 目前的实现还是这样。

总结

  • 在 Java 9 及以后的版本,请大胆使用 a + b + c + ...
    • Java 9 后这里的字符串拼接使用了 InvokeDynamic 指令,默认会准确计算长度,只进行一次内存分配。 同时,它生成最终字符串时会使用字符串的内部 API,比起 StringBuilder 来说少了一次复制。
    • Java 9 字符串拼接有不同的策略,默认策略就是上面所说的策略。其它策略在 Java 15 之后被删除掉了。
  • 有对应的工具方法的操作,如 String::join,那还是用对应的方法好一点。
    • String::join 在 Java 17 之前使用 StringJoiner,Java 17 之后采用了类似上面所说的优化。
  • 在开启字符串压缩的情况下,StringBuilder 拼接无法压缩的(如中文)字符串会引来一次额外的内存分配。(至少 Java 19 在 builder.setLength(0) 后还会这样。)
  • 循环进行的字符串拼接不在本文讨论范围内,请自行进行测试。

当然,我相信大多数应用的性能瓶颈都不会是在字符串拼接上。 本文做的最多也就是给各位从 Java 8 版本升级找多一个理由罢了。

本文以 CC BY-SA 4.0 发布。

BenchmarkScoreUnits
StringBuilderBenchmark.testConcat1029127.196±62281.635ops/s
StringBuilderBenchmark.testConcat:·gc.alloc.rate10748.311±651.604MB/sec
StringBuilderBenchmark.testConcat:·gc.alloc.rate.norm10952.003±0.001B/op
StringBuilderBenchmark.testConcat:·gc.count1070.000counts
StringBuilderBenchmark.testConcat:·gc.time795.000ms
StringBuilderBenchmark.testConcatJoiner1172726.141±40887.696ops/s
StringBuilderBenchmark.testConcatJoiner:·gc.alloc.rate12337.758±430.988MB/sec
StringBuilderBenchmark.testConcatJoiner:·gc.alloc.rate.norm11032.003±0.001B/op
StringBuilderBenchmark.testConcatJoiner:·gc.count1221.000counts
StringBuilderBenchmark.testConcatJoiner:·gc.time867.000ms
StringBuilderBenchmark.testConcatWithBuilders306762.701±3448.232ops/s
StringBuilderBenchmark.testConcatWithBuilders:·gc.alloc.rate11479.475±128.920MB/sec
StringBuilderBenchmark.testConcatWithBuilders:·gc.alloc.rate.norm39240.010±0.001B/op
StringBuilderBenchmark.testConcatWithBuilders:·gc.count1133.000counts
StringBuilderBenchmark.testConcatWithBuilders:·gc.time829.000ms
StringBuilderBenchmark.testConcatWithCharArray814517.943±18736.556ops/s
StringBuilderBenchmark.testConcatWithCharArray:·gc.alloc.rate12757.311±294.206MB/sec
StringBuilderBenchmark.testConcatWithCharArray:·gc.alloc.rate.norm16424.004±0.001B/op
StringBuilderBenchmark.testConcatWithCharArray:·gc.count1259.000counts
StringBuilderBenchmark.testConcatWithCharArray:·gc.time865.000ms
StringBuilderBenchmark.testConcatWithSameBuilder787552.767±58811.119ops/s
StringBuilderBenchmark.testConcatWithSameBuilder:·gc.alloc.rate12335.257±921.133MB/sec
StringBuilderBenchmark.testConcatWithSameBuilder:·gc.alloc.rate.norm16424.005±0.001B/op
StringBuilderBenchmark.testConcatWithSameBuilder:·gc.count1217.000counts
StringBuilderBenchmark.testConcatWithSameBuilder:·gc.time857.000ms
StringBuilderLatinBenchmark.testConcatLatin9220938.932±72935.317ops/s
StringBuilderLatinBenchmark.testConcatLatin:·gc.alloc.rate12100.013±95.690MB/sec
StringBuilderLatinBenchmark.testConcatLatin:·gc.alloc.rate.norm1376.000±0.001B/op
StringBuilderLatinBenchmark.testConcatLatin:·gc.count1186.000counts
StringBuilderLatinBenchmark.testConcatLatin:·gc.time805.000ms
StringBuilderLatinBenchmark.testConcatLatinJoiner8680897.528±76117.299ops/s
StringBuilderLatinBenchmark.testConcatLatinJoiner:·gc.alloc.rate11656.274±102.184MB/sec
StringBuilderLatinBenchmark.testConcatLatinJoiner:·gc.alloc.rate.norm1408.000±0.001B/op
StringBuilderLatinBenchmark.testConcatLatinJoiner:·gc.count1146.000counts
StringBuilderLatinBenchmark.testConcatLatinJoiner:·gc.time796.000ms
StringBuilderLatinBenchmark.testConcatWithSameBuilder8324764.305±297070.236ops/s
StringBuilderLatinBenchmark.testConcatWithSameBuilder:·gc.alloc.rate10923.965±389.666MB/sec
StringBuilderLatinBenchmark.testConcatWithSameBuilder:·gc.alloc.rate.norm1376.000±0.001B/op
StringBuilderLatinBenchmark.testConcatWithSameBuilder:·gc.count1071.000counts
StringBuilderLatinBenchmark.testConcatWithSameBuilder:·gc.time731.000ms