为什么StringBuilder比StringBuffer慢?

9
这个例子中,StringBuffer实际上比StringBuilder更快,而我本来预计的结果是相反的。
这是否与JIT进行的优化有关?有人知道为什么StringBuffer会比StringBuilder更快,即使它的方法是同步的吗?
以下是代码和基准测试结果:
public class StringOps {

    public static void main(String args[]) {

        long sConcatStart = System.nanoTime();
        String s = "";
        for(int i=0; i<1000; i++) {
            s += String.valueOf(i);
        }
        long sConcatEnd = System.nanoTime();

        long sBuffStart = System.nanoTime();
        StringBuffer buff = new StringBuffer();
        for(int i=0; i<1000; i++) {
            buff.append(i);
        }
        long sBuffEnd = System.nanoTime();

        long sBuilderStart = System.nanoTime();
        StringBuilder builder = new StringBuilder();
        for(int i=0; i<1000; i++) {
            builder.append(i);
        }
        long sBuilderEnd = System.nanoTime();

        System.out.println("Using + operator : " + (sConcatEnd-sConcatStart) + "ns");
        System.out.println("Using StringBuffer : " + (sBuffEnd-sBuffStart) + "ns");
        System.out.println("Using StringBuilder : " + (sBuilderEnd-sBuilderStart) + "ns");

        System.out.println("Diff '+'/Buff = " + (double)(sConcatEnd-sConcatStart)/(sBuffEnd-sBuffStart));
        System.out.println("Diff Buff/Builder = " + (double)(sBuffEnd-sBuffStart)/(sBuilderEnd-sBuilderStart));
    }
}


基准测试结果:

Using + operator : 17199609ns
Using StringBuffer : 244054ns
Using StringBuilder : 4351242ns
Diff '+'/Buff = 70.47460398108615
Diff Buff/Builder = 0.056088353624091696


更新:

感谢大家。热身确实是问题所在。一旦添加了一些热身代码,基准测试结果变为:

Using + operator : 8782460ns
Using StringBuffer : 343375ns
Using StringBuilder : 211171ns
Diff '+'/Buff = 25.576876592646524
Diff Buff/Builder = 1.6260518726529685

YMMV(你的经验可能有所不同),但总体比例符合预期。

http://docs.oracle.com/javase/6/docs/api/java/lang/StringBuffer.html说:通常应该优先使用StringBuilder类,而非StringBuffer类,因为它支持所有相同的操作,但它更快,因为它不执行同步。 - Dan Iliescu
4
@DanIliescu,OP可能已经知道了。他感到惊讶的是结果居然相反,同步的StringBuffer更快。 - ppeterka
1
@Parag,请发布您运行的基准测试代码以测试速度,并提供确切的结果,以便能够看到发生了什么... - ppeterka
@ppeterka编辑了问题,包括代码。 - Parag
5个回答

23

我查看了您的代码,StringBuilder 似乎 更慢的最可能原因是您的基准测试没有正确考虑JVM热身效应。 在这种情况下:

  • JVM启动会产生相当数量的垃圾需要处理,
  • JIT编译可能在运行的过程中启动。

其中任何一种都可能增加测试中测量StringBuilder部分所需的时间。

有关更多详细信息,请阅读此问题的答案:如何在Java中编写正确的微基准测试?


5

完全相同的代码,来自于java.lang.AbstractStringBuilder,在两种情况下都使用了相同的容量(16)。

唯一的区别是初始调用时使用了synchronized

我得出结论,这是一种度量误差。

StringBuilder:

228    public StringBuilder append(int i) {
229        super.append(i);
230        return this;
231    }

StringBuffer :

345    public synchronized StringBuffer append(int i) {
346        super.append(i);
347        return this;
348    }

AbstractStringBuilder :

605     public AbstractStringBuilder append(int i) {
606         if (i == Integer.MIN_VALUE) {
607             append("-2147483648");
608             return this;
609         }
610         int appendedLength = (i < 0) ? Integer.stringSize(-i) + 1
611                                      : Integer.stringSize(i);
612         int spaceNeeded = count + appendedLength;
613         if (spaceNeeded > value.length)
614             expandCapacity(spaceNeeded);
615         Integer.getChars(i, spaceNeeded, value);
616         count = spaceNeeded;
617         return this;
618     }


110     void expandCapacity(int minimumCapacity) {
111         int newCapacity = (value.length + 1) * 2;
112         if (newCapacity < 0) {
113             newCapacity = Integer.MAX_VALUE;
114         } else if (minimumCapacity > newCapacity) {
115             newCapacity = minimumCapacity;
116         }
117         value = Arrays.copyOf(value, newCapacity);
118     }

(expandCapacity没有被覆盖)

这篇博客文章更多地讲述了:

  • 微基准测试中存在的困难
  • 在发布基准测试结果之前,您不应该忽略对测量内容(此处为公共超类)的一些考虑

请注意,最近JDK中synchronized的“缓慢”可以被视为一种误解。我所做或阅读的所有测试都得出结论:通常没有理由花费太多时间避免同步。


虽然同步的开销比以前低得多,但它们仍然存在,如果您设计一个可以隔离它们的基准测试,它们将是可测量的。值得注意的是,不必要的同步可能会导致由于缓存刷新而产生额外的内存流量。这还加上了获取和释放锁的开销。 - Stephen C

2
当你在自己的计算机上运行这段代码时,你会看到不同的结果。有时候StringBuffer更快,有时候StringBuilder更快。这可能是因为在使用StringBuffer和StringBuilder之前,需要进行JVM热身,而这个时间会因多次运行而变化,正如@Stephen所说的那样。
以下是我进行4次运行的结果:-
Using StringBuffer : 398445ns
Using StringBuilder : 272800ns

Using StringBuffer : 411155ns
Using StringBuilder : 281600ns

Using StringBuffer : 386711ns
Using StringBuilder : 662933ns

Using StringBuffer : 413600ns
Using StringBuilder : 270356ns

当然,仅凭4次执行无法预测确切的数字。

2
我建议:
  • 将每个循环分解成单独的方法,以便一个循环的优化不会影响另一个循环。
  • 忽略前10K次迭代。
  • 至少运行测试2秒。
  • 多次运行测试以确保可重复性。

当您运行代码少于10000次时,它可能不会触发默认的-XX:CompileThreshold=10000编译阈值。部分原因是为了收集有关如何最佳优化代码的统计信息。然而,当一个循环触发编译时,它会触发整个方法的编译,这可能会使后续的循环看起来更好(因为它们在开始之前被编译)或者看起来更差(因为它们在没有收集任何统计信息的情况下被编译)。


考虑以下代码:

public static void main(String... args) {
    int runs = 1000;
    for (int i = 0; i < runs; i++)
        String.valueOf(i);

    System.out.printf("%-10s%-10s%-10s%-9s%-9s%n", "+ oper", "SBuffer", "SBuilder", "+/Buff", "Buff/Builder");
    for (int t = 0; t < 5; t++) {
        long sConcatTime = timeStringConcat(runs);
        long sBuffTime = timeStringBuffer(runs);
        long sBuilderTime = timeStringBuilder(runs);

        System.out.printf("%,7dns %,7dns %,7dns ",
                sConcatTime / runs, sBuffTime / runs, sBuilderTime / runs);
        System.out.printf("%8.2f %8.2f%n",
                (double) sConcatTime / sBuffTime, (double) sBuffTime / sBuilderTime);
    }
}

public static double dontOptimiseAway = 0;

private static long timeStringConcat(int runs) {
    long sConcatStart = System.nanoTime();
    for (int j = 0; j < 100; j++) {
        String s = "";
        for (int i = 0; i < runs; i += 100) {
            s += String.valueOf(i);
        }
        dontOptimiseAway = Double.parseDouble(s);
    }
    return System.nanoTime() - sConcatStart;
}

private static long timeStringBuffer(int runs) {
    long sBuffStart = System.nanoTime();
    for (int j = 0; j < 100; j++) {
        StringBuffer buff = new StringBuffer();
        for (int i = 0; i < runs; i += 100)
            buff.append(i);
        dontOptimiseAway = Double.parseDouble(buff.toString());
    }
    return System.nanoTime() - sBuffStart;
}

private static long timeStringBuilder(int runs) {
    long sBuilderStart = System.nanoTime();
    for (int j = 0; j < 100; j++) {
        StringBuilder buff = new StringBuilder();
        for (int i = 0; i < runs; i += 100)
            buff.append(i);
        dontOptimiseAway = Double.parseDouble(buff.toString());
    }
    return System.nanoTime() - sBuilderStart;
}

打印次数为1000

+ oper    SBuffer   SBuilder  +/Buff   Buff/Builder
  6,848ns   3,169ns   3,287ns     2.16     0.96
  6,039ns   2,937ns   3,311ns     2.06     0.89
  6,025ns   3,315ns   2,276ns     1.82     1.46
  4,718ns   2,254ns   2,180ns     2.09     1.03
  5,183ns   2,319ns   2,186ns     2.23     1.06

然而,如果你增加运行次数 = 10,000

+ oper    SBuffer   SBuilder  +/Buff   Buff/Builder
  3,791ns     400ns     357ns     9.46     1.12
  1,426ns     139ns     113ns    10.23     1.23
    323ns     141ns     117ns     2.29     1.20
    317ns     115ns      78ns     2.76     1.47
    317ns     127ns     103ns     2.49     1.23

如果我们将运行次数增加到100,000次,我会得到

+ oper    SBuffer   SBuilder  +/Buff   Buff/Builder
  3,946ns     195ns     128ns    20.23     1.52
  2,364ns     113ns      86ns    20.80     1.32
  2,189ns     142ns      95ns    15.34     1.49
  2,036ns     142ns      96ns    14.31     1.48
  2,566ns     114ns      88ns    22.46     1.29

注意:由于循环的时间复杂度为O(N^2),因此+操作已经变慢了。

创建不同的方法是个好主意。在方法中选择双重循环有什么特定的原因吗? - Parag
我选择了一个循环内嵌另一个循环的方式来避免 StringBuilder 的大小成为问题。假设一个字符串中的数字数量大约在1000个左右,而不是最后一个测试中的10万个。也就是说,这是为了让测试稍微更加真实一些。 - Peter Lawrey

1

我稍微修改了你的代码并添加了预热循环。

我的观察大部分时间都表明StringBuilder更快。

我正在运行在Ubuntu12.04虚拟机上的Windows 7系统,并为虚拟机分配了2GB的RAM。

public class StringOps {

public static void main(String args[]) {

    for(int j=0;j<10;j++){
        StringBuffer buff = new StringBuffer();
        for(int i=0; i<1000; i++) {
                buff.append(i);
        }
    buff = new StringBuffer();
    long sBuffStart = System.nanoTime();
    for(int i=0; i<10000; i++) {
                buff.append(i);
        }
    long sBuffEnd = System.nanoTime();


        StringBuilder builder = new StringBuilder();
        for(int i=0; i<1000; i++) {
                builder.append(i);
        }
    builder = new StringBuilder();
    long sBuilderStart = System.nanoTime();
    for(int i=0; i<10000; i++) {
                builder.append(i);
        }   
        long sBuilderEnd = System.nanoTime();

        if((sBuffEnd-sBuffStart)>(sBuilderEnd-sBuilderStart)) {
        System.out.println("String Builder is faster") ; 
    }
    else {
        System.out.println("String Buffer is faster") ;
    }
    }
}

}

结果是:

String Builder is faster
String Builder is faster
String Builder is faster
String Builder is faster
String Buffer is faster
String Builder is faster
String Builder is faster
String Builder is faster
String Builder is faster
String Builder is faster

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