Java中toString()方法中的StringBuilder与String拼接的区别

1111

下面有两个toString()实现,哪一个更好:

public String toString(){
    return "{a:"+ a + ", b:" + b + ", c: " + c +"}";
}
或者
public String toString(){
    StringBuilder sb = new StringBuilder(100);
    return sb.append("{a:").append(a)
          .append(", b:").append(b)
          .append(", c:").append(c)
          .append("}")
          .toString();
}

更重要的是,考虑到我们只有3个属性,可能没有什么区别,但你从何时开始切换使用 StringBuilder 而不是 + 进行字符串拼接呢?


62
何时应该使用 StringBuilder?当涉及到内存或性能问题时,或者可能会出现这些问题时。如果您只是偶尔处理几个字符串,那么没有太大问题。但如果您需要反复进行此类操作,则使用 StringBuilder 可以明显提高性能。 - ewall
1
参数中100的含义是什么? - Asif Mushtaq
3
@UnKnown 100 是 StringBuilder 的初始大小。 - non sequitor
1
@nonsequitor 那么最大字符数是100个? - Asif Mushtaq
12
不仅仅是初始大小,如果你知道处理的字符串大致大小,那么你可以告诉StringBuilder预先分配多少空间,否则当它用完空间时,会不得不通过创建新的char[]数组并复制数据来将大小加倍,这是代价高昂的。你可以通过指定大小来欺骗StringBuilder,这样就不需要创建新的数组——所以如果你认为你的字符串长度约为100个字符,那么你可以将StringBuilder设置为该大小,它就不必内部扩展了。 - non sequitor
显示剩余2条评论
20个回答

1121

版本1更好,因为它更短,并且编译器实际上会将其转换为版本2——没有任何性能差异。

更重要的是,我们只有3个属性,可能不会有什么区别,但是什么时候切换从concat到builder呢?

在循环中连接字符串时,就需要使用StringBuilder了,通常此时编译器无法自动进行替换。


88
不要重复老生常谈,但规范中的措辞是:“为了提高字符串重复连接的性能,Java编译器可能使用StringBuffer类或类似技术来减少评估表达式时创建的中间String对象的数量。” 关键词是“可能”。考虑到这是正式可选的(尽管很可能已实现),我们不应该保护自己吗? - Lucas
121
@Lucas:不,我们不应该这样做。如果编译器决定不执行该优化,那是因为它不值得。在99%的情况下,编译器知道哪种优化更值得,所以一般情况下开发者不应该干预。当然,你的情况可能属于另外的1%,但只有通过(慎重的)基准测试才能确定。 - sleske
21
@sleske,我不认为你是对的。编译器在寻找可行的优化方案时具有有限的能力。它不能替你思考。 - Marcin
6
@Vach:规范实际上说“可以使用StringBuffer类或类似技术”。提到StringBuffer有点过时了(特别是它在Java 8的当前JLS中仍然没有变化),但仅此而已。此外,如果现代JVM可以确定对象永远不会被不同的线程访问,它通常可以消除同步代码中的锁定。 - Michael Borgwardt
7
我认为这并不总是正确的。最近我优化了一个循环中的代码,通过删除所有的 "+" 并使用 StringBuilder 替换它们,性能从大约 20 秒内的 ~500 次迭代提高到 5 秒内的 35,000 次迭代。我对这种巨大的差异感到非常震惊。我还替换了一些整数的连接,使用 String.format 方法调用,这可能也有助于提高性能;实际上,我不确定哪一种改变对性能影响最大。但是假设 "+" 总是相当好的是错误的。 - Gullbyrd
显示剩余8条评论

302

关键在于你是一次性地在一个地方写入拼接,还是随着时间的推移逐步累加它。

对于你提供的示例,显式使用StringBuilder没有意义。(查看第一个情况的已编译代码。)

但如果你正在循环内构建字符串,那么应该使用StringBuilder。

为了澄清,假设hugeArray包含数千个字符串,像这样的代码:

...
String result = "";
for (String s : hugeArray) {
    result = result + s;
}

相比之下,这种方法非常浪费时间和内存:

...
StringBuilder sb = new StringBuilder();
for (String s : hugeArray) {
    sb.append(s);
}
String result = sb.toString();

7
StringBuilder 不需要一遍又一遍地重新创建 String 对象。 - Olga
180
该死,我使用这两个函数来测试我正在处理的一个大字符串。 6.51分钟对比11秒。 - Mewster
1
顺便提一下,在第一个示例中,您也可以使用 result += s; - randers
1
这个语句会创建多少个对象? "{a:"+ a + ", b:" + b + ", c: " + c +"}"; - Asif Mushtaq
2
像这样的代码怎么样:String str = (a == null) ? null : a' + (b == null) ? null : b' + (c == null) ? c : c' + ...; ? 那会防止优化发生吗? - amitfr
显示剩余6条评论

90
在大多数情况下,您不会看到两种方法之间的实际差异,但很容易构造出一个最坏情况,如下所示:
public class Main
{
    public static void main(String[] args)
    {
        long now = System.currentTimeMillis();
        slow();
        System.out.println("slow elapsed " + (System.currentTimeMillis() - now) + " ms");

        now = System.currentTimeMillis();
        fast();
        System.out.println("fast elapsed " + (System.currentTimeMillis() - now) + " ms");
    }

    private static void fast()
    {
        StringBuilder s = new StringBuilder();
        for(int i=0;i<100000;i++)
            s.append("*");      
    }

    private static void slow()
    {
        String s = "";
        for(int i=0;i<100000;i++)
            s+="*";
    }
}

输出结果为:

slow elapsed 11741 ms
fast elapsed 7 ms
问题在于使用 to += 对字符串进行追加时会重建一个新的字符串,所以它的成本与你的字符串长度成线性关系(两个字符串长度之和)。
因此 - 对于你的问题:
第二种方法可能更快,但可读性较差且难以维护。就像我说的,在你特定的情况下,你可能看不到这种差异。

11
虽然您对+=的理解是正确的,但原始示例是一系列+,编译器将其转换为单个的string.concat调用。因此您的结论不适用于该示例。 - Blindy
1
@Blindy和Droo:你们两个都对。在这种情况下使用.concate是最好的解决方法,因为+=会在每次循环过程执行时创建新对象。 - perilbrain
3
他的 toString() 在循环中没有被调用,你知道吗? - Omry Yadan
String.concat 比 StringBuffer.append 快大约两倍。 - jitendra varshney
可能是在 String.concat 存在之前,这个答案就已经存在了。 但请用代码来证明,而不是用言语 :) - Omry Yadan
显示剩余5条评论

75

我更喜欢:

String.format( "{a: %s, b: %s, c: %s}", a, b, c );

由于它简短易读,我不会优化这个代码的速度,除非你在一个重复次数非常高的循环中使用它并且已经测量了性能差异。

我同意,如果你需要输出大量的参数,这种形式可能会变得混乱(就像其中一条评论所说的那样)。在这种情况下,我会转向更易读的形式(也许使用apache-commons的ToStringBuilder - 参考matt b的答案),并再次忽略性能。


70
实际上,它更长,包含更多符号,并且具有文本顺序不连续的变量。 - Tom Hawtin - tackline
4
那么你会说它的可读性比其他方法中的一个要差吗? - tangens
84
看起来对我来说阅读更困难了。现在我必须在{...}和参数之间来回扫描。 - Steve Kuo
10
我更喜欢这种形式,因为如果其中一个参数是“null”,它是安全的。 - rds
5
如果 b 为空,那么简单的字符串连接 a+b 和使用 format 的结果是一样的。这两种方法都会将字符串 "null" 添加到末尾。 - Navin
显示剩余17条评论

34
自 Java 1.5 以来,使用“+”和 StringBuilder.append() 进行简单的单行字符串连接会生成完全相同的字节码。因此,为了代码可读性,请使用“+”。
有两个例外:
1. 多线程环境下:使用 StringBuffer。 2. 循环中的字符串连接:使用 StringBuilder/StringBuffer。

4
在Java 1.5之前,使用"+"和StringBuffer.append()进行简单的一行拼接生成了完全相同的字节码(因为StringBuilder不存在)。自Java 9开始,使用"+"进行简单的一行连接可能会产生比StringBuilder更好的代码。 - Holger

29

我和我的老板也在是否使用append或+上发生了冲突。由于他们使用的是append(我仍然无法弄清楚,因为他们说每次都会创建一个新对象),所以我想做一些研究。虽然我喜欢Michael Borgwardt的解释,但我只是想展示一下如果有人将来真的需要知道的话的说明。

/**
 *
 * @author Perilbrain
 */
public class Appc {
    public Appc() {
        String x = "no name";
        x += "I have Added a name" + "We May need few more names" + Appc.this;
        x.concat(x);
        // x+=x.toString(); --It creates new StringBuilder object before concatenation so avoid if possible
        //System.out.println(x);
    }

    public void Sb() {
        StringBuilder sbb = new StringBuilder("no name");
        sbb.append("I have Added a name");
        sbb.append("We May need few more names");
        sbb.append(Appc.this);
        sbb.append(sbb.toString());
        // System.out.println(sbb.toString());
    }
}

上述类的反汇编结果如下

 .method public <init>()V //public Appc()
  .limit stack 2
  .limit locals 2
met001_begin:                                  ; DATA XREF: met001_slot000i
  .line 12
    aload_0 ; met001_slot000
    invokespecial java/lang/Object.<init>()V
  .line 13
    ldc "no name"
    astore_1 ; met001_slot001
  .line 14

met001_7:                                      ; DATA XREF: met001_slot001i
    new java/lang/StringBuilder //1st object of SB
    dup
    invokespecial java/lang/StringBuilder.<init>()V
    aload_1 ; met001_slot001
    invokevirtual java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lan\
g/StringBuilder;
    ldc "I have Added a nameWe May need few more names"
    invokevirtual java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lan\
g/StringBuilder;
    aload_0 ; met001_slot000
    invokevirtual java/lang/StringBuilder.append(Ljava/lang/Object;)Ljava/lan\
g/StringBuilder;
    invokevirtual java/lang/StringBuilder.toString()Ljava/lang/String;
    astore_1 ; met001_slot001
  .line 15
    aload_1 ; met001_slot001
    aload_1 ; met001_slot001
    invokevirtual java/lang/String.concat(Ljava/lang/String;)Ljava/lang/Strin\
g;
    pop
  .line 18
    return //no more SB created
met001_end:                                    ; DATA XREF: met001_slot000i ...

; ===========================================================================

;met001_slot000                                ; DATA XREF: <init>r ...
    .var 0 is this LAppc; from met001_begin to met001_end
;met001_slot001                                ; DATA XREF: <init>+6w ...
    .var 1 is x Ljava/lang/String; from met001_7 to met001_end
  .end method
;44-1=44
; ---------------------------------------------------------------------------


; Segment type: Pure code
  .method public Sb()V //public void Sb
  .limit stack 3
  .limit locals 2
met002_begin:                                  ; DATA XREF: met002_slot000i
  .line 21
    new java/lang/StringBuilder
    dup
    ldc "no name"
    invokespecial java/lang/StringBuilder.<init>(Ljava/lang/String;)V
    astore_1 ; met002_slot001
  .line 22

met002_10:                                     ; DATA XREF: met002_slot001i
    aload_1 ; met002_slot001
    ldc "I have Added a name"
    invokevirtual java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lan\
g/StringBuilder;
    pop
  .line 23
    aload_1 ; met002_slot001
    ldc "We May need few more names"
    invokevirtual java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lan\
g/StringBuilder;
    pop
  .line 24
    aload_1 ; met002_slot001
    aload_0 ; met002_slot000
    invokevirtual java/lang/StringBuilder.append(Ljava/lang/Object;)Ljava/lan\
g/StringBuilder;
    pop
  .line 25
    aload_1 ; met002_slot001
    aload_1 ; met002_slot001
    invokevirtual java/lang/StringBuilder.toString()Ljava/lang/String;
    invokevirtual java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lan\
g/StringBuilder;
    pop
  .line 28
    return
met002_end:                                    ; DATA XREF: met002_slot000i ...


;met002_slot000                                ; DATA XREF: Sb+25r
    .var 0 is this LAppc; from met002_begin to met002_end
;met002_slot001                                ; DATA XREF: Sb+9w ...
    .var 1 is sbb Ljava/lang/StringBuilder; from met002_10 to met002_end
  .end method
;96-49=48
; ---------------------------------------------------------------------------

从上面的两段代码中,您可以看到Michael是正确的。在每种情况下只创建一个SB对象。


23

使用最新版本的Java(1.8),反汇编(javap -c)显示编译器引入的优化。使用+sb.append()将生成非常相似的代码。但是,如果我们在for循环中使用+,检查其行为将是值得的。

在for循环中使用+添加字符串

Java:

public String myCatPlus(String[] vals) {
    String result = "";
    for (String val : vals) {
        result = result + val;
    }
    return result;
}

字节码:(for循环摘录)

12: iload         5
14: iload         4
16: if_icmpge     51
19: aload_3
20: iload         5
22: aaload
23: astore        6
25: new           #3                  // class java/lang/StringBuilder
28: dup
29: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
32: aload_2
33: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
36: aload         6
38: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
41: invokevirtual #6                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
44: astore_2
45: iinc          5, 1
48: goto          12

使用StringBuilder.append添加字符串

Java:

public String myCatSb(String[] vals) {
    StringBuilder sb = new StringBuilder();
    for(String val : vals) {
        sb.append(val);
    }
    return sb.toString();
}

字节码:(for循环摘录)

17: iload         5
19: iload         4
21: if_icmpge     43
24: aload_3
25: iload         5
27: aaload
28: astore        6
30: aload_2
31: aload         6
33: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
36: pop
37: iinc          5, 1
40: goto          17
43: aload_2

然而有一个明显的区别。在第一种情况下,使用 + 的时候,每个 for 循环迭代都会创建一个新的 StringBuilder,并通过执行 toString() 调用来存储生成的结果(第29到41行)。因此,当在 for 循环中使用 + 运算符时,您会生成不需要的中间字符串。


1
这是Oracle JDK还是OpenJDK? - Christophe Roussy
1
@ChristopheRoussy不重要,因为它们包含完全相同的代码。 - Holger
@Holger,根据Heinz Kabutz的说法:“OpenJDK与Oracle JDK的代码99%相同(取决于您从哪个提供商获取),因此这实际上归结为支持问题。”不确定那1%的差异在哪里或者这是否仍然正确。 - Christophe Roussy
2
@ChristopheRoussy 可能是许可证头。我怀疑“99%”不是一个精确的测量数字。它更多地表示“如果你发现了一个无关紧要的差异,请不要再回来挑剔我”。 - Holger

23

这取决于字符串的大小。

请看下面的示例:

static final int MAX_ITERATIONS = 50000;
static final int CALC_AVG_EVERY = 10000;

public static void main(String[] args) {
    printBytecodeVersion();
    printJavaVersion();
    case1();//str.concat
    case2();//+=
    case3();//StringBuilder
}

static void case1() {
    System.out.println("[str1.concat(str2)]");
    List<Long> savedTimes = new ArrayList();
    long startTimeAll = System.currentTimeMillis();
    String str = "";
    for (int i = 0; i < MAX_ITERATIONS; i++) {
        long startTime = System.currentTimeMillis();
        str = str.concat(UUID.randomUUID() + "---");
        saveTime(savedTimes, startTime);
    }
    System.out.println("Created string of length:" + str.length() + " in " + (System.currentTimeMillis() - startTimeAll) + " ms");
}

static void case2() {
    System.out.println("[str1+=str2]");
    List<Long> savedTimes = new ArrayList();
    long startTimeAll = System.currentTimeMillis();
    String str = "";
    for (int i = 0; i < MAX_ITERATIONS; i++) {
        long startTime = System.currentTimeMillis();
        str += UUID.randomUUID() + "---";
        saveTime(savedTimes, startTime);
    }
    System.out.println("Created string of length:" + str.length() + " in " + (System.currentTimeMillis() - startTimeAll) + " ms");
}

static void case3() {
    System.out.println("[str1.append(str2)]");
    List<Long> savedTimes = new ArrayList();
    long startTimeAll = System.currentTimeMillis();
    StringBuilder str = new StringBuilder("");
    for (int i = 0; i < MAX_ITERATIONS; i++) {
        long startTime = System.currentTimeMillis();
        str.append(UUID.randomUUID() + "---");
        saveTime(savedTimes, startTime);
    }
    System.out.println("Created string of length:" + str.length() + " in " + (System.currentTimeMillis() - startTimeAll) + " ms");

}

static void saveTime(List<Long> executionTimes, long startTime) {
    executionTimes.add(System.currentTimeMillis() - startTime);
    if (executionTimes.size() % CALC_AVG_EVERY == 0) {
        out.println("average time for " + executionTimes.size() + " concatenations: "
                + NumberFormat.getInstance().format(executionTimes.stream().mapToLong(Long::longValue).average().orElseGet(() -> 0))
                + " ms avg");
        executionTimes.clear();
    }
}

输出:

java字节码版本:8
java.version: 1.8.0_144
[str1.concat(str2)]
10000次连接的平均时间:0.096毫秒
10000次连接的平均时间:0.185毫秒
10000次连接的平均时间:0.327毫秒
10000次连接的平均时间:0.501毫秒
10000次连接的平均时间:0.656毫秒
创建长度为1950000的字符串,用时:17745毫秒
[str1+=str2]
10000次连接的平均时间:0.21毫秒
10000次连接的平均时间:0.652毫秒
10000次连接的平均时间:1.129毫秒
10000次连接的平均时间:1.727毫秒
10000次连接的平均时间:2.302毫秒
创建长度为1950000的字符串,用时:60279毫秒
[str1.append(str2)]
10000次连接的平均时间:0.002毫秒
10000次连接的平均时间:0.002毫秒
10000次连接的平均时间:0.002毫秒
10000次连接的平均时间:0.002毫秒
10000次连接的平均时间:0.002毫秒
创建长度为1950000的字符串,用时:100毫秒

随着字符串长度的增加,+=.concat 的连接时间也会增加,后者更有效率但仍然不是常数级别。这就是为什么一定需要使用StringBuilder的原因。
附注:我认为 何时在Java中使用StringBuilder 并不是这个问题的重复。这个问题讨论了toString(),它大多数情况下不会对巨大的字符串进行连接。

2019更新

自从java8时代以来,情况有所改变。现在(java13),+=的连接时间似乎与str.concat()几乎相同。然而,StringBuilder的连接时间仍然是恒定的。(上面的原始帖子已稍作编辑,以添加更详细的输出)

java字节码版本:13 java版本:13.0.1 [str1.concat(str2)] 10000次字符串连接的平均时间:0.047毫秒平均值 10000次字符串连接的平均时间:0.1毫秒平均值 10000次字符串连接的平均时间:0.17毫秒平均值 10000次字符串连接的平均时间:0.255毫秒平均值 10000次字符串连接的平均时间:0.336毫秒平均值 创建长度为1950000的字符串:9147毫秒 [str1+=str2] 10000次字符串连接的平均时间:0.037毫秒平均值 10000次字符串连接的平均时间:0.097毫秒平均值 10000次字符串连接的平均时间:0.249毫秒平均值 10000次字符串连接的平均时间:0.298毫秒平均值 10000次字符串连接的平均时间:0.326毫秒平均值 创建长度为1950000的字符串:10191毫秒 [str1.append(str2)] 10000次字符串连接的平均时间:0.001毫秒平均值 10000次字符串连接的平均时间:0.001毫秒平均值 10000次字符串连接的平均时间:0.001毫秒平均值 10000次字符串连接的平均时间:0.001毫秒平均值 10000次字符串连接的平均时间:0.001毫秒平均值 创建长度为1950000的字符串:43毫秒 值得注意的是,bytecode:8/java.version:13 的组合相较于 bytecode:8/java.version:8 有很好的性能优势。

这应该是被接受的答案..它取决于字符串流的大小,决定使用concat或StringBuilder。 - user1428716
1
实际上这是针对不同情况的。OP并没有在循环中进行连接。在内联连接的情况下,StringBuilder实际上效率更低,可以参考@wayne的答案。 - klarki
@klarki 事实上,它在 toString() 中使用并不排除需要连接一个巨大的字符串(即使只有一次)。我示例中的循环仅仅是展示了,你想要连接的字符串越大,使用 .concat() 所需的时间就越长。 - Marinos An
对于Java 11,它是:[str1.concat(str2)] 在4486毫秒内创建了长度为1950000的字符串 [str1+=str2] 在5582毫秒内创建了长度为1950000的字符串 [str1.append(str2)] 在36毫秒内创建了长度为1950000的字符串 - Ankush

18

在Java 9中,版本1应该会更快,因为它被转换为invokedynamic调用。有关详细信息,请参见JEP-280

  

这个想法是用一个简单的invokedynamic调用java.lang.invoke.StringConcatFactory来替换整个StringBuilder追加操作,以接受需要连接的值。


9
出于性能方面的考虑,不建议使用+=String连接)。原因是:Java String是不可变的,每次进行新的连接操作都会创建一个新的String(新的字符串与已经在字符串池中的旧字符串具有不同的指纹)。创建新的字符串会给垃圾回收器带来压力,并减慢程序的速度:对象的创建是昂贵的。
下面的代码应该使它更加实用和清晰。
public static void main(String[] args) 
{
    // warming up
    for(int i = 0; i < 100; i++)
        RandomStringUtils.randomAlphanumeric(1024);
    final StringBuilder appender = new StringBuilder();
    for(int i = 0; i < 100; i++)
        appender.append(RandomStringUtils.randomAlphanumeric(i));

    // testing
    for(int i = 1; i <= 10000; i*=10)
        test(i);
}

public static void test(final int howMany) 
{
    List<String> samples = new ArrayList<>(howMany);
    for(int i = 0; i < howMany; i++)
        samples.add(RandomStringUtils.randomAlphabetic(128));

    final StringBuilder builder = new StringBuilder();
    long start = System.nanoTime();
    for(String sample: samples)
        builder.append(sample);
    builder.toString();
    long elapsed = System.nanoTime() - start;
    System.out.printf("builder - %d - elapsed: %dus\n", howMany, elapsed / 1000);

    String accumulator = "";
    start = System.nanoTime();
    for(String sample: samples)
        accumulator += sample;
    elapsed = System.nanoTime() - start;
    System.out.printf("concatenation - %d - elapsed: %dus\n", howMany, elapsed / (int) 1e3);

    start = System.nanoTime();
    String newOne = null;
    for(String sample: samples)
        newOne = new String(sample);
    elapsed = System.nanoTime() - start;
    System.out.printf("creation - %d - elapsed: %dus\n\n", howMany, elapsed / 1000);
}

下面是一次运行的结果报告。

builder - 1 - elapsed: 132us
concatenation - 1 - elapsed: 4us
creation - 1 - elapsed: 5us

builder - 10 - elapsed: 9us
concatenation - 10 - elapsed: 26us
creation - 10 - elapsed: 5us

builder - 100 - elapsed: 77us
concatenation - 100 - elapsed: 1669us
creation - 100 - elapsed: 43us

builder - 1000 - elapsed: 511us
concatenation - 1000 - elapsed: 111504us
creation - 1000 - elapsed: 282us

builder - 10000 - elapsed: 3364us 
concatenation - 10000 - elapsed: 5709793us
creation - 10000 - elapsed: 972us

不考虑1个连接的结果(JIT尚未发挥作用),即使10个连接,性能损失也是显着的; 对于成千上万次的连接,差异是巨大的。从这个非常快速的实验中学到的教训(可以通过上述代码轻松重现):即使在需要少量连接的基本情况下(如上所述,创建新字符串本身就很昂贵并对GC施加压力),也不要使用“+ =”来连接字符串。

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