Skip to content
Java strings 7 min read

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

FeatureStringBufferStringBuilder
MutableYesYes
Thread-safeYes (synchronized)No
Performance (single-threaded)Slightly slowerFaster
Performance (multi-threaded)SafeUnsafe
Introduced inJava 1.0Java 5
ExtendsAbstractStringBuilderAbstractStringBuilder
Common use caseShared buffer across threadsBuilding 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 StringBuffer is 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; StringBuilder guarantees 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: synchronized on individual methods prevents data corruption of the internal array, but it does not make compound operations atomic. For example, a check-then-act sequence like if (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 own StringBuilder and combine results at the end. You get StringBuilder speed in each thread without any locking.
  • java.util.concurrent utilities — for append-only logging, ConcurrentLinkedQueue<String> or a StringJoiner collected 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:

MethodDescription
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:

  1. Will this buffer ever be accessed by more than one thread at the same time?

    • Yes → use StringBuffer (or consider ThreadLocal<StringBuilder>)
    • No → use StringBuilder
  2. Are you maintaining legacy code that already uses StringBuffer in a single-threaded context?

    • Switching to StringBuilder is a safe, purely mechanical refactor — same API, slightly better performance.

Tip: In almost all everyday Java code — building query strings, formatting messages, looping over data — you are in a single thread. Reach for StringBuilder by default and only introduce StringBuffer when 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);
  • 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 String instead 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 String is immutable, and why that makes mutable builders necessary
Last updated June 13, 2026
Was this helpful?