StringBuffer vs StringBuilder
Both StringBuffer and StringBuilder let you build and mutate strings without the allocation overhead of repeatedly creating new String objects. They share virtually the same API — so the real question is always: which one should you use? The answer comes down to a single concern: thread safety.
The Core Difference at a Glance
| Feature | StringBuffer | StringBuilder |
|---|---|---|
| Mutable | Yes | Yes |
| Thread-safe | Yes (synchronized) | No |
| Performance (single-threaded) | Slightly slower | Faster |
| Performance (multi-threaded) | Safe | Unsafe |
| Introduced in | Java 1.0 | Java 5 |
| Extends | AbstractStringBuilder | AbstractStringBuilder |
| Common use case | Shared buffer across threads | Building strings in one thread |
The API is so similar that you can often swap one for the other with just a find-and-replace — but choosing the wrong one can either silently corrupt data (using StringBuilder from multiple threads) or waste CPU time (using StringBuffer where you don’t need locking).
Identical API, Different Safety Guarantee
Both classes extend AbstractStringBuilder and expose the same set of methods: append(), insert(), delete(), replace(), reverse(), substring(), indexOf(), and more. The only difference is that every mutating method on StringBuffer carries the synchronized keyword.
// StringBuffer — every mutating method is synchronized
StringBuffer sbf = new StringBuffer("Hello");
sbf.append(", World");
// StringBuilder — same method, no synchronization
StringBuilder sbd = new StringBuilder("Hello");
sbd.append(", World");
System.out.println(sbf); // Hello, World
System.out.println(sbd); // Hello, World
Output:
Hello, World
Hello, World
The outputs are identical. The difference lives entirely in what the JVM does before it executes the method body.
Performance: The Cost of Synchronization
In single-threaded code, StringBuffer is measurably — though often only slightly — slower than StringBuilder. Every synchronized method call must acquire and release the object’s monitor, even when there is zero contention.
Here is a micro-benchmark to make it concrete:
public class BenchmarkDemo {
static final int ITERATIONS = 100_000;
public static void main(String[] args) {
// StringBuffer
long start = System.nanoTime();
StringBuffer sbf = new StringBuffer();
for (int i = 0; i < ITERATIONS; i++) {
sbf.append("x");
}
long bufferTime = System.nanoTime() - start;
// StringBuilder
start = System.nanoTime();
StringBuilder sbd = new StringBuilder();
for (int i = 0; i < ITERATIONS; i++) {
sbd.append("x");
}
long builderTime = System.nanoTime() - start;
System.out.println("StringBuffer (ms): " + bufferTime / 1_000_000.0);
System.out.println("StringBuilder (ms): " + builderTime / 1_000_000.0);
}
}
Output (approximate — results vary by JVM and hardware):
StringBuffer (ms): 4.2
StringBuilder (ms): 1.8
Note: Modern JVMs apply lock elision via escape analysis — if the JIT can prove that a
StringBufferis not shared, it may remove the lock entirely at runtime. In practice this means the gap can narrow to near-zero in warm JVM benchmarks. But lock elision is a best-effort optimisation;StringBuilderguarantees no lock overhead.
Thread Safety: When StringBuffer Is the Right Call
If two or more threads append to the same buffer concurrently, StringBuilder will produce unpredictable results — garbled output, wrong lengths, or even an ArrayIndexOutOfBoundsException from a race on the internal count field.
// UNSAFE — two threads sharing a StringBuilder
StringBuilder shared = new StringBuilder();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
shared.append("x"); // data race!
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
// length may be less than 2000 due to lost updates
System.out.println(shared.length());
Switching to StringBuffer makes this safe:
// SAFE — StringBuffer is synchronized
StringBuffer safe = new StringBuffer();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
safe.append("x"); // each append holds the monitor
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(safe.length()); // always 2000
Output:
2000
Warning:
synchronizedon individual methods prevents data corruption of the internal array, but it does not make compound operations atomic. For example, a check-then-act sequence likeif (sb.length() > 0) sb.deleteCharAt(0)is still a race condition. For that you need explicit locking with ReentrantLock or synchronized blocks.
A Better Alternative for Concurrent Scenarios
If you do need a shared mutable string buffer in concurrent code, also consider:
ThreadLocal<StringBuilder>— give each thread its ownStringBuilderand combine results at the end. You getStringBuilderspeed in each thread without any locking.java.util.concurrentutilities — for append-only logging,ConcurrentLinkedQueue<String>or aStringJoinercollected by a stream may be cleaner than a shared buffer.
// ThreadLocal pattern — each thread gets its own builder
ThreadLocal<StringBuilder> local = ThreadLocal.withInitial(StringBuilder::new);
Runnable task = () -> {
StringBuilder sb = local.get();
for (int i = 0; i < 1000; i++) {
sb.append("x");
}
// collect/merge results as needed
};
Method Signatures Side by Side
Because both classes extend AbstractStringBuilder, the method signatures are identical. Here are the most common ones — they work the same way on both:
| Method | Description |
|---|---|
append(x) | Append any value to the end |
insert(offset, x) | Insert at a given position |
replace(start, end, str) | Replace a range with str |
delete(start, end) | Remove characters in a range |
deleteCharAt(index) | Remove a single character |
reverse() | Reverse the sequence in place |
indexOf(str) | First index of a substring |
substring(start, end) | Extract as a String |
length() | Current character count |
capacity() | Size of internal buffer array |
charAt(index) | Read a character |
setCharAt(index, ch) | Overwrite a character |
toString() | Convert to String |
Decision Guide: Which One Should You Use?
Ask yourself two questions:
-
Will this buffer ever be accessed by more than one thread at the same time?
- Yes → use
StringBuffer(or considerThreadLocal<StringBuilder>) - No → use
StringBuilder
- Yes → use
-
Are you maintaining legacy code that already uses
StringBufferin a single-threaded context?- Switching to
StringBuilderis a safe, purely mechanical refactor — same API, slightly better performance.
- Switching to
Tip: In almost all everyday Java code — building query strings, formatting messages, looping over data — you are in a single thread. Reach for
StringBuilderby default and only introduceStringBufferwhen the concurrency requirement is clear and explicit.
Under the Hood
Both StringBuffer and StringBuilder inherit their storage from AbstractStringBuilder. In Java 8 and earlier, that storage is a char[]. Since Java 9 (Compact Strings, JEP 254) it is a byte[] paired with a coder byte — Latin-1 strings use one byte per character instead of two, halving memory for ASCII content.
Buffer growth works identically in both: when an append would exceed the current capacity, the internal array is replaced with a new one of size (oldCapacity × 2) + 2. This doubling strategy gives O(1) amortised append time.
The only bytecode-level difference is that StringBuffer methods contain MONITORENTER and MONITOREXIT instructions around the method body. These compile to a lock acquire and release on the object’s monitor. When there is no contention (no other thread wants the lock) a modern JVM handles this with a single compare-and-swap on the object header — fast, but not free. Under high contention, threads block and context-switch, which is why StringBuffer loses significantly to StringBuilder in multi-threaded benchmarks where every thread is hammering the same instance.
You can inspect this difference yourself with the javap tool:
javap -c StringBuffer # you'll see MONITORENTER / MONITOREXIT in the bytecode
javap -c StringBuilder # none
Quick Cheat Sheet
// Single-threaded — always prefer StringBuilder
StringBuilder sb = new StringBuilder();
sb.append("Java ").append(21);
String result = sb.toString(); // "Java 21"
// Multi-threaded shared buffer — use StringBuffer
StringBuffer log = new StringBuffer();
// ... safe to call log.append(...) from multiple threads
// Multi-threaded but each thread has its own — use StringBuilder via ThreadLocal
ThreadLocal<StringBuilder> tl = ThreadLocal.withInitial(StringBuilder::new);
Related Topics
- StringBuffer — full coverage of the synchronized class: constructors, methods, and thread-safety details
- StringBuilder — full coverage of the faster, non-synchronized class
- String vs StringBuffer — when to stay with immutable
Stringinstead of a mutable buffer - Synchronization — the locking mechanism that underpins
StringBuffer’s thread safety - Java Memory Model — how the JVM guarantees visibility of writes across threads
- String Immutability — why
Stringis immutable, and why that makes mutable builders necessary