Topic 6.3
StringBuilder and StringBuffer
In one line
StringBuilder is a mutable, growable buffer of characters for building text step by step without creating a new String on every change; call toString() once at the end. StringBuffer is the older, synchronized twin, rarely needed today.
Think of it like this
Writing a shopping list. With immutable Strings you'd have to copy the whole list onto a fresh sheet of paper every time you add one item: tea (new sheet), tea and samosa (new sheet), tea, samosa and cake (new sheet). A StringBuilder is a notepad: you keep writing on the same page, cross things out, insert a line, and only at the end tear off a clean copy (toString()) to hand over.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Mutable
- Can be changed after it's created. A
StringBuilderis mutable; aStringis not. - Buffer
- A block of memory used to collect data before it's used, like a notepad for characters.
- Capacity
- How many characters the builder's internal array can hold before it must grow. It's at least as big as
length(). - Amortised cost
- The average cost per operation over a long run, counting the occasional expensive step (like regrowing) spread across all the cheap ones.
- Method chaining
- Calling one method after another on the same object in one expression, possible because each method returns that object.
- synchronized
- A Java keyword that lets only one thread at a time run a piece of code on the same object (Phase 13).
- Thread
- One independent path of execution inside a program. Several threads can run at the same time (Topic 13.1).
Step by step
01The cost of + in a loop
String s = ""; for (...) s = s + i; looks harmless. But Strings are immutable (Topic 6.1), so each + builds a new String containing all old characters plus the new ones. Pass 1 copies 1 character, pass 2 copies 2, pass n copies n. Total: about n²/2 copies, and every old String becomes garbage.
Since Java 9, javac compiles a + b + c into one invokedynamic call that sizes the result once, which makes a single expression fast. It can't help a loop: each pass is still a separate expression creating a separate String.
02Creating a builder and appending
new StringBuilder() starts empty with capacity 16. new StringBuilder("tea") starts with that text and capacity 3 + 16. new StringBuilder(1000) starts empty with room for 1000 characters, useful when you know roughly how big the result will be.
append is overloaded for every type: String, char, int, long, double, boolean, Object (calls String.valueOf, so null becomes the text null), char[] and any CharSequence. Each returns this.
StringBuilder sb = new StringBuilder();
sb.append("Total: ").append(3).append(" items, paid=").append(true);
String result = sb.toString(); // "Total: 3 items, paid=true"03Editing in the middle
insert(i, x) shifts everything from index i right and writes x there. delete(start, end) removes [start, end) and shifts the tail left. replace(start, end, str) does both. These are O(n) because characters after the edit point move, just like inserting into the middle of an ArrayList (Topic 9.2).
Indexes are checked: insert(10, "x") on a 3-character builder throws StringIndexOutOfBoundsException. delete is forgiving about end: an end past the length is clamped to the length.
reverse() reverses the characters, and it's Unicode-aware: a surrogate pair (an emoji, Topic 6.8) stays in the right order instead of being split.
04How the internal array grows
Inside, a builder (via AbstractStringBuilder) has a byte[] value, a coder (Latin-1 or UTF-16, the same compact-strings idea as String) and an int count of used characters. length() is count; capacity() is the array size.
When an append needs more room than the capacity, the builder grows to about 2 * old + 2 (or exactly what's needed, if that's larger), allocates the new array, copies the old one in, and continues. Doubling is why appends are amortised O(1): the big copies happen rarely.
setLength(n) cuts (or pads with '\u0000') the text without shrinking the array. trimToSize() shrinks the array to fit, which only matters for builders you keep around for a long time.
05toString() makes an independent copy
toString() creates a new String from the used part of the array. Later changes to the builder don't touch that String. That's what lets the builder be mutable while everything it produced stays immutable.
Because each call copies, don't call toString() inside a loop just to check something; use length(), charAt() or indexOf() on the builder directly.
06StringBuffer: the synchronized twin
StringBuffer and StringBuilder share the parent AbstractStringBuilder and have the same methods. The difference is that StringBuffer's methods are synchronized: a thread must take the buffer's lock before each call (Topic 13.3).
Even so, a sequence like if (buf.length() > 0) buf.append(",") isn't atomic across the two calls, so StringBuffer rarely gives real thread safety for compound logic. The modern approach is to give each thread its own StringBuilder and combine results afterwards.
07Joining: String.join and StringJoiner
Building a, b, c with a loop means handling the separator: either skip it before the first item or cut the last one off with setLength(sb.length() - 2).
String.join(", ", items) (Java 8) does it in one call for arrays and any Iterable. StringJoiner adds a prefix, a suffix and a value to use when nothing was added. Collectors.joining (Topic 10.6) is the stream version; all three use a StringBuilder inside.
StringJoiner j = new StringJoiner(", ", "[", "]");
j.add("tea").add("samosa");
System.out.println(j); // [tea, samosa]Try it yourself
- 1
Predict the capacity
In the capacity example, after the 17th character, append 18 more characters (
"abcdefghijklmnopqr"). Predict the new capacity using2 * old + 2, then run and check. - 2
Reverse a sentence's words
Using only a
StringBuilderandString.split(" "), turn"chai is hot"into"hot is chai". Predict where you need to add the spaces, then run. - 3
Feel the n² cost
Copy the timing sketch into a
Mainclass and run it withnat 10,000, then 100,000. The+=time should grow about 100 times for 10 times more items, while the builder grows about 10 times. That's what O(n²) vs O(n) looks like.
Code & diagrams
Expected output
appended: tea and samosa!2
inserted: Order: tea and samosa!2
replaced: Order: chai and samosa!2
deleted: Order: chai!
setCharAt: order: chai!
reversed: !iahc :redro
length: 12, indexOf("iahc"): 1
append returns the same builder: trueThe growth formula is an implementation detail of current JDKs; only the amortised O(1) behaviour is promised.
Expected output
empty: length 0, capacity 16
16 chars: length 16, capacity 16
17 chars: length 17, capacity 34
setLength(5): length 5, capacity 34
trimToSize: length 5, capacity 5
text now: 01234
new StringBuilder("hello"): length 5, capacity 21
new StringBuilder(100): length 0, capacity 100StringBuilder.compareTo arrived in Java 11; String.join and StringJoiner in Java 8.
Expected output
builder: tea, samosa, cake
String.join: tea, samosa, cake
join a List: a | b | c
StringJoiner: [tea, samosa, cake]
empty joiner: nothing ordered
x.equals(y): false
x.compareTo(y) == 0: true
contentEquals: trueint n = 100_000;
long t0 = System.nanoTime();
String s = "";
for (int i = 0; i < n; i++) {
s += i; // new String every pass: O(n^2) copying
}
long t1 = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i); // amortised O(1) per append
}
String fast = sb.toString();
long t2 = System.nanoTime();
System.out.println("+= : " + (t1 - t0) / 1_000_000 + " ms");
System.out.println("builder : " + (t2 - t1) / 1_000_000 + " ms");
// Typical laptop: "+=" takes seconds, the builder a few milliseconds.
// Use JMH (Topic 14.6) for trustworthy numbers.StringBuffer log = new StringBuffer(); // every method is synchronized
Runnable task = () -> {
for (int i = 0; i < 10_000; i++) {
log.append('x'); // safe: one thread at a time per call
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start(); b.start();
a.join(); b.join();
System.out.println(log.length()); // always 20000
// With StringBuilder the length can come out short, or append can even throw
// ArrayIndexOutOfBoundsException, because two threads grow the array at once.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Assign a literal to a StringBuilder
Write StringBuilder sb = "hello"; and then String s = sb;.
Break #2
Insert past the end
Call new StringBuilder("abc").insert(10, "x").
Break #3
Use a StringBuilder as a map key
Put new StringBuilder("tea") into a HashSet, then check set.contains(new StringBuilder("tea")).
Myth vs fact
Myth
The compiler turns every + into a StringBuilder, so + in loops is fine.
Fact
The compiler optimises each single expression. In a loop, every pass is a new expression that creates a new String and copies everything so far, which is O(n²) overall.
Myth
StringBuffer is the thread-safe choice, so use it to be safe.
Fact
Its per-call locking costs time and doesn't make multi-call logic atomic. Build text per thread with StringBuilder; share results through proper concurrent tools (Phase 13).
Myth
Always use StringBuilder instead of +.
Fact
For a one-line expression like "Hi " + name + "!", + is just as fast (Java 9+ sizes it in one go) and easier to read. Reach for a builder when you build text across a loop or many statements.
Myth
toString() hands out the builder's internal array.
Fact
It copies the characters into a new String, so the String stays immutable when the builder changes later.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Java 9's indy string concatenation (JEP 280) compiles
a + b + ctoinvokedynamicbootstrapped byStringConcatFactory.makeConcatWithConstants. The strategy computes the exact length and fills one array, often beating a hand-writtenStringBuilderfor single expressions. Compile with-XDstringConcat=inlineto get the oldStringBuilderchains. - ▸
Pre-sizing (
new StringBuilder(expectedLength)) avoids repeated grow-and-copy steps. A builder that ever held a non-Latin-1 character switches its whole array to UTF-16 (inflate), doubling its memory, and doesn't switch back. - ▸
The JIT's escape analysis can sometimes eliminate builder allocation entirely for short-lived local builders, and HotSpot has had special
-XX:+OptimizeStringConcathandling forStringBuilder.append().toString()chains for years. - ▸
StringBuilderimplementsComparable<StringBuilder>since Java 11, but still notequals.CharSequence.compare(a, b)(Java 11) compares any twoCharSequences by content.
Remember this
- 1
A **
StringBuilder** (injava.lang, added in Java 5) holds a mutable sequence of characters in an internal array that has spare room, its capacity.appendwrites into that spare room, so adding text usually costs only the characters copied, not a whole new object. When the room runs out, the builder allocates a bigger array (roughly double) and copies the old contents across, so a long series of appends costs amortised O(1) per character. - 2
Most methods change the builder in place and return the same builder, so calls chain:
sb.append("tea").append(' ').append(3). The main ones:append(anything),insert(index, x),delete(start, end),deleteCharAt(i),replace(start, end, str),setCharAt(i, c),reverse(),setLength(n), plus read methodslength(),charAt(i),indexOf(str)andsubstring(...). Ranges are start inclusive, end exclusive, likeString.substring. - 3
toString()copies the current characters into a new, immutable String. The builder stays usable: change it again and calltoString()again for another String. Earlier Strings never change, because they're separate copies. - 4
Why bother?
s = s + xinside a loop creates a brand-new String each pass and copies everything built so far into it: for n pieces that's about n²/2 character copies and n throwaway objects. A builder copies each character about once (plus occasional regrowth). For 100,000 appends the difference is milliseconds versus many seconds. - 5
**
StringBuffer** (Java 1.0) has the same methods, but every one issynchronized, so it's safe for several threads to call at once. That locking costs time, and sharing one text buffer between threads is rare, soStringBuilderreplaced it for everyday use. You'll still meetStringBufferin old code and a few old APIs (likeMatcher.appendReplacementbefore Java 9). - 6
Builders don't override
equalsorhashCode: two builders with the same text are notequals(they use identity, fromObject). Compare contents withsb1.compareTo(sb2) == 0(Java 11),sb.toString().equals(...)or"text".contentEquals(sb), and never use a builder as aHashMapkey. For joining with separators,String.joinandStringJoiner(Java 8) are often clearer than a hand-written loop.
Explain it without notes
Why is s = s + x in a loop slow, and how does StringBuilder fix it?
What's the difference between length() and capacity(), and how does the capacity grow?
When would you use StringBuffer instead of StringBuilder?
Why can't you use equals to compare two StringBuilders?
Is "Hello, " + name + "!" slower than using a StringBuilder?
Practice
Write static String repeatWithDash(String word, int times) that returns e.g. tea-tea-tea for ("tea", 3), using a StringBuilder. Print the results for 3 and 1.
Write static boolean isPalindrome(String s) using StringBuilder.reverse(), ignoring case. Test "Level", "chai" and "Racecar".
Build a multiplication table line for 7 (7 x 1 = 7 up to 7 x 5 = 35), one per line, in a single StringBuilder, and print it once.
Trade-offs
- ↔
StringBuilderis fast for building text step by step, but it's mutable: never share one across threads or store one as a key; convert to a String when the text is finished. - ↔
+is clearest for single expressions and is optimised since Java 9; a builder adds noise there. Use the builder for loops and multi-step assembly. - ↔
Pre-sizing a builder saves copies but wastes memory if you guess far too high. Size it when you know the order of magnitude, otherwise let it grow.
Done when you can
Done when you can explain why
+=in a loop is O(n²) and a builder is O(n).Done when you can use append, insert, delete, replace, reverse and setLength confidently.
Done when you can explain length vs capacity and how the array grows.
Done when you know why builders aren't
equalsand how to compare them.Done when you can choose between StringBuilder, StringBuffer,
String.joinandStringJoiner.