Given that your last two tests are identical, you really only have four tests here. For the sake of convenience I have refactored them into separate methods and removed the benchmarking code as it's not necessary to understand what's going on here.
public static void stringBuilderTest(int iterations) {
final StringBuilder sb = new StringBuilder();
for (int i = iterations; i-- > 0;) {
sb.append("a");
}
}
public static void stringBufferTest(int iterations) {
final StringBuffer sb = new StringBuffer();
for (int i = iterations; i-- > 0;) {
sb.append("a");
}
}
public static void emptyStringConcatTest(int iterations) {
String s = new String();
for (int i = iterations; i-- > 0;) {
s += "";
}
}
public static void nonEmptyStringConcatTest(int iterations) {
String s = new String();
for (int i = iterations; i-- > 0;) {
s += "a";
}
}
We already know that the StringBuilder version of the code is the fastest of the four. The StringBuffer version is slower because all of its operations are synchronized, which carries an unavoidable overhead that StringBuilder doesn't have because it isn't synchronized.
So the two methods that we're interested in are emptyStringConcatTest
and nonEmptyStringConcatTest
. If we inspect the bytecode for the compiled version of emptyStringConcatTest
, we see the following:
public static void emptyStringConcatTest(int);
flags: ACC_PUBLIC, ACC_STATIC
LineNumberTable:
line 27: 0
line 28: 8
line 29: 17
line 31: 40
Code:
stack=2, locals=3, args_size=1
0: new #14 // class java/lang/String
3: dup
4: invokespecial #15 // Method java/lang/String."<init>":()V
7: astore_1
8: iload_0
9: istore_2
10: iload_2
11: iinc 2, -1
14: ifle 40
17: new #7 // class java/lang/StringBuilder
20: dup
21: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V
24: aload_1
25: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
28: ldc #16 // String
30: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
33: invokevirtual #17 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
36: astore_1
37: goto 10
40: return
LineNumberTable:
line 27: 0
line 28: 8
line 29: 17
line 31: 40
StackMapTable: number_of_entries = 2
frame_type = 253 /* append */
offset_delta = 10
locals = [ class java/lang/String, int ]
frame_type = 250 /* chop */
offset_delta = 29
Under the hood, the two methods are almost identical, with this line being the only difference:
Empty String:
28: ldc #9 // String
Non-empty String (note the small but important difference!):
28: ldc #9 // String a
The first thing to note about the bytecode is the structure of the for
loop's body:
10: iload_2
11: iinc 2, -1
14: ifle 40
17: new #7 // class java/lang/StringBuilder
20: dup
21: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V
24: aload_1
25: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
28: ldc #16 // String
30: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
33: invokevirtual #17 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
36: astore_1
37: goto 10
What we've actually ended up with here is a compiler optimisation that's turned
for (int i = iterations; i-- > 0;) {
s += "";
}
into:
for (int i = iterations; i-- > 0;) {
s = new StringBuilder().append(s).append("").toString();
}
That's not good. We're instantiating a new temporary StringBuilder object in every single iteration, of which there are 100,000. That's a lot of objects.
The difference you see between the emptyStringConcatTest
and nonEmptyStringConcatTest
can be further explained if we inspect the source code of StringBuilder#append(String)
:
public StringBuilder append(String str) {
super.append(str);
return this;
}
The superclass of StringBuilder is AbstractStringBuilder, so let's take a look at its implementation of append(String)
:
public AbstractStringBuilder append(String str) {
if (str == null) str = "null";
int len = str.length();
if (len == 0) return this;
int newCount = count + len;
if (newCount > value.length)
expandCapacity(newCount);
str.getChars(0, len, value, count);
count = newCount;
return this;
}
You'll notice here that if the length of the parameter str
is zero, the method simply returns without doing any further operations, making it pretty quick in the case of an empty String.
Non-empty String parameters trigger bounds-checking of the backing char[]
, potentially causing it to be resized by expandCapacity(int)
, which copies the original array into a new, larger array (note that the backing array in a StringBuilder is not final
- it can be reassigned!). Once that's done, we call out to String#getChars(int, int, char[], int)
, which does more array copying. The exact implementations of the array copying are hidden away in native code so I'm not going to dig around to find them.
To further compound that, the sheer number of objects that we're creating and then throwing away may be sufficient to trigger a run of the JVM's Garbage Collector, which carries a further overhead with it.
So in summary; the huge drop in performance for your equivalent to nonEmptyStringConcatTest
is largely down to an awful 'optimisation' that the compiler made. Avoid it by never doing direct concatenation inside a loop.