What is the difference between String, StringBuilder and StringBuffer?

Asked constantlyjunior0–6 yrs9 min readJava 5 (1.5)Java 8LTSJava 9

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.

  • String is immutable. Every operation that looks like a change returns a new object; the original is untouched.
  • StringBuilder is a mutable buffer — a resizable array you append into, with no copy per append.
  • StringBuffer is StringBuilder with every method synchronized. It predates StringBuilder and is almost always the wrong choice.
  • The difference only matters in a loop. One a + b is fine; s += x a 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 to invokedynamic, not to a StringBuilder chain. 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 appendTotal for n appends
StringBuilder.appendcopy 1 charO(n)
s += "x"copy the whole string so farO(n²)

That's the whole entry. The rest is measuring it and knowing when it doesn't matter.

What the difference costs

Compiled and run on this build — output varies between runs
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)));
Output
s += "x"        168ms
sb.append("x")  4ms
same result: true

Identical 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:

Compiled and run on this build
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());
Output
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=100

Three 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:

Compiled and run on this build
System.out.println("StringBuilder.append synchronized : " + isAppendSynchronized(StringBuilder.class));
System.out.println("StringBuffer.append  synchronized : " + isAppendSynchronized(StringBuffer.class));
Output
StringBuilder.append synchronized : false
StringBuffer.append  synchronized : true

StringBuffer 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:

Compiled and run on this build
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());
}
Output
"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:

Compiled and run on this build
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(" | ")));
Output
java, collections, hashmap
[java, collections, hashmap]
JAVA | COLLECTIONS | HASHMAP

All 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 interviewsReality
"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?

AnswerEach += 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?

Answer21. 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?

AnswerNot 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

TierExerciseTime
Warm-upMeasure the quadratic curve5 min
ChallengeFix the report builder20 min
ProductionThe export that timed out at 50,000 rows45 min
InterviewFull round replay10 min

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Where this question goes next

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.