在循环中重用StringBuilder是否更好?

110

我有一个与使用StringBuilder相关的性能问题。

在一个非常长的循环中,我正在操作一个StringBuilder并像这样将其传递给另一个方法:

StringBuilder sb = new StringBuilder();
for (int i = 0; i < veryLargeNumber; i++) {
    // manipulate sb
}
someOtherMethod(sb.toString());
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());
}
15个回答

71

在我的小型基准测试中,第二个的速度快了大约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

5
我已经在我的回答中添加了编辑,以解释为什么会发生这种情况。稍后我会更仔细地查看(45分钟)。请注意,在append调用中进行连接降低了使用StringBuilder的初衷 :) - Jon Skeet
3
如果你反转这两个块的顺序,看看会发生什么也许很有趣——在第一次测试期间JIT仍在“热身”StringBuilder。这可能是无关紧要的,但值得尝试。 - Jon Skeet
1
我仍然会选择第一个版本,因为它更加“清晰”。但你实际上进行了基准测试是很好的 :)下一个建议的更改:尝试将适当的容量传递到构造函数中的第一种方法。 - Jon Skeet
27
使用sb.setLength(0);来清空StringBuilder的内容是最快的方法,而不需要重新创建对象或使用.delete()。请注意,这不适用于StringBuffer,因为它的并发检查会抵消速度优势。 - P Arrayah
2
低效的答案。P Arrayah和Dave Jarvis是正确的。setLength(0)是迄今为止最有效的答案。StringBuilder由char数组支持并且是可变的。在调用.toString()时,char数组被复制并用于支持不可变字符串。此时,可以通过将插入指针移回零(通过.setLength(0))来重新使用StringBuilder的可变缓冲区。sb.toString创建另一个副本(不可变的char数组),因此每次迭代都需要两个缓冲区,而不是.setLength(0)方法每个循环只需要一个新缓冲区。 - Chris
显示剩余5条评论

27

更快的方法:

public class ScratchPad {

    private static String a;

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

        // Pre-allocate enough space to store all appended strings.
        // StringBuilder, ultimately, uses an array of characters.
        final 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( final 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由字符数组支持并且是可变的。在调用.toString()时,字符数组被复制并用于支持一个不可变字符串。此时,可以通过将插入指针移到0(通过.setLength(0))来重新使用StringBuilder的可变缓冲区。那些建议每个循环分配一个全新的StringBuilder的答案似乎没有意识到.toString会创建另一个副本,因此每次迭代都需要两个缓冲区,而.setLength(0)方法每次循环只需要一个新缓冲区。 - Chris

25

在编写高质量代码的哲学中,把 StringBuilder 放在循环体内总是更好的选择。这样它就不会超出预期范围。

其次,为 StringBuilder 分配一个初始大小是提高性能的最大因素之一,可以避免在循环运行时 StringBuilder 不断增长。

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

1
你可以始终使用花括号来限定整个内容,这样你就不需要将Stringbuilder放在外面了。 - Epaga
@Epaga:它仍然在循环外部。是的,它不会污染外部范围,但对于尚未在上下文中验证的性能改进来说,这是一种不自然的编写代码的方式。 - Jon Skeet
甚至更好的是,将整个内容放入自己的方法中。;-) 但我明白你所说的上下文。 - Epaga
最好使用预期的大小初始化,而不是随意的数字(例如4096)。你的代码可能会返回一个引用大小为4096的char[]数组的字符串(这取决于JDK;据我记得这在1.4版本中是这样的情况)。 - kohlerm

12

好的,我现在明白了正在发生什么,也有意义。

我一直以为 toString 只是将底层的 char[] 传递给一个不复制的字符串构造函数。下一次“写”操作(例如 delete)将进行拷贝。我相信在之前的某个版本中 StringBuffer 是这种情况。(现在不是了。)但不是这样的 - toString 只是将数组(和索引和长度)传递给公共的 String 构造函数,该构造函数会进行拷贝。

因此,在“重用 StringBuilder”的情况下,我们真正为每个字符串创建一份数据的副本,在缓冲区中始终使用相同的字符数组。显然,每次创建新的 StringBuilder 都会创建一个新的底层缓冲区,然后在创建新字符串时也会将该缓冲区复制(在我们特定的情况下有些毫无意义,但出于安全考虑仍然这样做)。

所有这些都导致第二个版本肯定更有效率,但同时我仍然认为它的代码更丑陋。


只是一些有趣的关于 .NET 的信息,它们的情况不同。 .NET StringBuilder 内部修改普通的字符串对象,toString 方法只是简单地返回它(将其标记为不可修改,因此后续的 StringBuilder 操作将重新创建它)。因此,典型的“new StringBuilder->修改它->to String”序列不会产生任何额外的副本(仅用于扩展存储或缩小存储,如果结果字符串长度比其容量短得多)。在 Java 中,这个周期总是至少复制一次(在 StringBuilder.toString() 中)。 - Ivan Dubrov
Sun JDK 1.5之前的版本具有您所假定的优化功能:http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6219959 - Dan Berindei

9

因为Sun Java编译器内置了优化功能,当它看到字符串拼接时自动创建StringBuilders(在J2SE 5.0之前是StringBuffers),所以问题中的第一个示例等同于:

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

在我看来,更易读的方法是更好的方法。尽管你试图进行优化,可能会在某些平台上获得收益,但也可能会在其他平台上损失。

但是,如果你真的遇到了性能问题,那么可以进行优化。不过,我建议首先按照Jon Skeet的建议显式指定StringBuilder的缓冲区大小。


6

现代JVM非常聪明,对这样的事情处理得很好。除非您使用实际生产数据进行了适当的基准测试,并验证了非平凡的性能改进(并记录下来;),否则不要去猜测和实现一些不可维护/难以理解的hacky方法。


请看我下面的答案中的基准测试。第二种方法更快。 - Epaga
1
@Epaga:你的基准测试对于真正的应用程序性能提升说不了什么,因为与循环中其他操作相比,执行StringBuilder分配所花费的时间可能微不足道。这就是为什么基准测试中上下文的重要性。 - Jon Skeet
1
@Epaga:在他用真正的代码进行测量之前,我们将不知道它的重要性。如果每次循环迭代都有大量的代码,我强烈怀疑它仍然是无关紧要的。我们不知道“…”中有什么。 - Jon Skeet
1
别误解我的意思,顺便说一句 - 你的基准测试结果本身仍然非常有趣。我很着迷于微型基准测试。只是在进行真实测试之前,我不喜欢扭曲我的代码形式。 - Jon Skeet
明智的话,我认为我们完全同意。 :-) - Epaga
显示剩余2条评论

4

根据我在Windows上开发软件的经验,我认为在循环过程中清除StringBuilder比每次迭代实例化一个StringBuilder性能更好。清除它可以立即释放该内存以供重写,无需额外的分配。我不太熟悉Java垃圾收集器,但我认为释放并且没有重新分配(除非您的下一个字符串增加了StringBuilder)比实例化更有益。

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


问题在于,更多的内存必须被重新分配,因为现有的数据正在被前一个循环迭代末尾新创建的字符串使用。 - Jon Skeet
哦,这很有道理,我原本以为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
最快的方法是使用“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 简单地更新至新的大小。


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 );

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