What is the difference between String, StringBuilder and StringBuffer?
String is immutable, so every change allocates a new one. StringBuilder is a growable buffer you mutate in place. StringBuffer is the same thing with every method synchronized, which you almost never need. Concatenating in a loop is the only case where the difference is dramatic — and it is dramatic.
The Answer
Say this in the room. 45 seconds.
Stringis immutable. Every operation that looks like a change returns a new object; the original is untouched.StringBuilderis a mutable buffer — a resizable array you append into, with no copy per append.StringBufferisStringBuilderwith every methodsynchronized. It predatesStringBuilderand is almost always the wrong choice.- The difference only matters in a loop. One
a + bis fine;s += xa thousand times is quadratic, because each+copies everything so far. - Measured on JDK 21 at 30,000 appends: about 40x slower with
+=. - Since Java 9, a single
+compiles toinvokedynamic, not to aStringBuilderchain. The loop advice survives; the bytecode explanation doesn't.
Understand It
One buffer versus n copies
StringBuilder holds a byte[] you write into. Appending copies your characters into free space at the end — no new object, no copying of what's already there.
String has no mutator at all, so s += "x" cannot extend anything. It must build a brand-new String containing everything so far plus one character, then point s at it. The old one becomes garbage.
Add up the copying across a loop of n appends:
| Work per append | Total for n appends | |
|---|---|---|
StringBuilder.append | copy 1 char | O(n) |
s += "x" | copy the whole string so far | O(n²) |
That's the whole entry. The rest is measuring it and knowing when it doesn't matter.
What the difference costs
int n = 30_000;
// Untimed pass first, so the JIT has compiled both loops before we measure.
sink(byConcatenation(n).length() == n);
sink(byBuilder(n).length() == n);
long concat = ms(() -> sink(byConcatenation(n).length() == n));
long builder = ms(() -> sink(byBuilder(n).length() == n));
System.out.println("s += \"x\" " + concat + "ms");
System.out.println("sb.append(\"x\") " + builder + "ms");
System.out.println("same result: " + byConcatenation(n).equals(byBuilder(n)));s += "x" 168ms
sb.append("x") 4ms
same result: trueIdentical output, two orders of magnitude apart. And the gap widens with n — double the input and the builder doubles its time while the concatenation quadruples.
Caveat: crude timing, one JVM, no JMH. The exact milliseconds vary per run, which is why this block is marked as such. The direction and the scale of it do not.
Capacity, and the growth you can watch
The buffer starts at 16 characters and roughly doubles when full — 2 × old + 2, to be exact:
StringBuilder sb = new StringBuilder();
System.out.println("new StringBuilder() capacity=" + sb.capacity() + " length=" + sb.length());
sb.append("0123456789abcdef"); // exactly 16
System.out.println("after 16 chars capacity=" + sb.capacity() + " length=" + sb.length());
sb.append("g"); // one more forces a grow
System.out.println("after 17 chars capacity=" + sb.capacity() + " length=" + sb.length());
System.out.println("new StringBuilder(\"abc\") capacity=" + new StringBuilder("abc").capacity());
System.out.println("new StringBuilder(100) capacity=" + new StringBuilder(100).capacity());new StringBuilder() capacity=16 length=0
after 16 chars capacity=16 length=16
after 17 chars capacity=34 length=17
new StringBuilder("abc") capacity=19
new StringBuilder(100) capacity=100Three things worth noticing.
capacity() and length() are different questions — how much room there is, versus how much you've used. Only length() affects the string you get out.
new StringBuilder("abc") gives capacity 19, not 3: the initial content plus the usual 16 of headroom. So constructing from a seed string does not leave you one append from a resize.
Each grow allocates a bigger array and copies everything across. If you know the final size, new StringBuilder(expectedSize) skips every resize. Worth doing in a hot loop, not worth thinking about elsewhere.
StringBuffer is not the thread-safe answer
The only difference is the lock, and you can check that rather than take it on faith:
System.out.println("StringBuilder.append synchronized : " + isAppendSynchronized(StringBuilder.class));
System.out.println("StringBuffer.append synchronized : " + isAppendSynchronized(StringBuffer.class));StringBuilder.append synchronized : false
StringBuffer.append synchronized : trueStringBuffer came first, in Java 1.0. Java 5 added StringBuilder as the same class with the locks removed, because the overwhelming majority of string building happens on one thread inside one method, where a lock buys nothing.
But note what StringBuffer actually guarantees: each individual call is atomic. That is rarely what you need. Two threads appending to a shared buffer still produce interleaved nonsense in an unpredictable order — the lock stops corruption of the array, not the logical race. If two threads are building one string you have a design problem, and a synchronized buffer does not solve it.
Use StringBuilder. If you think you need StringBuffer, you probably need to not share the buffer.
The trap in + versus concat
They look interchangeable and differ on null:
String nothing = null;
System.out.println("\"x\" + null = " + ("x" + nothing));
try {
"x".concat(nothing);
} catch (NullPointerException e) {
System.out.println("\"x\".concat(null) -> " + e.getClass().getSimpleName());
}"x" + null = xnull
"x".concat(null) -> NullPointerException+ is defined to convert null to the four characters null, which is how "user: null" ends up in your logs instead of an exception telling you something upstream is broken. concat throws instead. Neither is wrong — but + silently hiding a null is worth knowing about when you're reading a log line that makes no sense.
When the difference does not matter
The advice gets over-applied. These are all fine:
String greeting = "Hello, " + name + "!"; // one expression, one pass
String path = base + "/" + file; // ditto
log.info("user {} did {}", userId, action); // no concatenation at all
A single concatenation expression is compiled into one invokedynamic call that sizes the result and fills it once. Rewriting that as a StringBuilder makes the code longer and no faster. Reach for the builder when you are appending in a loop, or across several statements, or conditionally.
And for joining with a delimiter, neither is the right answer since Java 8:
List<String> tags = List.of("java", "collections", "hashmap");
System.out.println(String.join(", ", tags));
StringJoiner joiner = new StringJoiner(", ", "[", "]");
tags.forEach(joiner::add);
System.out.println(joiner);
System.out.println(tags.stream().map(String::toUpperCase).collect(Collectors.joining(" | ")));java, collections, hashmap
[java, collections, hashmap]
JAVA | COLLECTIONS | HASHMAPAll three handle the separator-between-not-after problem for you. The manual version — append a comma each time, then deleteCharAt(sb.length() - 1) — is still everywhere, and every instance of it is a small bug waiting for an empty list.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "Why is String concatenation in a loop slow?"
Because String is immutable, so s += "x" cannot extend anything — it allocates a new String containing everything so far plus the addition, and copies. Over n iterations that's O(n²) copying. Measured on JDK 21 at 30,000 appends: roughly 40x slower than a builder, and the gap grows with n.
2. "Does + compile to StringBuilder?"
Not since Java 9. It compiles to invokedynamic makeConcatWithConstants, and the JVM builds the result in a single sized pass. Up to Java 8 javac did emit a StringBuilder chain. The loop advice is unchanged, because each + still produces a complete new immutable String regardless of how it's implemented.
3. "So is "a" + b + c a performance problem?"
No. One expression is one concatenation call. The problem is repeated concatenation across iterations or statements, where each step copies the accumulated result. Rewriting a single expression as a builder is noise.
4. "StringBuilder or StringBuffer?"
StringBuilder, essentially always. StringBuffer predates it and synchronizes every method; Java 5 added the unsynchronized version because string building is almost always thread-confined. And StringBuffer only makes individual calls atomic — two threads appending still interleave, so it doesn't fix a shared-buffer design anyway.
5. "What's the default capacity, and why care?"
16 characters, growing to 2n + 2 on overflow — so 16 → 34. Each grow allocates and copies. If you know the final size, pass it to the constructor and skip every resize. Note new StringBuilder("abc") starts at 19: the seed plus 16.
6. "Difference between + and concat on null?"
+ converts null to the literal text "null". concat throws NullPointerException. That's why nulls quietly reach your logs as the word null instead of failing loudly.
7. "How do you join a list with commas?"
String.join(", ", list), StringJoiner if you need a prefix and suffix, or Collectors.joining inside a stream. All are Java 8. The append-then-delete-the-trailing-comma pattern is the wrong answer, and it breaks on an empty list.
Code traps
Trap A — predict before you run:
StringBuilder sb = new StringBuilder("abc");
sb.append("def");
String s = sb.toString();
sb.append("ghi");
System.out.println(s);
System.out.println(sb);
Answer
abcdef then abcdefghi. toString() copies the buffer's current contents into a new immutable String — it does not give you a live view. Appending afterwards cannot change s. This is the one place StringBuilder's mutability stops: the moment you call toString() you're back in immutable-String land.
Trap B:
String a = "x";
StringBuilder b = new StringBuilder("x");
System.out.println(a.equals("x"));
System.out.println(b.equals(new StringBuilder("x")));
System.out.println(b.toString().equals("x"));
Answer
true, false, true. StringBuilder does not override equals(), so it's identity comparison — two builders holding identical text are never equal, and sb1.equals(sb2) is always false for distinct instances. It doesn't override hashCode() either, which means a StringBuilder is useless as a HashMap key for the opposite reason to a mutable key: not because the hash changes, but because it was never content-based. Always compare toString(), or use compareTo (added in Java 11).
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "Always use StringBuilder instead of +." | A single concatenation expression is one call. Only loops matter. |
| "The compiler turns + into StringBuilder." | invokedynamic makeConcatWithConstants since Java 9. |
| "Use StringBuffer when threads are involved." | It only makes single calls atomic; the interleaving race remains. |
| "StringBuilder's default capacity is 10." | 16. And new StringBuilder("abc") starts at 19, not 3. |
"sb1.equals(sb2) compares the text." | No equals() override — identity only. Compare toString(). |
| "String is slow." | String is immutable. In a loop that's slow; everywhere else it's a feature. |
Check Yourself
Q1. In one sentence, why is s += "x" in a loop quadratic?
Answer
Each += must allocate a new String holding everything accumulated so far plus the addition, so the copying cost grows with the length on every iteration — summing to O(n²) across the loop.
Q2. new StringBuilder("hello").capacity() — what and why?
Answer
21. The constructor allocates the seed string's length plus 16 characters of headroom, so you don't hit a resize on the first append. ("abc" gives 19 by the same rule.)
Q3. Two threads append to one shared StringBuffer. Is the result correct?
Answer
Not in any useful sense. Each append is atomic so the internal array isn't corrupted, but the order the two threads interleave in is unpredictable, so the text is garbage. StringBuffer prevents data-structure corruption, not logical races — if two threads are building one string, the design is wrong.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Measure the quadratic curve | 5 min |
| Challenge | Fix the report builder | 20 min |
| Production | The export that timed out at 50,000 rows | 45 min |
| Interview | Full round replay | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 5 (1.5)
StringBuilder arrives — StringBuffer's logic with the synchronization removed, and the default choice from then on.
Before Java 5 (1.5): StringBuffer was the only mutable string type, and every append took and released a lock even in single-threaded code that could never contend.
- Java 8LTS
StringJoiner and String.join() handle delimited output, including the separators between elements, without manual index checks.
Before Java 8: You appended a delimiter every iteration and then deleted the trailing one, or tracked a boolean for the first element. Both are still common and both are avoidable.
- Java 9
String concatenation with + compiles to invokedynamic makeConcatWithConstants, and the JVM sizes and fills the result in one pass (JEP 280).
Before Java 9 (now gone): javac emitted an explicit StringBuilder chain — new StringBuilder().append(a).append(b).toString(). This is why 'the compiler turns + into StringBuilder' was true and is now a decade out of date.
- Java 9
Compact strings: the buffer holds one byte per character for Latin-1 text, so capacity is measured in characters but costs half the memory it used to.
Before Java 9 (now gone): Always char[] — two bytes per character, even for pure ASCII.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up5 min
Measure the quadratic curve
One concept, guided. Near-impossible to fail.
- Challenge20 min
Fix the report builder
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The export that timed out at 50,000 rows
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — String vs StringBuilder
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- Why is String immutable, and what is the string pool?
- text blocks — not written yet
- string formatting — not written yet
- Is Java pass-by-value or pass-by-reference?
Questions that lead here
Why is String immutable, and what is the string pool?
String is immutable because nothing in its API exposes a mutator — not because its byte[] field is final. The pool is a JVM-wide cache of literals that makes == accidentally work on literals and fail everywhere else.
Asked constantlyintermediate0–8 yrs9 min readStrings
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-27.