在循环中重用StringBuilder更好吗?


101

我有一个有关使用StringBuilder的性能相关问题。在一个很长的循环中,我正在操纵a StringBuilder并将其传递给另一个这样的方法:

for (loop condition) {
    StringBuilder sb = new StringBuilder();
    sb.append("some string");
    . . .
    sb.append(anotherString);
    . . .
    passToMethod(sb.toString());
}

StringBuilder在每个循环周期实例化是一个好的解决方案吗?而且像下面这样,调用delete会更好吗?

StringBuilder sb = new StringBuilder();
for (loop condition) {
    sb.delete(0, sb.length);
    sb.append("some string");
    . . .
    sb.append(anotherString);
    . . .
    passToMethod(sb.toString());
}

Answers:


69

在我的迷你基准测试中,第二个要快25%。

public class ScratchPad {

    static String a;

    public static void main( String[] args ) throws Exception {
        long time = System.currentTimeMillis();
        for( int i = 0; i < 10000000; i++ ) {
            StringBuilder sb = new StringBuilder();
            sb.append( "someString" );
            sb.append( "someString2"+i );
            sb.append( "someStrin4g"+i );
            sb.append( "someStr5ing"+i );
            sb.append( "someSt7ring"+i );
            a = sb.toString();
        }
        System.out.println( System.currentTimeMillis()-time );
        time = System.currentTimeMillis();
        StringBuilder sb = new StringBuilder();
        for( int i = 0; i < 10000000; i++ ) {
            sb.delete( 0, sb.length() );
            sb.append( "someString" );
            sb.append( "someString2"+i );
            sb.append( "someStrin4g"+i );
            sb.append( "someStr5ing"+i );
            sb.append( "someSt7ring"+i );
            a = sb.toString();
        }
        System.out.println( System.currentTimeMillis()-time );
    }
}

结果:

25265
17969

请注意,这与JRE 1.6.0_07一起使用。


基于Jon Skeet在编辑中的想法,此处为版本2。

public class ScratchPad {

    static String a;

    public static void main( String[] args ) throws Exception {
        long time = System.currentTimeMillis();
        StringBuilder sb = new StringBuilder();
        for( int i = 0; i < 10000000; i++ ) {
            sb.delete( 0, sb.length() );
            sb.append( "someString" );
            sb.append( "someString2" );
            sb.append( "someStrin4g" );
            sb.append( "someStr5ing" );
            sb.append( "someSt7ring" );
            a = sb.toString();
        }
        System.out.println( System.currentTimeMillis()-time );
        time = System.currentTimeMillis();
        for( int i = 0; i < 10000000; i++ ) {
            StringBuilder sb2 = new StringBuilder();
            sb2.append( "someString" );
            sb2.append( "someString2" );
            sb2.append( "someStrin4g" );
            sb2.append( "someStr5ing" );
            sb2.append( "someSt7ring" );
            a = sb2.toString();
        }
        System.out.println( System.currentTimeMillis()-time );
    }
}

结果:

5016
7516

4
我在回答中添加了一个编辑内容,以解释为什么可能会发生这种情况。稍后(45分钟),我会仔细检查。请注意,在append调用中进行串联可以减少使用StringBuilder的
麻烦

3
同样有趣的是,如果反转两个块会发生什么-JIT在第一个测试期间仍在“预热” StringBuilder。这可能无关紧要,但是尝试很有趣。
乔恩·斯基特

1
我仍然会选择第一个版本,因为它更干净。但是,您实际上已经完成了基准测试是一件好事:)接下来的建议更改:尝试将具有适当容量的#1传递给构造函数。
乔恩·斯基特

25
使用sb.setLength(0); 相反,这是清空StringBuilder内容以防止重新创建对象或使用.delete()的最快方法。请注意,这不适用于StringBuffer,其并发检查会使速度优势无效。
P Arrayah

1
无效的答案。P Arrayah和Dave Jarvis是正确的。setLength(0)无疑是最有效的答案。StringBuilder由char数组支持,并且是可变的。在调用.toString()时,将复制char数组,并将其用于支持不可变的字符串。此时,只需将插入指针移回零(通过.setLength(0)),即可重新使用StringBuilder的可变缓冲区。sb.toString创建了另一个副本(不可变char数组),因此,每次迭代都需要两个缓冲区,而.setLength(0)方法每个循环仅需要一个新缓冲区。
克里斯(Chris)

25

按照编写可靠代码的理念,将StringBuilder放入循环始终会更好。这样,它就不会超出其预期的代码范围。

其次,StringBuilder的最大改进来自为其提供初始大小,以避免在循环运行时变得更大

for (loop condition) {
  StringBuilder sb = new StringBuilder(4096);
}

1
您总是可以用大括号将整个内容范围限定,这样就不会在外面使用Stringbuilder了。
Epaga

@Epaga:它仍然在循环之外。是的,它不会污染外部作用域,但这是编写代码以提高性能的一种不自然的方式,尚未在上下文中进行验证。
乔恩·斯基特

甚至更好的是,将整个事情放在自己的方法中。;-)但是我听到您的回复:上下文。
Epaga

最好用预期的大小而不是用任意数字总和初始化(4096)您的代码可能返回一个字符串,该字符串引用大小为4096的char [](取决于JDK;据我所知,这是1.4的情况)
kohlerm

24

更快:

public class ScratchPad {

    private static String a;

    public static void main( String[] args ) throws Exception {
        long time = System.currentTimeMillis();
        StringBuilder sb = new StringBuilder( 128 );

        for( int i = 0; i < 10000000; i++ ) {
            // Resetting the string is faster than creating a new object.
            // Since this is a critical loop, every instruction counts.
            //
            sb.setLength( 0 );
            sb.append( "someString" );
            sb.append( "someString2" );
            sb.append( "someStrin4g" );
            sb.append( "someStr5ing" );
            sb.append( "someSt7ring" );
            setA( sb.toString() );
        }

        System.out.println( System.currentTimeMillis()-time );
    }

    private static void setA( String aString ) {
        a = aString;
    }
}

在编写可靠代码的哲学中,应从使用该方法的对象中隐藏该方法的内部工作原理。因此,无论是在循环内还是循环外声明StringBuilder,从系统角度而言都是没有区别的。由于在循环外声明它更快,并且不会使代码的读取更加复杂,因此可以重用该对象而不是重新实例化它。

即使代码更复杂,并且您可以肯定地知道对象实例化是瓶颈,也可以对其进行注释。

三个运行与此答案:

$ java ScratchPad
1567
$ java ScratchPad
1569
$ java ScratchPad
1570

三个答案与另一个答案:

$ java ScratchPad2
1663
2231
$ java ScratchPad2
1656
2233
$ java ScratchPad2
1658
2242

尽管不重要,但设置StringBuilder的初始缓冲区大小将带来很小的收益。


3
到目前为止,这是最好的答案。StringBuilder由char数组支持,并且是可变的。在调用.toString()时,将复制char数组,并将其用于支持不可变的字符串。此时,只需将插入指针移回零(通过.setLength(0)),即可重新使用StringBuilder的可变缓冲区。那些建议为每个循环分配一个全新StringBuilder的答案似乎并未意识到.toString会创建另一个副本,因此每个迭代都需要两个缓冲区,而.setLength(0)方法则每个循环仅需要一个新缓冲区。
克里斯(Chris)

12

好的,我现在知道发生了什么,这确实是有道理的。

我的印象是,toString仅将基础传递给char[]带副本的String构造函数。然后将在下一个“写”操作(例如delete)上进行复制。我相信某些以前的版本就是这种情况StringBuffer。(现在不是。)但是-不行,toString只是将数组(以及索引和长度)传递给String需要复制的公共构造函数。

因此,在“重用StringBuilder”的情况下,我们真正地为每个字符串创建了一个数据副本,始终使用缓冲区中的相同char数组。显然,StringBuilder每次创建一个新缓冲区都会创建一个新的基础缓冲区-然后在创建新字符串时复制该缓冲区(在我们特定的情况下,这是毫无意义的,但是出于安全原因完成)。

所有这些导致第二个版本肯定更加有效-但同时我仍然会说它的代码更丑陋。


只是有关.NET的一些有趣信息,情况有所不同。.NET StringBuilder在内部修改常规的“字符串”对象,并且toString方法仅返回它(将其标记为不可修改,因此后续的StringBuilder操作将重新创建它)。因此,典型的“新StringBuilder->修改它->转换为字符串”序列不会产生任何额外的副本(仅用于扩展存储或缩小它,如果生成的字符串长度比其容量短得多)。在Java中,此循环始终至少复制一个副本(在StringBuilder.toString()中)。
伊万·杜布罗夫

Sun JDK 1.5之前的版本具有您所假设的优化:bugs.sun.com/bugdatabase/view_bug.do?bug_id=6219959
Dan Berindei 2011年

9

由于我还没有指出这一点,因为Sun Java编译器内置了优化功能,该编译器在看到String串联时会自动创建StringBuilders(J2SE 5.0之前的StringBuffers),因此问题中的第一个示例等效于:

for (loop condition) {
  String s = "some string";
  . . .
  s += anotherString;
  . . .
  passToMethod(s);
}

IMO是一种更好的可读性。您尝试进行优化可能会在某些平台上有所收获,而在其他平台上则可能会蒙受损失。

但是,如果您确实遇到性能问题,那么可以确定,进行优化。我首先要根据Jon Skeet明确指定StringBuilder的缓冲区大小。


4

现代JVM对于此类事情真的很聪明。我不会第二次猜测它,并且会进行一些难以维护/可读性低下的事情……除非您对生产数据做适当的基准测试,以验证性能的飞跃性提高(并记录下来;)


“简单”是关键所在-基准测试可以显示一种形式按比例更快,但没有暗示真实应用程序需要花费多少时间:)
Jon Skeet

请参阅下面我的答案中的基准。第二种方法更快。
Epaga

1
@Epaga:您的基准测试几乎没有说明实际应用程序中的性能改进,与循环的其余部分相比,执行StringBuilder分配所花费的时间可能微不足道。这就是为什么上下文在基准测试中很重要的原因。
乔恩·斯基特

1
@Epaga:在他用他的真实代码对其进行度量之前,我们不知道它到底有多重要。如果循环的每次迭代都有很多代码,我强烈怀疑它仍然是无关紧要的。我们不知道“ ...”中的内容
乔恩·斯凯特

1
(顺便说一句,别误会我-您的基准测试结果本身还是很有趣的。我对微基准测试着迷。我只是不喜欢在执行实际测试之前使代码变形而已。)
乔恩·斯基特(Jon Skeet)

4

根据我在Windows上开发软件的经验,我说在循环中清除StringBuilder的性能要好于每次迭代实例化StringBuilder的性能。清除它可以释放要立即覆盖的内存,而无需其他分配。我对Java垃圾收集器还不太熟悉,但是我认为释放而不进行重新分配(除非您的下一个字符串增长了StringBuilder)比实例化更有益。

(我的观点与其他人的建议背道而驰。嗯。是时候进行基准测试了。)


事实是,无论如何,都必须重新分配更多的内存,因为在先前的循环迭代结束时,新创建的String正在使用现有数据。
乔恩·斯凯特

哦,这很有意义,尽管我有toString在分配并返回一个新的字符串实例,并且为构建器清除了字节缓冲区,而不是重新分配。
cfeduke

Epaga的基准测试表明,清除和重用比每次实例化都要多。
cfeduke

1

进行“ setLength”或“ delete”可以提高性能的原因主要是代码“学习”了缓冲区的正确大小,而很少进行内存分配。通常,我建议让编译器进行字符串优化。但是,如果性能至关重要,我通常会预先计算缓冲区的预期大小。默认的StringBuilder大小为16个字符。如果超出此范围,则必须调整大小。调整大小是性能下降的地方。这是另一个迷你基准说明了这一点:

private void clear() throws Exception {
    long time = System.currentTimeMillis();
    int maxLength = 0;
    StringBuilder sb = new StringBuilder();

    for( int i = 0; i < 10000000; i++ ) {
        // Resetting the string is faster than creating a new object.
        // Since this is a critical loop, every instruction counts.
        //
        sb.setLength( 0 );
        sb.append( "someString" );
        sb.append( "someString2" ).append( i );
        sb.append( "someStrin4g" ).append( i );
        sb.append( "someStr5ing" ).append( i );
        sb.append( "someSt7ring" ).append( i );
        maxLength = Math.max(maxLength, sb.toString().length());
    }

    System.out.println(maxLength);
    System.out.println("Clear buffer: " + (System.currentTimeMillis()-time) );
}

private void preAllocate() throws Exception {
    long time = System.currentTimeMillis();
    int maxLength = 0;

    for( int i = 0; i < 10000000; i++ ) {
        StringBuilder sb = new StringBuilder(82);
        sb.append( "someString" );
        sb.append( "someString2" ).append( i );
        sb.append( "someStrin4g" ).append( i );
        sb.append( "someStr5ing" ).append( i );
        sb.append( "someSt7ring" ).append( i );
        maxLength = Math.max(maxLength, sb.toString().length());
    }

    System.out.println(maxLength);
    System.out.println("Pre allocate: " + (System.currentTimeMillis()-time) );
}

public void testBoth() throws Exception {
    for(int i = 0; i < 5; i++) {
        clear();
        preAllocate();
    }
}

结果表明,重用该对象比创建预期大小的缓冲区快约10%。


1

大声笑,我第一次见过人们通过在StringBuilder中组合字符串来比较性能。为此,如果使用“ +”,它可能会更快; D。使用StringBuilder加速检索整个字符串的目的是“本地性”的概念。

在您频繁检索不需要频繁更改的String值的情况下,Stringbuilder可以提高字符串检索的性能。这就是使用Stringbuilder的目的。.请不要MIS-Test的核心目的。

有人说,飞机飞得更快。因此,我用我的自行车对其进行了测试,发现飞机运动速度较慢。你知道我如何设置实验设置吗?


1

速度没有明显提高,但是从我的测试中可以看出,使用1.6.0_45 64位平均要快几毫秒:使用StringBuilder.setLength(0)而不是StringBuilder.delete():

time = System.currentTimeMillis();
StringBuilder sb2 = new StringBuilder();
for (int i = 0; i < 10000000; i++) {
    sb2.append( "someString" );
    sb2.append( "someString2"+i );
    sb2.append( "someStrin4g"+i );
    sb2.append( "someStr5ing"+i );
    sb2.append( "someSt7ring"+i );
    a = sb2.toString();
    sb2.setLength(0);
}
System.out.println( System.currentTimeMillis()-time );

1

最快的方法是使用“ setLength”。它不会涉及复制操作。创建新的StringBuilder的方法应该完全淘汰。StringBuilder.delete(int start,int end)的速度较慢是因为它将再次为调整大小部分复制该数组。

 System.arraycopy(value, start+len, value, start, count-end);

之后,StringBuilder.delete()将把StringBuilder.count更新为新的大小。虽然StringBuilder.setLength()只是简化更新StringBuilder.count新的大小。


0

首先对人类更好。如果在某些JVM的某些版本上第二个要快一点,那又如何呢?

如果性能至关重要,请绕过StringBuilder并编写自己的代码。如果您是一名优秀的程序员,并且考虑到您的应用程序如何使用此功能,则应该能够使其更快。值得吗?可能不是。

为什么这个问题被称为“最喜欢的问题”?因为性能优化是如此有趣,所以无论它是否实用。


这不仅是一个学术问题。尽管大多数时候(阅读95%)我都喜欢可读性和可维护性,但实际上在某些情况下,几乎没有什么改进会带来很大的不同……
Pier Luigi

好,我将更改答案。如果对象提供了一种允许清除并重新使用它的方法,则可以这样做。如果要确保清除效率高,请先检查代码;也许它发布了一个私有数组!如果有效,则在循环外分配对象并在内部重用。
dongilmore

0

我认为尝试优化这种性能没有意义。今天(2019),我的I5笔记本电脑上的这两个语句都运行了大约11秒,持续了100.000.000次循环:

    String a;
    StringBuilder sb = new StringBuilder();
    long time = 0;

    System.gc();
    time = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        StringBuilder sb3 = new StringBuilder();
        sb3.append("someString");
        sb3.append("someString2");
        sb3.append("someStrin4g");
        sb3.append("someStr5ing");
        sb3.append("someSt7ring");
        a = sb3.toString();
    }
    System.out.println(System.currentTimeMillis() - time);

    System.gc();
    time = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        sb.setLength(0);
        sb.delete(0, sb.length());
        sb.append("someString");
        sb.append("someString2");
        sb.append("someStrin4g");
        sb.append("someStr5ing");
        sb.append("someSt7ring");
        a = sb.toString();
    }
    System.out.println(System.currentTimeMillis() - time);

==> 11000毫秒(循环内声明)和8236毫秒(循环外声明)

即使我正在运行带有数十亿循环的地址去复制程序,也要相差2秒。1亿次循环没有任何区别,因为程序运行了几个小时。另请注意,如果只有一个append语句,情况将有所不同:

    System.gc();
    time = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        StringBuilder sb3 = new StringBuilder();
        sb3.append("someString");
            a = sb3.toString();
    }
    System.out.println(System.currentTimeMillis() - time);

    System.gc();
    time = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        sb.setLength(0);
        sb.delete(0, sb.length());
        sb.append("someString");
        a = sb.toString();
    }
    System.out.println(System.currentTimeMillis() - time);

==> 3416毫秒(内部循环),3555毫秒(外部循环)在这种情况下,在循环内创建StringBuilder的第一条语句更快。而且,如果您更改执行顺序,则速度会更快:

    System.gc();
    time = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        sb.setLength(0);
        sb.delete(0, sb.length());
        sb.append("someString");
        a = sb.toString();
    }
    System.out.println(System.currentTimeMillis() - time);

    System.gc();
    time = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        StringBuilder sb3 = new StringBuilder();
        sb3.append("someString");
            a = sb3.toString();
    }
    System.out.println(System.currentTimeMillis() - time);

==> 3638毫秒(外部循环),2908毫秒(内部循环)

问候,乌尔里希


By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.