JAVA / STRINGS AND TEXT HANDLING
Building strings efficiently with StringBuilder
Accumulate text in a single mutable StringBuilder, size and rewind its buffer on purpose, and turn it into a String exactly once.
What you will learn
- Accumulate loop output in one builder and call toString() only after the last append.
- Read length() as characters written and capacity() as room left before the next copy.
- Preallocate with new StringBuilder(n) and recycle a builder with setLength(0).
- Compare builder text with toString().equals or contentEquals, never with equals.
Understanding Building strings efficiently with StringBuilder
A StringBuilder is a mutable array of characters plus a count of how many you have actually written. append does not create anything new: it writes your characters into the free space after the count, bumps the count, and returns the same builder so the next call continues where the last one stopped. That is why the object identity never changes across appends, and why chaining reads as one statement: sb.append(name).append(", ") is two writes into one array, not two intermediate results.
Two numbers describe a builder. length() is the text you have written; capacity() is how many characters fit before the array has to be replaced. When an append does not fit, the builder allocates a larger array (HotSpot picks twice the old size plus two) and copies the existing characters across once. Because the size doubles instead of growing by one, the total copying needed to build n characters stays proportional to n, and that is the entire reason a builder beats adding pieces one at a time.
toString() is the one place a copy is unavoidable: it snapshots the buffer into an immutable String, so call it once when the text is finished rather than inside the loop still filling it. Reach for a builder when the number of pieces is decided at run time (loop bodies, if/else branches, recursive walks) and pass a size estimate to the constructor when you have one, since a bigger initial array costs nothing extra. If you produce many strings in a row, setLength(0) rewinds the count to zero while keeping the array you already grew.
public class BuildReport {
public static void main(String[] args) {
String[] names = {"ada", "linus", "grace", "ken"};
StringBuilder sb = new StringBuilder(64);
sb.append("users: ");
for (String name : names) {
sb.append(name).append(", ");
}
sb.setLength(sb.length() - 2); // drop the trailing ", "
sb.append(" (").append(names.length).append(')');
System.out.println(sb);
System.out.println("length=" + sb.length() + " capacity=" + sb.capacity());
String result = sb.toString();
sb.setLength(0); // rewind, keep the 64-char array
sb.append("reversed: ").append(new StringBuilder(result).reverse());
System.out.println(sb);
}
}One mutable buffer, many appends, one toString(): a builder trades a little spare array capacity for the ability to add text without rebuilding what is already there.
Worked examples
Capacity grows in jumps, not per character
Shows when the internal array is reallocated and why appends are cheap on average.
public class CapacityGrowth {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
System.out.println("len=" + sb.length() + " cap=" + sb.capacity());
for (int i = 0; i < 40; i++) {
sb.append('#');
if (sb.length() == 17 || sb.length() == 35) {
System.out.println("len=" + sb.length() + " cap=" + sb.capacity());
}
}
System.out.println("len=" + sb.length() + " cap=" + sb.capacity());
}
}Example explained
Line 1The no-arg constructor reserves room for 16 characters, so the first 16 appends only write into space that already exists.
Line 2The 17th append finds the array full and reallocates to 2*16+2 = 34, copying the 16 existing characters once.
Line 3The 35th append doubles again to 70, so reallocations get further apart as the text grows.
Line 4At the end capacity is 70 while length is 40; that unused tail is exactly what makes append constant time on average.
Builders do not compare by content
Demonstrates that StringBuilder inherits Object.equals, so matching text still compares as unequal.
public class BuilderEquality {
public static void main(String[] args) {
StringBuilder a = new StringBuilder("ok");
StringBuilder b = new StringBuilder("ok");
System.out.println(a.equals(b));
System.out.println(a.toString().equals(b.toString()));
System.out.println("ok".contentEquals(a));
}
}Example explained
Line 1StringBuilder never overrides equals, so a.equals(b) is an identity test between two different objects and prints false.
Line 2toString() materialises a String for each builder, and String.equals compares characters, which is why the second line is true.
Line 3String.contentEquals takes any CharSequence, so it checks the builder's characters without allocating a second String.
Line 4The same gap applies to hashCode: a builder's hash never changes when you append, which makes it a broken map key.
Which append overload runs
Shows how the compile-time type of the argument decides what actually lands in the buffer.
public class AppendOverloads {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append('A');
sb.append('A' + 1);
sb.append((char) ('A' + 1));
char[] letters = {'x', 'y'};
sb.append(letters);
String missing = null;
sb.append(missing);
System.out.println(sb);
System.out.println(sb.length());
}
}Example explained
Line 1'A' + 1 promotes both operands to int, so append(int) runs and writes the two digits 6 and 6 instead of a letter.
Line 2Casting the result back to char selects append(char) and writes the single letter B.
Line 3append(char[]) copies the array's characters; if the same array were typed as Object it would go to append(Object) and print something like [C@1b6d3586.
Line 4append((String) null) writes the four letters n-u-l-l rather than throwing, so a missing value slips silently into your text.
Important notes
Only the 16-character default of new StringBuilder() is specified by the API; the 34 and 70 above come from HotSpot's grow-to-2n+2 rule, so read capacity() to reason about cost, never to drive logic.
StringBuilder is unsynchronized on purpose, which is precisely its advantage over StringBuffer, so keep an instance local to the method or thread that fills it; and reverse() flips code units, preserving surrogate pairs but moving combining accents onto the wrong letter.
Common mistakes
Declaring the StringBuilder inside the loop instead of before it: each iteration starts from an empty 16-character buffer, so the result holds only the last piece.
Calling sb.toString() inside the loop for a length check, a log line, or a contains test: every call copies the whole buffer and reintroduces the quadratic cost the builder was meant to remove, when sb.length() and sb.indexOf answer the same questions for free.
Comparing builders with equals or using one as a HashMap key: identical text compares as false, and because the hash is identity based, a lookup misses even when the characters match.
Try it yourself
Change, predict, then run
Turn int[] steps = {5, 3, 12, 7} into the string "5 -> 3 -> 12 -> 7" using a single StringBuilder that appends " -> " only when the builder is not already empty. Print the finished string along with its length() and capacity(), and say why the two numbers differ.
Open the Java workspaceCheck your understanding
A loop appends 10,000 single characters to one new StringBuilder(). Roughly how many times is the internal array copied, and why?
- About ten times, because the capacity roughly doubles each time it fills, so reallocations get rarer as the text grows
- Exactly 10,000 times, because each append allocates a fresh array and copies the existing characters into it
- Never, because a StringBuilder extends its array by one slot per character without copying
- Once at construction, because the no-arg constructor sizes the array for the whole result
Show answer
Doubling from 16 gives 34, 70, 142 and so on, reaching past 10,000 in about ten reallocations, so the total characters copied stays proportional to the final length. The 10,000-copies option describes what repeated String concatenation does, where every step really does allocate a new object and copy everything built so far; a builder only copies when its current array is full.