Java 8的字符串连接

时间:2021-05-29 15:27:22

字符串连接是Java中最着名的一个警告。几乎所有有经验的Java开发人员已经听说过或解释了关于何时使用String vs StringBuilder / StringBuffer来连接字符串。
在最后几个月,我对我在公司工作的Java职位进行了一些采访。候选人有时需要工作的一个行使需要在for循环中连接字符串。显然,作为一个反常的程序员,我喜欢问人们他们对他们写的代码的性能和如何改进他们的想法。答案真的令人惊讶,特别是关于字符串连接。虽然一些解释不是真的令人信服,他们让我怀疑使用StringBuilder / StringBuffer是否仍然需要一个最近的Java虚拟机。因此,我决定做一些调查。

了解问题
在Java中String对象是不可变的。这意味着对String对象的任何操作不会改变对象的内容,但创建一个带有转换值的新对象。

String result = "";
for (int i=0; i<1e6; i++) {
    result += "some more data";
}

例如,上面介绍的代码片段是一个简单的循环,它循环1M次,并在每次迭代时将String“一些更多的数据”连接到结果变量。但是,使用+运算符(在我们的示例中严格等同于result = result +“some more data”)或者甚至String#concat(String)并不意味着内部数据被粘贴在结果变量的结尾步。这是不可能的,因为String对象是不可变的。
在引擎盖下,发生许多操作。首先,分配一个新的字符数组,其大小与结果变量所包含的现有值相匹配,但是还有附加的有效载荷。然后,将它们的值复制到新的数组实例,并从数组中创建一个新的String对象。最后,新的String实例将替换已经分配给结果变量的实例,并且最后一个实例被标记为垃圾回收,因为它不再被变量引用。
这个动作序列意味着随着结果变量的增长,每次要复制的数据量也会增加,并且完成操作的时间也会增加。简单的数学可以应用于根据所需的复制来估计前一段代码的复杂性。
在第一次迭代时,将1414个字符复制到大小为0 + 140 + 14的字符数组。第二次迭代将2828个字符复制到大小为14 + 1414 + 14的字符数组。然后,在第三次迭代时,分配大小为28 + 1428 + 14的数组,并将3636个字符复制到其中,依此类推,依此类推。
总之,n次迭代所需的副本数量等于:

i=0n114×i=7n(n1)=7n27n∑i=0n−114×i=7n(n−1)=7n2−7n

如果你已经采取复杂类,你可能还记得7n2-7n7n2-7n意味着你的算法复杂度在O(n2)O(n2),即二次。

StringBuilder / StringBuffer来救援
如前所述,使用“+”运算符连接Strings的简单Java循环的复杂性是二次的。这不好,特别是对于一个简单的操作,如串联。假设复制10个字符需要10ms,那么这意味着复制100个字符将需要1s。换句话说,10倍大的问题需要100倍的工作。
希望,这个问题的解决方案存在。它在于使用StringBuilder或StringBuffer类。两者的主要区别是最后一个是线程安全的,而第一个不是。下面是解决之前解释的问题的示例:

StringBuilder result = new StringBuilder();
for (int i=0; i<1e6; i++) {
    result.append("some more data");
}

你可以看到一个StringBuilder / StringBuffer的实例,就像一个可变的String对象。 append调用更改对象的状态,从而避免了几个副本。
在内部,StringBuilder使用一个可调整大小的数组和一个索引,指示数组中使用的最后一个单元格的位置。当附加一个新的字符串时,其字符被复制到数组的末尾,索引向右移动。如果内部数组已满,则其大小加倍(准确地说,如果数组大小为xx,则新大小将为2x + 22x + 2)。
那么,为什么这种方式比使用+运算符的方式更快?原因在于,当数组已满时,偶尔执行数组扩展和相关联的字符副本。渐近地说,通过使用2x + 22x + 2作为扩展因子,调整大小操作不会经常发生,因此StringBuilder#append(String)需要O(1)分摊时间。因此,整个环路在O(n)O(n)中具有复杂性。

看看字节码
我解释的可能是无聊,但一个有趣的问题是以前的解释是否仍然支持Java 8?我的意思是它仍然需要使用StringBuilder / StringBuffer或一些魔术技巧应用于+运算符?因为深入JDK 8的源代码将需要几个星期,回答这个问题的另一种方法是查看编译器生成的字节码。
让我们从一个简单的例子开始:

public class StaticStringConcatenation {
    public static void main(String[] args) {
        String result = "";
        result += "some more data";
        System.out.println(result);
    }
}

可以为上面的Java类获取javac生成的字节码的人工可读表示。它需要首先生成与源代码相关联的类文件,然后使用javap命令反汇编这最后一个文件。由于上面的代码,以及在这篇文章中介绍的所有其他资源在Github的专用Gradle项目中提供,这两个步骤可以复制如下一旦项目克隆:

$ ./gradlew build &>-
$ javap -c ./build/classes/main/StaticStringConcatenation.class
Compiled from "StaticStringConcatenation.java"
public class StaticStringConcatenation {
  public StaticStringConcatenation();
    Code:
       0: aload_0          // Push 'this' on to the stack
       1: invokespecial #1 // Invoke Object class constructor
                           // pop 'this' ref from the stack
       4: return           // Return from constructor

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2 // Load constant #2 on to the stack
       2: astore_1         // Create local var from stack (pop #2)
       3: new           #3 // Push new StringBuilder ref on stack
       6: dup              // Duplicate value on top of the stack
       7: invokespecial #4 // Invoke StringBuilder constructor
                           // pop object reference
      10: aload_1          // Push local variable containing #2
      11: invokevirtual #5 // Invoke method StringBuilder.append()
                           // pop obj reference + parameter
                           // push result (StringBuilder ref)
      14: ldc           #6 // Push "some more data" on the stack
      16: invokevirtual #5 // Invoke StringBuilder.append
                           // pop twice, push result
      19: invokevirtual #7 // Invoke StringBuilder.toString:();
      22: astore_1         // Create local var from stack (pop #6)
      23: getstatic     #8 // Push value System.out:PrintStream
      26: aload_1          // Push local variable containing #6
      27: invokevirtual #9 // Invoke method PrintStream.println()
                           // pop twice (object ref + parameter)
      30: return           // Return void from method
}

在上面的输出中,已经手动编辑注释以获得适合页面的文本,但也阐明了字节码指令。因此,如果您在尝试执行先前的命令时收到更多模糊的注释消息,这是正常的。


在继续之前,需要对JVM内部的一些说明。 Java虚拟机(JVM)是​​一种抽象机器,提供可以执行Java字节码的运行时环境。为此,JVM由若干组件组成,包括:
. 操作数堆栈(以下称为堆栈),其目的是类似于具有CPU的寄存器执行字节码指令;
. 运行时常数池(以下称为常量池),以维护每类型常量池;
. 一个用于在运行时分配类实例和数组的Heap。
javap产生的输出显示使用JVM操作码的字节码指令(精确的助记符)。例如,aload_0是当调用StaticStringConcatenation类的构造函数时执行的第一个操作码。其目的是将“this”(对堆中创建的本地对象的引用)推送到堆栈。然后,invocationpecial调用基于堆栈中的对象引用的实例初始化方法(因此从堆栈中弹出参考以使用它)。在本示例中,在常量池中通过#1确定要执行的确切类和方法。与标识符关联的常量池值可以与javap的-v选项一起显示。最后,return终止构造函数的执行。
总之,JVM使用操作码来执行基本指令。通过作为堆栈的中间体使得几个指令的执行和流水线化成为可能。当执行操作码时,值被推入和/或弹出。
现在,让我们来看看主要方法的说明及其相关注释。在代码7中,创建了一个StringBuilder的新实例,然后在代码11,通过使用append方法将空的String附加到StringBuilder对象。类似地,在代码16处,在利用toString方法(代码19)检索String表示之前串联“一些更多数据”。最后,一旦获取对静态字段PrintStream的引用(代码8),该值就显示在标准输出(代码27)上。
如果你遵循我之前说的话,你可能会问为什么是StringBuilder的一个实例被创建?毕竟所有的代码源都没有引用StringBuilder。答案在于,所有构建最终String的子字符串在编译时都是已知的。在这种特定情况下,Java编译器(由知道+运算符的缺点的人编写)优化生成的字节码。在我们的例子中,+操作符的字符串连接被替换为:

new StringBuilder().append("").append("some more data");
此优化称为静态字符串连接优化,并且自Java 5起可用。
所以,这是否意味着所有以前关于+运算符的成本的解释不再成立?在这一点上它是真正的静态字符串连接。然而,仍需要使用动态字符串连接进行调查。

进一步与动态字符串连接
动态字符串连接指的是结果在运行时已知的子字符串的连接。这是例如在for循环中附加到String的子串的情况:

public class DynamicStringConcatenation {
    public static void main(String[] args) {
        String result = "";
        for (int i = 0; i < 1e6; i++) {
            result += "some more data";
        }
        System.out.println(result);
    }
}
下面是反汇编的人类可读字节码:

$ javap -c ./build/classes/main/DynamicStringConcatenation.class
Compiled from "DynamicStringConcatenation.java"
public class DynamicStringConcatenation {
  public DynamicStringConcatenation();
    Code:
       0: aload_0          // Push 'this' on to the stack
       1: invokespecial #1 // Invoke Object class constructor
                           // pop 'this' ref from the stack
       4: return           // Return from constructor

  public static void main(java.lang.String[]);
    Code:
       0: ldc            #2 // Load constant #2 on to the stack
       2: astore_1          // Create local var from stack, pop #2
       3: iconst_0          // Push value 0 onto the stack
       4: istore_2          // Pop value and store it in local var
       5: iload_2           // Push local var 2 on to the stack
       6: i2d               // Convert int to double on
                            // top of stack (pop + push)
       7: ldc2_w         #3 // Push constant 10e6 on to the stack
      10: dcmpg             // Compare two doubles on top of stack
                            // pop twice, push result: -1, 0 or 1
      11: ifge           40 // if value on top of stack is greater
                            // than or equal to 0 (pop once)
                            // branch to instruction at code 40
      14: new            #5 // Push new StringBuilder ref on stack
      17: dup               // Duplicate value on top of the stack
      18: invokespecial  #6 // Invoke StringBuilder constructor
                            // pop object reference
      21: aload_1           // Push local var 1 (empty String)
                            // on to the stack
      22: invokevirtual  #7 // Invoke StringBuilder.append
                            // pop obj ref + param, push result
      25: ldc            #8 // Push "some more data" on the stack
      27: invokevirtual  #7 // Invoke StringBuilder.append
                            // pop obj ref + param, push result
      30: invokevirtual  #9 // Invoke StringBuilder.toString
                            // pop object reference
      33: astore_1          // Create local var from stack (pop)
      34: iinc         2, 1 // Increment local variable 2 by 1
      37: goto            5 // Move to instruction at code 5
      40: getstatic     #10 // Push value System.out:PrintStream
      43: aload_1           // Push local var 1 (result String)
      44: invokevirtual #11 // Invoke method PrintStream.println()
                            // pop twice (object ref + parameter)
      47: return            // Return void from method
}

如果你快速看看说明和注释,你可以看到有一些引用StringBuilder。然而,这并不意味着,在这种情况下,字符串连接是“优化的”。仔细看看(通过绘制例如栈如何演变)将显示,每次迭代创建一个新的StringBuilder实例。这是因为静态字符串连接的优化应用在循环体中,但不在外部。编译器无法在不执行指令的情况下计算连接结果,这不是其角色。
假设必须显示与DynamicStringConcatenation类的字节码相关联的源代码,则该代码将如下所示:
String result =“”;
for(int i = 0; i <1e6; i ++){
    StringBuilder tmp = new StringBuilder();
    tmp.append(result);
    tmp.append(“some more data”);
    result = tmp.toString();
}}
System.out.println(result);
基于代码,这意味着在理解问题部分给出的关于性能问题的解释仍然适用于动态字符串连接。使用+运算符将子串连接到定义在循环体外部的String将导致严重的性能下降。尽管StringBuilder被编译器使用,但在每次迭代时必须创建一个实例,并且在附加“一些更多数据”并返回一个带有toString的String表示之前,将结果变量中的字符复制到StringBuilder实例。这最后一次出现StringBuilder实例包含的字符的另一个副本。
一个解决方案是在循环外手动创建一个StringBuilder实例:
StringBuilder result = new StringBuilder((int)1e6);
for(int i = 0; i <1e6; i ++){
    result.append(“some more data”);
}}
System.out.println(result.toString());
此外,如果事先知道将附加到StringBuilder实例的字符总数,您可以将此值传递给构造函数。它将防止一些调整操作,从而产生更好的结果。

使用微观基准评估性能
到目前为止,已经阐明,与Java运算符的动态字符串连接在Java 8中没有被编译器优化,类似于以前的Java版本。然而,没有什么可以结束。事实上,JVM是高度优化的,并且在编译时不进行的优化可以在运行时由即时(JIT)编译器执行。
JIT的目的是双重的。首先,它用于分析后台的方法调用,并编译经常用于本机CPU指令的方法的字节码。因此,一旦编译了一个方法,就使用它的本地形式,这避免了解释字节码的间接。第二,JIT可以分析动态运行时信息以进行编译器不能进行的优化。例如,经常使用的内联函数,消除死代码,如果监视器不能从其他线程访问则删除锁等等。这样,用Java编写的代码有时运行速度可能比C中的等效代码快。
由于Java HotSpot性能引擎(JVM)应用了许多optos,因此在运行时可能会出现一些魔术技巧与动态字符串连接。要检查这个假设,最好是看看实现,但它需要太多的时间,没有保证不要忘记看一段代码。另一个解决方案是编写微基准来比较使用+运算符和StringBuilder的实例来连接for循环中的字符串所需的时间。
编写微基准测试,特别是在Java中是不容易的。如前所述,JIT以透明方式执行许多优化。它意味着要意识到它的优化,初始化,重新编译等的影响写一个有意义的微基准。否则,您可能会测量完全错误的东西。希望,一些库存在帮助这项任务。特别是,Java Microbenchmark Harness(JMH)具有由俄罗斯人在JIT实现上工作的优势。
以下是使用JMH评估字符串连接性能的微观基准:
@State(Scope.Thread)
@BenchmarkMode(Mode.SingleShotTime)
@Measurement(batchSize = 100000, iterations = 20)
@Warmup(batchSize = 100000, iterations = 10)
@Fork(5)
public class StringConcatenationBenchmark {
    private String string;
    private String stringConcat;
    private StringBuilder stringBuilder;
    private StringBuffer stringBuffer;

    @Setup(Level.Iteration)
    public void setup() {
        string = "";
        stringConcat = "";
        stringBuilder = new StringBuilder();
        stringBuffer = new StringBuffer();
    }

    @Benchmark
    public void stringConcatenation() {
        string += "some more data";
    }

    @Benchmark
    public void stringConcatConcatenation() {
        stringConcat = stringConcat.concat("some more data");
    }

    @Benchmark
    public void stringBuilderConcatenation() {
        stringBuilder.append("some more data");
    }

    @Benchmark
    public void stringBufferConcatenation() {
        stringBuffer.append("some more data");
    }
}

上一个类在Github上提供,包装在gradle项目中。如果你想在你的一边运行微基准,一旦项目被克隆,你可以如下:
$ ./gradlew jmh
简而言之,JMH使用带有Measurement和Warmup注释的batchSize参数进行迭代。最后一个注释对于执行旨在加热JVM的运行非常有用,以便在进行测量时进行JIT优化。有关JMH及其注释的更多信息,请参见java-performance.info。
使用StringBuilder / StringBuffer明显优于使用+运算符或String#concat(String)用于动态字符串连接的其他方法。虽然String#concat(String)以与基于+运算符的方法类似的方式缩放,但两者之间的性能差异可以通过编译器对String#concat(String)不执行转换的事实来解释。这最后不需要创建多个StringBuilder实例,同时避免调用StringBuilder#toString()引起的额外复制。

结论
总而言之,Java 8似乎不会为+操作符引入字符串连接的新优化。这意味着,对于编译器或JIT不应用魔术技巧的特定情况,仍然需要手动使用StringBuilder。例如,当大量子字符串连接到定义在循环范围之外的String变量时。
没有自动优化所有字符串连接的原因仍然对我来说有点模糊。可能,需要太多的信息和努力来安全地处理所有可能的情况。毕竟,这也是一个好点让程序员想想他们写的东西。
如果你对字符串优化在Java和他们的相关方法感兴趣,我建议看看由 AlekseyShipilёv有趣的幻灯片