Java StringBuilder
Build mutable strings efficiently in Java with the StringBuilder class — append, insert, reverse, and more.
Java StringBuilder
String is immutable; growing one with += in a loop is quadratic. StringBuilder is the standard library's answer: a single object with a resizable internal buffer that you append, insert, delete, and reverse in place, then convert to an immutable String once at the end. It's the workhorse behind every modern JDK string-building pattern, and the right reach the moment you find yourself accumulating text in a loop or across method calls.
Constructing a builder
StringBuilder sb = new StringBuilder(); // empty, capacity 16
StringBuilder withCap = new StringBuilder(1024); // empty, preallocated capacity
StringBuilder fromText = new StringBuilder("hi "); // capacity = length + 16The no-arg constructor starts at capacity 16, which is fine for short results and a pessimistic choice for long ones. If you know roughly how big the final string will be, pass the capacity up front — every avoided grow is one fewer array allocation and one fewer arraycopy. new StringBuilder(estimatedLength) is the single most effective microoptimisation in this whole part of the book.
Chaining: every mutator returns this
Every mutating method on StringBuilder returns the builder itself, so calls compose into a single expression:
String greeting = new StringBuilder()
.append("Hello, ")
.append(name)
.append('!')
.append('\n')
.toString();This is convention, not magic; you could break it across multiple statements with no functional difference. The chained style mirrors how the compiler rewrites + chains under the hood, which is itself the reason the chained form reads naturally in Java.
The mutator family
StringBuilder has a small, focused surface:
append— adds text or any primitive at the end. Overloaded for every primitive,char[],CharSequence, andObject(callstoString).insert(offset, ...)— same overloads, but at an arbitrary position.delete(start, end),deleteCharAt(i)— remove a range or a single character.replace(start, end, replacement)— replace a range with a new substring; lengths can differ.reverse()— flip the buffer in place.setCharAt(i, ch)— rewrite a single character.setLength(n)— truncate (or pad with�) toncharacters.
These edit the buffer; they do not return a new String. To get a snapshot of the contents as an immutable string, call toString().
Inspecting and converting
length()— current character count.capacity()— current size of the internal array. Always ≥length().charAt(i),substring(start)/substring(start, end)— read access, identical toString.indexOf(s),lastIndexOf(s)— locate a substring.toString()— produce an immutableString. Call this once at the end; calling it repeatedly during a build is wasted allocation.ensureCapacity(n)— pre-grow the buffer to at leastn.trimToSize()— shrink the buffer to fit the current contents. Rarely needed.
How the buffer grows
Internally, StringBuilder holds a byte[] (or char[] on older JDKs). When an append would overflow, the buffer is reallocated to roughly 2 × oldCapacity + 2, and the old contents are copied across. Each grow is O(n) in the current size, but the doubling pattern makes the total cost of n appends O(n) amortised — quite unlike repeated String concatenation, where the same loop is O(n²).
append "a" — capacity 16, length 1
... 15 more — capacity 16, length 16
append "b" — grow to 34, length 17
... 17 more — capacity 34, length 34
append "c" — grow to 70, length 35If you know the final length, you skip every one of those reallocations by constructing with that capacity directly.
StringBuilder vs the + operator
For short, statically-known string assembly, the compiler does the right thing on its own. "Hello, " + name + "!" is rewritten at compile time into either a single StringBuilder chain or a call to StringConcatFactory.makeConcatWithConstants (Java 9+). Both are efficient. You don't need to micro-manage these expressions.
The pattern to avoid is += inside a loop over an unknown count:
// O(n²) — every += allocates a new String holding everything seen so far
String out = "";
for (String token : tokens) {
out += token + "|";
}
// O(n) — one buffer, one final String
StringBuilder sb = new StringBuilder();
for (String token : tokens) {
sb.append(token).append('|');
}
String out = sb.toString();If the loop runs a handful of times, the difference is invisible. At a few thousand tokens, it's a benchmark-able problem. At a million, the first form is a hang and the second is milliseconds.
StringBuilder is not thread-safe
StringBuilder deliberately omits synchronization to be fast in the overwhelmingly common single-threaded case. If two threads append to the same builder simultaneously, the results are undefined: lost writes, overwritten characters, or an ArrayIndexOutOfBoundsException from the grow path. For the rare case where one builder is shared across threads, use the synchronised twin — StringBuffer — instead. In practice you almost never share a builder; each thread builds its own.
A worked example
The program below uses every part of the surface that matters in everyday code: chained append, an explicit insert, a replace that changes a range's length, a reverse, and one final toString. The capacity is printed at the start and end to make the buffer doubling visible.
Watch the capacity number. With a starting capacity of 32, every append in the chain fits — no reallocation. The insert and replace shift characters around but don't change the length enough to trip a grow either. That's what pre-sizing buys you: predictable allocation and no copy-on-grow surprises.
What's next
StringBuilder is fast because it's not thread-safe. Its synchronised twin is the right answer in the (rare) case where you genuinely want one buffer shared between threads — same API, methods that lock. Continue to Java StringBuffer.
Practice
Why is `StringBuilder` typically preferred over `String` concatenation in a loop?