All three hold text. The difference is whether the text can change and who pays for it. A String can never change after it’s created; operations that look like they modify it, such as concat, replace or +, return a new String. StringBuilder is a mutable buffer: append and insert edit it in place. StringBuffer is the same mutable buffer with every method synchronized, a design from Java 1.0 that StringBuilder replaced in Java 5.
So: use String for values you pass around and store, StringBuilder when you assemble text piece by piece (especially in a loop), and StringBuffer almost never. The interview usually continues with “why is += in a loop slow?” and “isn’t StringBuffer safer?”, both covered below.
Comparison table
String |
StringBuilder |
StringBuffer |
|
|---|---|---|---|
| Mutable | No | Yes | Yes |
| Thread-safe | Yes, because it’s immutable | No | Each method is synchronized |
| Since | Java 1.0 | Java 5 | Java 1.0 |
| Cost of appending | New object plus a full copy each time | Amortised O(1) per character | Same, plus taking a lock |
equals compares content |
Yes | No (identity, inherited from Object) |
No (identity) |
Usable as a HashMap key |
Yes, and its hash is cached | Don’t: mutable and identity-based | Don’t |
| Typical use | Values, keys, fields, return types | Building text in a method | Legacy APIs that require it |
Why String is immutable, and what it buys
Immutability is deliberate. Because a String can’t change:
- Literals can be shared.
"OK"written in a hundred classes refers to one pooled object. That’s only safe because nobody can modify it. - The hash code is cached.
StringcomputeshashCode()once and stores it, so repeatedHashMaplookups with the same key don’t rehash the characters. A mutable key would break the map if it changed after insertion. - It’s safe to share between threads with no locking, since there’s nothing to race on.
- It’s safe to trust. A file path or class name checked by one piece of code can’t be altered by the caller afterwards.
The price is that every change produces a new object. For one or two operations that’s cheap and the JIT handles short-lived objects well. In a loop, it adds up quickly.
The loop that gets slower the more you feed it
Here’s the classic slow version, building a comma-separated list of 10,000 ids:
String csv = "";
for (int id : ids) { // 10,000 ids, say 5 digits each plus a comma
csv += id + ",";
}Each += creates a new String that holds everything so far plus the new piece, which means copying everything so far. The first iteration copies 6 characters, the second 12, the third 18, and the last about 60,000. Adding that up gives 6 × (1 + 2 + … + 10,000), roughly 300 million character copies, to produce a 60,000-character result. Double the input and the work quadruples: it’s O(n²).
The builder version copies each character into the buffer once, plus occasional growth:
StringBuilder sb = new StringBuilder(ids.size() * 6); // optional: size it up front
for (int id : ids) {
sb.append(id).append(',');
}
String csv = sb.toString();StringBuilder starts with room for 16 characters unless you pass a capacity. When it fills up, it allocates a bigger array (roughly double plus two) and copies across. Starting from 16, reaching 60,000 characters takes about a dozen of those reallocations, and because the sizes double, the total copying stays below about twice the final length. That’s O(n) overall. Passing an estimated capacity skips the reallocations entirely; it’s a nice touch, not a requirement.
The arithmetic above was checked in Python. The Java snippets in this guide are illustrative (Java 17 syntax, not compiled here), so measure your own case with JMH before quoting timings. The complexity argument doesn’t depend on the machine.
For this particular job, String.join(",", parts) or stream.map(String::valueOf).collect(Collectors.joining(",")) is clearer still, and both use a builder internally.
What the compiler already does with +
Candidates sometimes claim you should never use + with strings. That’s wrong. A single expression like "Order " + id + " for " + name + " failed" is compiled efficiently. Before Java 9, javac turned it into a StringBuilder chain; since Java 9 (JEP 280) it emits an invokedynamic call to StringConcatFactory, which lets the JVM pick a fast strategy at runtime. Either way you get one result string without intermediate copies, and it’s more readable than an explicit builder.
What the compiler can’t do is carry one builder across loop iterations. Each csv += ... in the loop is a separate statement producing a separate String, so the quadratic copying remains. The rule is “don’t concatenate in a loop”, not “don’t use +”. Compile-time constants are a third case: "a" + "b" is folded into "ab" by the compiler, with no runtime work at all.
Isn’t StringBuffer the safe choice?
StringBuffer synchronizes each method, so two threads calling append at the same moment won’t corrupt its internal array. It sounds safer, but it rarely helps, for two reasons.
First, nearly every builder is a local variable inside one method call. Each thread (each request, each task) creates its own, so there’s nothing shared to protect. Using StringBuffer there just adds locking you don’t need.
Second, when a buffer genuinely is shared, per-method locking usually isn’t the right unit. Two threads appending log lines with buf.append(time).append(" ").append(msg).append("\n") will interleave their pieces, because each append locks separately and the chain as a whole isn’t atomic. You’d need an outer synchronized block anyway, at which point a StringBuilder inside that block does the job.
The JVM can sometimes remove locks on objects that never escape a thread, and biased locking used to make uncontended locks cheap, but biased locking was disabled by default in JDK 15. Don’t build an argument on those optimisations. Choose StringBuilder because it states the intent (a thread-confined buffer), and keep StringBuffer for older APIs that demand it, such as Matcher.appendReplacement before Java 9 added StringBuilder overloads.
Equality and other traps
StringBuilder and StringBuffer don’t override equals or hashCode. Two builders with the same text aren’t equals:
StringBuilder a = new StringBuilder("ab");
StringBuilder b = new StringBuilder("ab");
a.equals(b); // false: compares references
a.toString().equals(b.toString()); // true
"ab".contentEquals(a); // true: String can compare against any CharSequence
a.compareTo(b) == 0; // true since Java 11, which made builders ComparableA related mistake is storing builders in a Set or using them as map keys, which works by identity and breaks as soon as someone appends.
Other points interviewers like:
toString()copies the buffer into a newString, so later appends don’t affect the string you already got.reverse()exists on both builders and handles surrogate pairs correctly, so emoji and other characters outside the Basic Multilingual Plane stay intact.- Since Java 9, strings that fit in Latin-1 are stored one byte per character (“compact strings”), so memory estimates of two bytes per character are often too high.
Practice question: the slow report and the thread-safety argument
A service builds a 50,000-line audit report with report += line + "\n" and takes several seconds per request. A reviewer suggests switching to StringBuffer “because this method is called from many request threads at once”. Fix the method and respond to the review comment.
Answer
The slowness is quadratic copying: each += creates a new String containing the whole report so far, so total work grows with the square of the line count. Replace it with one builder for the whole loop, optionally presized:
StringBuilder report = new StringBuilder(lines.size() * 80);
for (String line : lines) {
report.append(line).append('\n');
}
return report.toString();That’s linear in the size of the report. On the review comment: many threads calling the method doesn’t mean they share a buffer. report is a local variable, so every call has its own builder, confined to its thread. StringBuffer would add a lock to every append and protect nothing. StringBuilder is correct here, and if the lines already sit in a collection, String.join("\n", lines) is shorter still. A strong answer also notes that if the report is streamed to a client or file, writing lines directly to a Writer avoids holding the whole report in memory.