StringBuilder in Java
-
Last Updated: February 1, 2025
-
By: javahandson
-
Series
Learn Java in a easy way
StringBuilder in Java is a mutable sequence of characters. You can append, insert, delete and reverse text inside one object, and the object never gets replaced. That single idea makes it the fastest, cleanest way to build strings in a loop, and this guide walks through its constructors, methods, internals and traps.
Think of a String as a printed page. Once the ink dries, you cannot edit it. Adding a word means printing a whole new page and throwing the old one away.
A StringBuilder is a whiteboard instead. Write on it, wipe a bit off, squeeze a word into the middle. The board stays the same board the entire time.
That is the whole difference, and it drives every other detail in this article. Java gave us StringBuilder in Java 5 as the fast, unsynchronized companion to the much older StringBuffer.
Almost every time you build text piece by piece, StringBuilder is the right tool. Let us see exactly why, and how to drive it well.
[IMAGE PLACEHOLDER: Diagram comparing a String creating three separate objects during concatenation against one StringBuilder object being edited in place]
Java marks String as immutable. No method on a String edits the characters it holds.
Methods such as concat(), toUpperCase() and replace() look like they change something. They do not. Each one builds a brand new String and hands it back.
String name = "Java";
name.concat(" HandsOn");
System.out.println(name); // Output: Java
name = name.concat(" HandsOn");
System.out.println(name); // Output: Java HandsOnThe first concat() call created a new String and dropped it immediately. Only the reassignment on line four kept the result.
Immutability costs almost nothing for a couple of joins. Put it inside a loop and the bill arrives fast.
String result = "";
for (int i = 0; i < 50000; i++) {
result += "x"; // a brand new String on every single pass
}
System.out.println(result.length()); // Output: 50000Every pass allocates a fresh String and copies all the characters gathered so far. Pass one copies 1 character, pass two copies 2, and pass fifty thousand copies 49,999.
Add those copies together and you get roughly 1.25 billion character copies. Computer scientists call this O(n2) work, and your CPU calls it a bad afternoon.
Swap the String for a StringBuilder and the copying disappears. One buffer grows a handful of times, and each character lands in it exactly once.
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 50000; i++) {
builder.append("x"); // same object, edited in place
}
String result = builder.toString();
System.out.println(result.length()); // Output: 50000Same answer, but the work drops to O(n). On a typical machine the first version takes seconds while this one finishes in a couple of milliseconds.
Notice the final toString() call. A builder is a workspace, not a finished value, so hand back a String once the building is done.
Four constructors exist, and they differ in one thing only: how much room the buffer starts with.
new StringBuilder() gives you an empty builder with room for 16 characters.
StringBuilder sb = new StringBuilder(); System.out.println(sb.length()); // Output: 0 System.out.println(sb.capacity()); // Output: 16
Sixteen is simply the default the JDK picked. Nothing magic about it.
Pass an int and you choose the starting room yourself.
StringBuilder sb = new StringBuilder(100); System.out.println(sb.length()); // Output: 0 System.out.println(sb.capacity()); // Output: 100
Roughly knowing the final size? Then this constructor saves a few resizes. Passing a negative number throws NegativeArraySizeException.
Give it a String and the builder starts with that content. Capacity becomes the string length plus 16.
StringBuilder sb = new StringBuilder("Java");
System.out.println(sb.length()); // Output: 4
System.out.println(sb.capacity()); // Output: 20 (4 + 16)Those spare 16 slots are deliberate. Most code appends right after creating the builder, so the JDK leaves elbow room.
The last constructor accepts any CharSequence, which covers String, StringBuffer, another StringBuilder, and CharBuffer.
StringBuffer buffer = new StringBuffer("Java HandsOn");
StringBuilder sb = new StringBuilder(buffer);
System.out.println(sb); // Output: Java HandsOn
System.out.println(sb.capacity()); // Output: 28 (12 + 16)Copying a StringBuffer into a StringBuilder like this is a common way to shed synchronization once a value stops being shared.
Beginners mix these two up constantly, so let us pin them down.
length() counts the characters you actually put incapacity() counts the slots currently reserved in memoryPicture a bus with 50 seats carrying 12 passengers. Length is 12 and capacity is 50.
Only length affects what toString() prints. Capacity is a memory detail that changes on its own.
Overflow the reserved room and the builder allocates a bigger array, copies everything across, and carries on.
StringBuilder sb = new StringBuilder(4);
System.out.println(sb.capacity()); // Output: 4
sb.append("Hello"); // needs 5 slots, only 4 exist
System.out.println(sb.length()); // Output: 5
System.out.println(sb.capacity()); // Output: 10 (4 * 2 + 2)Your code never triggers that copy by hand. The builder handles it silently, which is exactly why it feels effortless to use.
Two methods let you take the wheel when memory matters.
ensureCapacity(int) reserves room up front. Ask for less than you already have and nothing happens at all.
StringBuilder sb = new StringBuilder(); System.out.println(sb.capacity()); // Output: 16 sb.ensureCapacity(50); System.out.println(sb.capacity()); // Output: 50 sb.ensureCapacity(10); // already bigger, so ignored System.out.println(sb.capacity()); // Output: 50
trimToSize() does the opposite. It shrinks the array down to the characters you are holding, releasing whatever went unused.
StringBuilder sb = new StringBuilder(50);
sb.append("JavaHandsOn");
System.out.println(sb.capacity()); // Output: 50
sb.trimToSize();
System.out.println(sb.capacity()); // Output: 11Reach for trimToSize() only when a builder will sit in memory for a long time. Trimming a short-lived builder just wastes a copy.
This is the workhorse. Roughly nine out of ten builder calls in real code are appends.
Overloads cover every primitive plus Object, String, CharSequence and char arrays. Anything that is not already text goes through String.valueOf() first.
StringBuilder sb = new StringBuilder("Java");
sb.append(" HandsOn"); // String
sb.append(' '); // char
sb.append(2026); // int
sb.append(' ');
sb.append(true); // boolean
System.out.println(sb); // Output: Java HandsOn 2026 trueOne small habit pays off. Prefer append('x') over append("x") for a single character, because the char overload skips a String lookup.
Where append writes at the end, insert() writes at an index you choose. Everything after that index slides right.
StringBuilder sb = new StringBuilder("Java HandsOn");
sb.insert(0, ">> ");
System.out.println(sb); // Output: >> Java HandsOn
sb.insert(sb.length(), " <<");
System.out.println(sb); // Output: >> Java HandsOn <<Valid offsets run from 0 to length() inclusive. Anything outside that range throws StringIndexOutOfBoundsException.
Keep in mind that inserting near the front shifts every later character. Appending stays cheap no matter how big the builder grows.
delete(start, end) removes a range. Start is included and end is excluded, matching substring().
StringBuilder sb = new StringBuilder("Java HandsOn");
sb.delete(4, 10); // removes " Hands"
System.out.println(sb); // Output: JavaOndeleteCharAt(index) drops exactly one character. Handy for stripping a trailing comma.
StringBuilder sb = new StringBuilder("Java,");
sb.deleteCharAt(sb.length() - 1);
System.out.println(sb); // Output: JavaAn end index past the length is forgiven by delete(), which simply stops at the end. A start index past the length still throws.
replace(start, end, str) swaps a range for new text. The replacement can be any length, so the builder grows or shrinks to fit.
StringBuilder sb = new StringBuilder("Java Hello");
sb.replace(5, 10, "HandsOn");
System.out.println(sb); // Output: Java HandsOnsetCharAt(index, ch) overwrites a single character in place. It returns nothing, unlike the chainable methods.
StringBuilder sb = new StringBuilder("java");
sb.setCharAt(0, 'J');
System.out.println(sb); // Output: JavaFlipping the character order takes one call, which is why interviewers love it.
String word = "HandsOn"; String flipped = new StringBuilder(word).reverse().toString(); System.out.println(flipped); // Output: nOsdnaH
Surrogate pairs stay intact too. Java stores emoji and rarer scripts as two chars, and reverse() keeps those pairs glued together instead of scrambling them.
Editing is only half the job. These methods read without changing anything.
StringBuilder sb = new StringBuilder("Java HandsOn Java");
System.out.println(sb.charAt(5)); // Output: H
System.out.println(sb.indexOf("Java")); // Output: 0
System.out.println(sb.indexOf("Java", 1)); // Output: 13
System.out.println(sb.lastIndexOf("Java")); // Output: 13
System.out.println(sb.indexOf("Python")); // Output: -1
System.out.println(sb.substring(5, 12)); // Output: HandsOnWatch the return type of substring(). It hands back a String, not a StringBuilder, so you cannot keep chaining builder methods off it.
A missing search term always yields -1. Test for that before using the value as an index.
setLength(int) forces the character count. Shrinking truncates, and growing pads with the null character.
StringBuilder sb = new StringBuilder("Java HandsOn");
sb.setLength(4);
System.out.println(sb); // Output: Java
sb.setLength(6); // pads with two null characters
System.out.println(sb.length()); // Output: 6Those padding characters are real and invisible. Growing a builder with setLength() is rarely what you want.
The genuinely useful call is setLength(0). It empties the builder for reuse while keeping the capacity you already paid for.
StringBuilder sb = new StringBuilder(64);
sb.append("first row");
sb.setLength(0); // clear the content
System.out.println(sb.length()); // Output: 0
System.out.println(sb.capacity()); // Output: 64 (buffer kept)Recent Java releases added two small conveniences.
isEmpty() arrived with Java 15 through the CharSequence interface. It reads better than comparing the length to zero.
StringBuilder sb = new StringBuilder();
System.out.println(sb.isEmpty()); // Output: true
sb.append("Java");
System.out.println(sb.isEmpty()); // Output: falserepeat(CharSequence, int) landed in Java 21 and appends the same text several times.
StringBuilder line = new StringBuilder();
line.repeat("-", 20);
System.out.println(line); // Output: --------------------Running an older JDK? Then a short loop with append() does the same job.
Most editing methods return the very same builder instead of a new one. That single design choice makes chaining possible.
String message = new StringBuilder("Hello")
.append(" World")
.insert(0, ">> ")
.reverse()
.toString();
System.out.println(message); // Output: dlroW olleH >>Each call hands back the builder, so the next call applies to the updated content. Chains read well right up to about four calls.
Break longer chains into named steps. Debugging a ten-call chain is miserable, and a stack trace only points at the whole statement.
Two methods sit outside the pattern. setCharAt() and setLength() both return void, so a chain stops dead at either one.
Sometimes you need the raw characters rather than a String. Three methods cover that.
getChars() copies a slice straight into an array you already own, which avoids allocating anything new.
StringBuilder sb = new StringBuilder("Java HandsOn");
char[] target = new char[4];
sb.getChars(5, 9, target, 0); // copy indexes 5..8 into target
System.out.println(target); // Output: Handchars() and codePoints() both hand back an IntStream, which slots neatly into stream pipelines.
StringBuilder sb = new StringBuilder("Java HandsOn");
long vowels = sb.chars()
.filter(ch -> "aeiouAEIOU".indexOf(ch) >= 0)
.count();
System.out.println(vowels); // Output: 4Pick codePoints() when the text may hold emoji or non-Latin scripts. It treats a surrogate pair as one value, while chars() reports two.
Strip away the methods and a StringBuilder holds two fields: an array for the characters and an int for how many slots are in use.
Both StringBuilder and StringBuffer inherit those fields from a package-private parent named AbstractStringBuilder. The two classes really are twins, and synchronization is the main thing separating them.
Appending writes into the free slots and bumps the count. No allocation happens at all while spare room remains, and that is where the speed comes from.
Run out of room and the builder computes a new size:
new capacity = (old capacity * 2) + 2
Should that still fall short of what the append needs, the builder jumps straight to the required size instead.
StringBuilder sb = new StringBuilder(5);
System.out.println(sb.capacity()); // Output: 5
sb.append("Java HandsOn"); // needs 12 slots
System.out.println(sb.capacity()); // Output: 12
sb.append(" Tutorial Series"); // needs 28, doubling gives 26
System.out.println(sb.capacity()); // Output: 28Doubling matters more than it looks. Growing by one slot each time would copy the whole array on every append, dragging you back to O(n2).
With doubling, the copies get rarer as the builder grows. Filling 50,000 characters from the default 16 takes about a dozen resizes, not 50,000.
Java 9 changed the storage under both String and StringBuilder. The old char[] became a byte[] paired with a one-byte coder flag.
Text that fits in Latin-1 now uses one byte per character rather than two. Content needing wider characters flips the coder to UTF-16 and uses two bytes.
Most English text halves its memory footprint for free. Nothing about the API changed, and capacity() still reports characters rather than bytes.
One subtle effect is worth knowing. Appending the first non-Latin-1 character forces the builder to re-encode its whole buffer into UTF-16.
Calling toString() is not free. It allocates a new String and copies every character across.
Why the copy? Because String promises immutability. Sharing the live buffer would let a later append mutate a String, and the whole language leans on that never happening.
The practical rule follows directly. Call toString() once, at the end.
// Wasteful: a full copy on every pass
for (String item : items) {
sb.append(item);
System.out.println(sb.toString().length());
}
// Better: length() reads the buffer directly, no copy
for (String item : items) {
sb.append(item);
System.out.println(sb.length());
}Reading methods such as length(), charAt() and indexOf() touch the buffer directly. None of them allocate, so use them freely inside loops.
No method on StringBuilder is synchronized. Share one builder between threads and the results turn to nonsense.
package com.java.handson.strings;
public class UnsafeBuilderDemo {
public static void main(String[] args) throws InterruptedException {
StringBuilder shared = new StringBuilder();
Runnable job = () -> {
for (int i = 0; i < 10000; i++) {
shared.append('x');
}
};
Thread one = new Thread(job);
Thread two = new Thread(job);
one.start();
two.start();
one.join();
two.join();
System.out.println(shared.length()); // Output: 17342 (expected 20000)
}
}Run it and the number changes every time. It almost never reaches 20,000.
Here is the reason. Both threads read the same character count, both write into the same slot, and one write silently overwrites the other.
Worse outcomes are possible. Two threads resizing at once can throw ArrayIndexOutOfBoundsException from deep inside the JDK, which is a confusing stack trace to debug.
The first fix wraps the shared builder in a lock. Every thread must hold the same lock before touching it.
Runnable job = () -> {
for (int i = 0; i < 10000; i++) {
synchronized (shared) {
shared.append('x');
}
}
};
// Output: 20000, every runThe second fix removes the sharing instead. Give each thread its own builder and merge the pieces at the end.
StringBuilder local = new StringBuilder();
for (int i = 0; i < 10000; i++) {
local.append('x');
}
// hand `local` back and combine the results once every thread finishesPrefer the second approach whenever you can. It needs no locks, no contention, and no reasoning about interleaving.
What about StringBuffer? Its locking protects one method call, never a sequence of them. A check-then-append across two calls stays broken even with StringBuffer, so a shared builder usually needs your own lock anyway.
Here is the comparison interviewers ask for, in one table.
| Aspect | String | StringBuilder | StringBuffer |
|---|---|---|---|
| Mutable | No | Yes | Yes |
| Thread-safe | Yes, because it never changes | No | Yes, per method call |
| Speed for edits | Slowest | Fastest | Middle |
| Available since | Java 1.0 | Java 5 | Java 1.0 |
| Stored in the String pool | Yes, for literals | No | No |
| equals() compares | Content | Identity | Identity |
| Safe as a HashMap key | Yes | No | No |
| Best used for | Fixed text and keys | Building text in one thread | A buffer genuinely shared across threads |
One rule covers nearly every case. Use String for values, StringBuilder for building, and StringBuffer only when threads truly share the same buffer.
[IMAGE PLACEHOLDER: Decision flowchart asking "Does the text change?" then "Do multiple threads share it?" leading to String, StringBuilder or StringBuffer]
StringBuilder never overrides equals(). It inherits the identity check from Object, so two builders holding identical text still compare as different.
StringBuilder a = new StringBuilder("Java");
StringBuilder b = new StringBuilder("Java");
System.out.println(a.equals(b)); // Output: false
System.out.println(a.toString().equals(b.toString())); // Output: true
System.out.println(a.compareTo(b)); // Output: 0 (Java 11+)Convert to String before comparing content. The same gap applies to hashCode(), which is why a StringBuilder makes a terrible map key.
Appending a null String reference does not throw. It quietly writes the four letters n-u-l-l into your text.
String missing = null;
StringBuilder sb = new StringBuilder("Name: ");
sb.append(missing);
System.out.println(sb); // Output: Name: null
System.out.println(sb.length()); // Output: 10One overload behaves differently. Passing a null char[] throws NullPointerException instead, so guard those values before appending.
This one hides in plain sight and undoes the whole point of using a builder.
// Wasteful: builds a throwaway String on every pass
for (String item : items) {
sb.append(item + ", ");
}
// Better: two direct appends, no intermediate String
for (String item : items) {
sb.append(item).append(", ");
}The first loop concatenates before appending, creating a temporary String each time. Chained appends skip that entirely.
Range methods follow the same rule as String: start is included, end is excluded.
delete(0, 3) removes indexes 0, 1 and 2, leaving index 3 alonedeleteCharAt(sb.length() - 1)length() inclusiveStringIndexOutOfBoundsException, a subclass of IndexOutOfBoundsExceptionPlenty of code creates a builder where plain concatenation is clearer and just as fast.
// Overkill for a single join
String label = new StringBuilder().append("Hi ").append(name).toString();
// Clear, and the compiler optimises it anyway
String label = "Hi " + name;Since Java 9, the compiler turns a simple + expression into an efficient runtime call through StringConcatFactory. Manual builders win in loops, not in one-liners.
A String pulled out of a builder is a snapshot. Later edits to the builder leave that String untouched.
StringBuilder sb = new StringBuilder("Java");
String snapshot = sb.toString();
sb.append(" HandsOn");
System.out.println(sb); // Output: Java HandsOn
System.out.println(snapshot); // Output: JavaBeginners often expect snapshot to follow along. It cannot, because String never changes once created.
The same trap runs the other way too. Storing a builder in a list and editing it afterwards changes what the list holds, which surprises people who expected String behaviour.
Let us pull the pieces together into one program that formats an order summary.
package com.java.handson.strings;
import java.util.List;
public class ReportBuilder {
public static void main(String[] args) {
List<String> items = List.of("Keyboard", "Monitor", "Mouse");
StringBuilder report = new StringBuilder(128);
report.append("ORDER SUMMARY").append('\n');
report.append("-------------").append('\n');
for (int i = 0; i < items.size(); i++) {
report.append(i + 1)
.append(". ")
.append(items.get(i))
.append('\n');
}
report.append("Total items: ").append(items.size());
System.out.println(report);
System.out.println("Capacity used: " + report.length() + " of " + report.capacity());
}
}
// Output:
// ORDER SUMMARY
// -------------
// 1. Keyboard
// 2. Monitor
// 3. Mouse
// Total items: 3
// Capacity used: 74 of 128Three decisions in that program are worth calling out.
println() worked because print calls toString() for youJoining values with commas always leaves one comma too many. Two lines clean it up.
List<String> items = List.of("Keyboard", "Monitor", "Mouse");
StringBuilder csv = new StringBuilder();
for (String item : items) {
csv.append(item).append(", ");
}
System.out.println(csv); // Output: Keyboard, Monitor, Mouse,
if (csv.length() > 0) {
csv.setLength(csv.length() - 2); // drop the trailing ", "
}
System.out.println(csv); // Output: Keyboard, Monitor, MouseNotice the length guard. Running setLength() on an empty builder would ask for a negative length and throw.
For plain joining, String.join(", ", items) is shorter and does this for you. Keep the trick for cases where you build rows conditionally and cannot use a ready-made joiner.
Every class you write eventually needs a toString(). This is where StringBuilder earns its keep in everyday code.
package com.java.handson.strings;
public class Course {
private final String title;
private final int lessons;
private final boolean free;
public Course(String title, int lessons, boolean free) {
this.title = title;
this.lessons = lessons;
this.free = free;
}
@Override
public String toString() {
return new StringBuilder(64)
.append("Course{title='").append(title)
.append("', lessons=").append(lessons)
.append(", free=").append(free)
.append('}')
.toString();
}
public static void main(String[] args) {
System.out.println(new Course("Java Basics", 24, true));
}
}
// Output: Course{title='Java Basics', lessons=24, free=true}Every append here uses a different overload. Strings, an int, a boolean and a char all flow into the same buffer without you converting anything by hand.
Honest caveat: for a field or two, a plain + expression reads better and compiles to something just as fast. Bring in the builder once the field list grows or the format turns conditional.
Java ships two helpers built on top of StringBuilder, and both beat hand-rolled separator logic.
List<String> items = List.of("Keyboard", "Monitor", "Mouse");
// StringJoiner handles the prefix, separator and suffix
StringJoiner joiner = new StringJoiner(", ", "[", "]");
items.forEach(joiner::add);
System.out.println(joiner); // Output: [Keyboard, Monitor, Mouse]
// Collectors.joining does the same inside a stream
String csv = items.stream().collect(Collectors.joining(", "));
System.out.println(csv); // Output: Keyboard, Monitor, MouseSo when does a raw builder still win? Whenever the output is not a simple join.
setLength(0)A: StringBuilder is a mutable sequence of characters, added in Java 5. Methods such as append, insert and delete edit the same object instead of creating a new one, which makes it the standard way to build text in a loop.
A: Both are mutable and share the same API, because both extend AbstractStringBuilder. StringBuffer synchronizes its methods, so it is thread-safe but slower. StringBuilder skips the locking, which makes it faster and the right default for single-threaded code.
A: String is immutable, so each concatenation allocates a new String and copies every character collected so far. That gives O(n squared) work. StringBuilder writes into one growable buffer and copies only during occasional resizes, giving O(n) work.
A: An empty StringBuilder starts with capacity 16. Building one from a String or CharSequence gives capacity equal to that content length plus 16, so new StringBuilder("Java") reports 20.
A: When an append needs more room, the builder computes old capacity times two plus two. If that is still too small, it uses the exact size the append requires. Doubling keeps resizes rare, so appending stays cheap on average.
A: length() counts the characters actually stored, while capacity() counts the slots reserved in memory. Only length affects the output of toString(). Capacity is a memory detail that grows automatically as you append.
A: No. None of its methods are synchronized. Two threads appending to one shared StringBuilder can lose characters or throw ArrayIndexOutOfBoundsException. Give each thread its own builder, or guard the shared one with your own lock.
A: StringBuilder does not override equals() or hashCode(), so both fall back to Object identity. Call toString() on each one and compare the results, or use compareTo() which Java 11 added for content ordering.
A: Call setLength(0). That empties the content while keeping the capacity you already allocated, which is ideal when you reuse the builder in a loop. Creating a new StringBuilder also works, but it throws away the buffer.
A: Avoid it. It uses identity hashing, so a lookup with an equal-looking builder misses. Its content can also change after insertion, which strands the entry in the wrong bucket. Convert to String and use that as the key.
A: Older compilers rewrote plus expressions into StringBuilder calls. Since Java 9, javac emits an invokedynamic call to StringConcatFactory instead, which the runtime optimises. Either way the rewrite happens per expression, so a plus inside a loop still allocates on every pass.
A: It shrinks the internal array so the capacity matches the current length, releasing unused memory. Use it only for builders that live a long time, because trimming a short-lived builder costs an extra array copy for no benefit.
Let us wrap up what we covered. StringBuilder gives you a mutable character buffer, so building text no longer means throwing away a String on every step.
We started with the immutability that makes loop concatenation expensive. From there we walked the constructors, separated length from capacity, and used every method you will meet in real code.
We also opened the class up. Inside sits a growable array that doubles plus two, backed since Java 9 by compact byte storage.
Five points are worth carrying away:
equals() and hashCode() compare identity, so call toString() before comparing text+ operator, while loops belong to a builderMake StringBuilder your default whenever text gets assembled piece by piece. Save StringBuffer for the rare buffer that threads genuinely share.
This article belongs to our String series on javahandson.com. Read them in order for the full picture:
You may also enjoy final Keyword in Java, which explains the modifier that locks the StringBuilder class down, and HashMap in Java, which shows exactly why a mutable key causes so much trouble.