Javac的StringBuilder优化是否弊大于利?

16
假设我们有以下代码:
public static void main(String[] args) {
    String s = "";
    for(int i=0 ; i<10000 ; i++) {
        s += "really ";
    }
    s += "long string.";
}

(是的,我知道更好的实现方法应该使用 StringBuilder,但请忍耐一下。)
微不足道的是,我们可能期望生成的字节码类似于以下内容:
public static void main(java.lang.String[]);
Code:
   0: ldc           #2                  // String 
   2: astore_1      
   3: iconst_0      
   4: istore_2      
   5: iload_2       
   6: sipush        10000
   9: if_icmpge     25
  12: aload_1       
  13: ldc           #3                  // String really 
  15: invokevirtual #4                  // Method java/lang/String.concat:(Ljava/lang/String;)Ljava/lang/String;
  18: astore_1      
  19: iinc          2, 1
  22: goto          5
  25: aload_1       
  26: ldc           #5                  // String long string.
  28: invokevirtual #4                  // Method java/lang/String.concat:(Ljava/lang/String;)Ljava/lang/String;
  31: astore_1      
  32: return

然而,编译器会尝试变得更加智能 - 它不使用 concat 方法,而是内置了优化来使用 StringBuilder 对象,因此我们得到以下结果:

public static void main(java.lang.String[]);
Code:
   0: ldc           #2                  // String 
   2: astore_1      
   3: iconst_0      
   4: istore_2      
   5: iload_2       
   6: sipush        10000
   9: if_icmpge     38
  12: new           #3                  // class java/lang/StringBuilder
  15: dup           
  16: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
  19: aload_1       
  20: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  23: ldc           #6                  // String really 
  25: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  28: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  31: astore_1      
  32: iinc          2, 1
  35: goto          5
  38: new           #3                  // class java/lang/StringBuilder
  41: dup           
  42: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
  45: aload_1       
  46: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  49: ldc           #8                  // String long string.
  51: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  54: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  57: astore_1      
  58: return

然而,这对我来说似乎相当低效 - 而不是为整个循环使用一个字符串构建器,每个单独的连接操作都会创建一个字符串构建器,使其等同于以下内容:

public static void main(String[] args) {
    String s = "";
    for(int i=0 ; i<10000 ; i++) {
        s = new StringBuilder().append(s).append("really ").toString();
    }
    s = new StringBuilder().append(s).append("long string.").toString();
}

现在,编译器不再使用原来简单的方法创建大量字符串对象并将它们丢弃,而是采用了更差的方法创建了大量的String对象、StringBuilder对象,调用了更多的方法,并仍然将它们全部丢弃以生成与没有进行此优化相同的输出。

那么问题就是——为什么?我理解在像这样的情况下:

String s = getString1() + getString2() + getString3();

...编译器将为所有三个字符串创建一个 StringBuilder 对象,因此在某些情况下,优化是有用的。然而,检查字节码会发现即使将上述情况分开为以下情况:

String s = getString1();
s += getString2();
s += getString3();

这意味着我们回到了三个StringBuilder对象各自创建的情况。如果这些是奇怪的边角案例,我会理解的,但在循环中以这种方式附加字符串确实是非常常见的操作。

毫无疑问,在编译时确定由编译器生成的StringBuilder是否仅附加一个值是微不足道的,如果是这种情况,则使用简单的连接操作即可?

这全部都是在8u5中(然而,它至少可以追溯到Java 5,可能更早)。对于我的基准测试(不出所料),手动concat()方法比使用带有10,000个元素的循环的+=方法快2x3倍。当然,使用手动StringBuilder始终是首选方法,但是编译器肯定不应该对+=方法的性能产生不利影响吧?


2
你能提供链接证明它被称为优化吗? - Sotirios Delimanolis
@SotiriosDelimanolis 确定:http://docs.oracle.com/javase/specs/jls/se5.0/html/expressions.html#15.18.1.2 - Michael Berry
1
我在Java 6上尝试了一下(第一次使用javap,万岁),输出完全相同。 - Gimby
2
这显然不是重复的,Jarrod!至于实际问题,我猜没有人费心去尝试编写将循环中的字符串连接转换为等效的字符串构建器设置的代码 - 我想不出任何会阻止它的东西,但我假设如果不是这种情况,那么肯定有我忽略的东西会导致你无法自动化它。 - cyborg
2
如果不是因为在一种字符串本质上是可变的语言中,这个想法就不会很愚蠢。对我来说,真正重要的是表达一个想法,你希望有一些关于思想表达的聪明解释。字符串连接非常普遍,但与不可变性的概念完全不兼容(因为你总是得到一些新的东西,伴随着性能成本),所以我们有了这个显然令人困惑的妥协。如果+=运算符被扩展到StringBuilders来执行追加操作,那么一切都可能没问题-看起来是一样的。 - cyborg
显示剩余8条评论
2个回答

6
那么问题就是——为什么呢?不清楚为什么他们不在字节码编译器中更好地优化这一点。需要向Oracle Java编译器团队提问。
一个可能的解释是,在HotSpot JIT编译器中可能有代码将字节码序列优化为更好的内容。(如果你好奇,你可以修改代码使其被JIT编译,然后捕获和检查本地代码。但是,你可能会发现JIT编译器完全优化掉了方法体...)
另一个可能的解释是,最初的Java代码非常糟糕,以至于他们认为优化它不会有重大影响。考虑一位经验丰富的Java程序员如何编写:
public static void main(String[] args) {
    StringBuilder sb = new StringBuilder();
    for (int i=0 ; i<10000 ; i++) {
        sb.append("really ");
    }
    sb.append("long string.");
    String s = sb.toString();
}

这将大致加快 4 个数量级

更新 - 我使用了链接的 Q&A 中的代码链接,找到了 Java 字节码编译器源代码中生成该代码的实际位置:此处

源代码中没有提示来解释代码生成策略的“愚蠢”。


所以回答您的一般性问题:

Javac 的 StringBuilder 优化是否弊大于利?

不是的。

我的理解是,编译器开发人员进行了广泛的基准测试,以确定 (总体而言) StringBuilder 优化是值得的。

您找到了一个糟糕编写程序的极端情况,可以更好地进行优化(这是一个假设)。这并不足以得出优化总体上 “弊大于利” 的结论。


2
嗯,有点同意,但你所说的有点与Java开发中“编写愚蠢代码”的默契规则相矛盾。http://www.oracle.com/technetwork/articles/javase/devinsight-1-139780.html。为什么编译器不能优化这个并迎合不太熟练的Java开发人员呢? - Gimby
2
@Gimby - 1) 是的,它确实如此。这是一个众所周知的例外。2) 请问Java编译器团队。我能给你的只有可能的解释。 - Stephen C
3
因为编译器的作者不可能为所有可能的“愚蠢”变体处理“愚蠢代码”。 - jtahlborn
@jtahlborn - 因为他们必须划定界限。 - Stephen C
好的,我想这样就可以了;) - Gimby

1

值得一提的是,我的基准测试(毫不意外地)表明手动使用concat()方法比在循环中使用+=方法快2到3倍。

我很想看看你的基准测试结果,因为我的(基于优秀的JMH工具包)显示+=略快于String.concat。当我们每次循环迭代执行三个操作(s += "re"; s += "al"; s += "ly ";)时,+=仍然保持着近乎同样的性能,而String.concat则显然要慢三倍。

我在一台Intel Xeon E5-2695 v2 @ 2.40GHz运行OpenJDK build 1.8.0_40-ea-b23上进行了基准测试。有四种实现:

  • implicit,使用+=
  • explicit,每次连接都显式实例化一个StringBuilder,代表+=的展开
  • concat,使用String.concat
  • smart,像Stephen C's answer一样使用一个StringBuilder

每个实现都有两个版本:正常版本和在循环体中执行三个操作的版本。

以下是数字。这是吞吐量,所以数字越高越好。误差是99.9%置信区间的范围。(这是JMH的默认输出。)

Benchmark                      Mode  Cnt     Score     Error  Units
StringBuilderBench.smart      thrpt   30  5438.676 ± 352.088  ops/s
StringBuilderBench.implicit   thrpt   30    10.290 ±   0.878  ops/s
StringBuilderBench.concat     thrpt   30     9.685 ±   0.924  ops/s
StringBuilderBench.explicit   thrpt   30     9.078 ±   0.884  ops/s

StringBuilderBench.smart3     thrpt   30  3335.001 ± 115.600  ops/s
StringBuilderBench.implicit3  thrpt   30     9.303 ±   0.838  ops/s
StringBuilderBench.explicit3  thrpt   30     8.597 ±   0.237  ops/s
StringBuilderBench.concat3    thrpt   30     3.182 ±   0.228  ops/s

只使用一个 StringBuilder 的智能实现比其他实现要快得多,符合预期。在其余的实现中,'+=' 操作优于 String.concat,String.concat 又优于显式 StringBuilder 实例化。考虑到误差,它们都非常接近。
当每个循环执行三个操作时,所有的实现都会有一些(相对较小的)下降,除了 String.concat,其吞吐量降低了三倍。
这些结果并不令人惊讶,因为 HotSpot 为 StringBuilder(和 StringBuffer)进行了特定的优化--请参见 src/share/vm/opto/stringopts.cpp此文件的提交历史记录 显示这些优化可追溯至 2009 年底,作为 bug JDK-6892658 的一部分。

在我运行基准测试时,8u5和8u40早期访问版本之间似乎没有任何变化,因此这并不能解释为什么我们得到了不同的结果。(当然,编译器中的其他更改也可能改变结果。)


这是基准测试代码,我使用 java -jar benchmarks.jar -w 5s -wi 10 -r 5s -i 30 -f 1 运行它。可以在 Gist 上找到完整的代码和基准测试日志。
package com.jeffreybosboom.stringbuilderbench;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;

@State(Scope.Thread)
public class StringBuilderBench {
    //promote to non-final fields to inhibit constant folding (see JMHSample_10_ConstantFold.java)
    private String really = "really ", long_string = "long string.", re = "re", al = "al", ly = "ly ";
    @Benchmark
    public String implicit() {
        String s = "";
        for (int i = 0; i < 10000; i++)
            s += really;
        s += long_string;
        return s;
    }
    @Benchmark
    public String explicit() {
        String s = "";
        for (int i = 0; i < 10000; i++)
            s = new StringBuilder().append(s).append(really).toString();
        s = new StringBuilder().append(s).append(long_string).toString();
        return s;
    }
    @Benchmark
    public String concat() {
        String s = "";
        for (int i = 0; i < 10000; i++)
            s = s.concat(really);
        s = s.concat(long_string);
        return s;
    }
    @Benchmark
    public String implicit3() {
        String s = "";
        for (int i = 0; i < 10000; i++) {
            s += re;
            s += al;
            s += ly;
        }
        s += long_string;
        return s;
    }
    @Benchmark
    public String explicit3() {
        String s = "";
        for (int i = 0; i < 10000; i++) {
            s = new StringBuilder().append(s).append(re).toString();
            s = new StringBuilder().append(s).append(al).toString();
            s = new StringBuilder().append(s).append(ly).toString();
        }
        s = new StringBuilder().append(s).append(long_string).toString();
        return s;
    }
    @Benchmark
    public String concat3() {
        String s = "";
        for (int i = 0; i < 10000; i++) {
            s = s.concat(re);
            s = s.concat(al);
            s = s.concat(ly);
        }
        s = s.concat(long_string);
        return s;
    }
    @Benchmark
    public String smart() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 10000; i++)
            sb.append(really);
        sb.append(long_string);
        return sb.toString();
    }
    @Benchmark
    public String smart3() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 10000; i++) {
            sb.append(re);
            sb.append(al);
            sb.append(ly);
        }
        sb.append(long_string);
        return sb.toString();
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接