skip to content
everything.deepDives.stringConcatenation

string concatenation internals

from StringBuilder to invokedynamic and StringConcatFactory.

chapter
chapter 17.04
reading time
9 min read

before java 9, javac turned a + b + c into a StringBuilder chain baked into your class file. since java 9 (JEP 280) it emits one invokedynamic instead, and the first time it runs, StringConcatFactory decides how to build the string. on jdk 25 that means generating a tiny class that measures every piece, allocates one exact-size array, and fills it. the one thing that didn't change: += in a loop is still quadratic.

the picture

the strings chapter says "use + for one-liners, StringBuilder in loops". that advice is still right. the reason behind the first half changed completely in java 9, and almost nobody noticed, because the source code didn't change at all.

think of ordering a cake. javac 8 wrote the recipe into your class file step by step (get a bowl, add flour, oh the bowl's too small, get a bigger bowl...). javac 9+ writes "a cake, these ingredients" on the order slip, and the bakery (the JDK) decides how to make it, the first time you order. when the bakery gets better equipment, every old order slip benefits.

same method, same javac from jdk 25, two flags:

terminal
$ javac Concat.java && javap -c Concat
static String greet(String, int);
   0: aload_0
   1: iload_1
   2: invokedynamic #7, 0  // makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;
   7: areturn

the method is return "hi " + name + ", you have " + n + " messages";. the hidden -XDstringConcat=inline flag gives you the old shape on a new compiler (--release 8 produces the exact same 34 bytes). four instructions against fifteen, and notice what's missing from the indy version: the constants. they moved into the bootstrap arguments as a recipe:

terminal
$ javap -v Concat | grep -A3 BootstrapMethods
BootstrapMethods:
  0: #21 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(...)
    Method arguments:
      #19 hi \u0001, you have \u0001 messages

\u0001 marks "an argument goes here". (a \u0002 marks a constant passed separately, which javac only needs when your literal itself contains \u0001 or \u0002.)

what happens on the first call

an invokedynamic instruction starts out unlinked. the first time it runs, the JVM calls its bootstrap method, gets back a CallSite, and from then on that instruction jumps straight to the call site's target. the general mechanism is in invokedynamic. for string concatenation, the bootstrap is StringConcatFactory.makeConcatWithConstants:

hover a stage.

hover a stage. everything left of the call site happens once per concat expression, the first time it runs. every later call is just the generated method.

on jdk 25 the "generate" branch writes a hidden class called java.lang.String$$StringConcat with the ClassFile API, and caches it by argument shape, so every (Object, int) concat in the process shares one class and only the constants differ. you can dump it with a system property and read it with javap:

terminal
$ java -Djava.lang.invoke.StringConcatFactory.dump=true Run
hi ada, you have 3 messages
$ javap -c -p 'stringConcatClasses/java.lang.String$$StringConcat.0x00000fff01040800.class'
final class java.lang.String$$StringConcat extends java.lang.StringConcatHelper$StringConcatBase {
  final java.lang.String concat(java.lang.Object, int);
     ...
    24: invokestatic  #44   // Method length:(ILjava/lang/String;I)I
     ...
    49: invokestatic  #57   // StringConcatHelper.newArrayWithSuffix:(Ljava/lang/String;IB)[B
     ...
    64: invokestatic  #59   // Method prepend:(IB[B[Ljava/lang/String;Ljava/lang/String;I)I
    67: new           #50   // class java/lang/String
    75: invokespecial #62   // String."<init>":([BB)V

read the calls in order: add up the lengths, allocate the array with the last constant already at the end, prepend everything else backwards, wrap the array in a String. that's the whole algorithm.

one array instead of three

here's what that buys, for one call of our method with name = "ada" and n = 3:

"hi " + name + ", you have " + n + " messages" · name = "ada", n = 3
StringBuilder chainjavac 8, or -XDstringConcat=inline
·
nothing allocated yet
arrays allocated: none
generated concatjdk 25, behind invokedynamic
·
nothing allocated yet
arrays allocated: none

same expression, two compilations. left: what javac 8 emitted (and what -XDstringConcat=inline still emits). right: what jdk 25 generates behind the invokedynamic.

step 1/7
step through both. amber bytes were just written; red arrays are already garbage. the builder guesses, grows, and copies at the end. the generated concat knows the size up front and never copies.

measured on jdk 25 on an apple M4 Pro, 200,000 calls per run (50 million for the warmed-up row). allocation comes from the JVM's own per-thread byte counter, time from System.nanoTime. there's no JMH here, so read the times as rough and the bytes as exact:

modeindy (jdk 25 default)StringBuilder chain
interpreted (-Xint)~1.3 µs, 74 bytes per call~1.9 µs, 186 bytes per call
C1 only (TieredStopAtLevel=1)47 to 92 ns, 74 bytes~63 ns, 186 bytes
fully warmed (C2)10 to 13 ns, 50 bytes10 to 15 ns, 50 bytes
C2 with -XX:-OptimizeStringConcat(same as above)17 to 22 ns, 162 bytes

the last two rows are the honest part. once C2 compiles the method, both versions cost the same, because C2 has a pass (OptimizeStringConcat) that recognizes StringBuilder append chains and rewrites them into exactly what the indy version does. turn that pass off and the builder is back to allocating three times as much.

so indy's win isn't "faster at peak". it's that the good version doesn't depend on C2 noticing your pattern: it's good in the interpreter, good in C1, good in code C2 never gets to. and the bytecode is a quarter the size, which helps inlining budgets.

loops still need StringBuilder

invokedynamic makes one expression efficient. it can't see across loop iterations, so s += x in a loop still builds a brand new String every time, copying everything so far. that's O(n²) bytes copied:

java
String s = "";
for (int i = 0; i < n; i++) s += i % 10;   // copies 1, 2, 3 ... n chars

measured on the same machine: each method run 10 times to warm up, then 11 timed runs, median shown. separate JVM per method.

+=, 1k114 µs
builder, 1k26 µs
+=, 10k1,751 µs
builder, 10k41 µs
+=, 50k44,700 µs
builder, 50k179 µs
+=, 100k186,450 µs
builder, 100k312 µs
log scale
log scale. the += loop roughly quadruples every time n doubles; the StringBuilder barely moves. at 100,000 iterations it's 186 ms against 0.3 ms (the bars are in microseconds).
don't+= in a loop
java
String csv = "";
for (var row : rows) {
    csv += row.id() + "," + row.name() + "\n";
}
doone builder, or a joiner
java
var sb = new StringBuilder(rows.size() * 32);
for (var row : rows) {
    sb.append(row.id()).append(',').append(row.name()).append('\n');
}
String csv = sb.toString();

(String.join, Collectors.joining and StringJoiner are the same idea with less typing.)

the small print

predict the output
java
char c = 'a';
int n = 1;
IO.println(c + n + "!");
IO.println("!" + c + n);
java
void main() {
    char c = 'a';
    int n = 1;
    IO.println(c + n + "!");
    IO.println("!" + c + n);
    String s = null;
    IO.println("x" + s);
}
output
98!
!a1
xnull
  • null is fine. concatenating null gives the text "null", no exception, in both the old and the new scheme.
  • 200 slots, max. the bootstrap rejects more than 200 argument slots (a long or double takes two). javac knows, and splits huge expressions: a 250-int concat compiled on jdk 25 became three invokedynamics, 198 arguments, then 52, then one joining the two halves.
  • constants are folded first. "a" + "b" never reaches any of this; javac turns it into the constant "ab". that's what makes == behave strangely, see the string pool.
keylines
  • javac 9+ compiles a + b + c to one invokedynamic. the constants live in a recipe string.
  • the first call bootstraps through StringConcatFactory, which picks a strategy and links a call site for good.
  • jdk 25 generates a hidden class that sizes the result exactly, allocates once, and fills it back to front.
  • at C2 peak, old and new cost the same (C2 rewrites builder chains too). the win is everywhere else, and in letting the JDK improve without recompiling you.
  • += in a loop is still O(n²). use StringBuilder, String.join or a joining collector.