Why StringBuffer is Slower than StringBuilder
The StringBuffer class is significantly slower than StringBuilder because almost every public method in StringBuffer is declared with the synchronized keyword. This forces the J...
🟢 Junior Level
The StringBuffer class is significantly slower than StringBuilder because almost every public method in StringBuffer is declared with the synchronized keyword. This forces the Java Virtual Machine (JVM) to perform monitor acquisition and release operations on every single method call (append, insert, delete, length), even when running in a strictly single-threaded environment.
The Essence in 30 Seconds
StringBuilder(Java 5): Operates without any synchronization. Access to the underlying byte array and count pointer occurs directly in memory. The CPU executes instructions sequentially at maximum register and cache speeds.StringBuffer(Java 1.0): Guarantees thread safety via mutual exclusion (synchronized). Before executing any action, a thread must verify and acquire the monitor lock of the object, and subsequently release it. In single-threaded execution, this imposes parasitic overhead from atomic CPU instructions and memory barriers, making operations 1.5x to 2.5x slower. Under real multi-threaded contention, throughput collapses by 10x–20x due to thread parking and OS context switching.
// Performance benchmark: 1,000,000 append operations in a single thread
long start = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1_000_000; i++) {
sb.append("a"); // ~8–15 ms (direct array writes)
}
long timeSb = System.nanoTime() - start;
start = System.nanoTime();
StringBuffer sbf = new StringBuffer();
for (int i = 0; i < 1_000_000; i++) {
sbf.append("a"); // ~20–35 ms (1,000,000 monitor acquisitions)
}
long timeSbf = System.nanoTime() - start;
🟡 Middle Level
The Anatomy of Synchronization Overhead
Why is invoking a synchronized method expensive even without thread competition?
- Bytecode Instructions (
monitorenter/monitorexit):
Every method invocation inStringBufferis bracketed by bytecode instructions that define the boundary of a critical section. This restricts compiler and processor instruction reordering. - Atomic CPU Instructions (CAS):
To acquire an uncontended lock, the processor must execute an atomic instruction (e.g.,LOCK CMPXCHGon x86/x64). This instruction locks the CPU L1/L2 cache line via cache coherency protocols, preventing concurrent access from other processor cores. - Hardware Memory Barriers (Fences):
Under the Java Memory Model (JMM), monitor release forms a happens-before relationship with subsequent acquisitions. The JVM must flush CPU store buffers and invalidate speculative load caches, inhibiting instruction pipeline optimizations. - Cache Invalidation (
toStringCache):
InStringBuffer, every mutating call must invalidate the cached string field (toStringCache = null), adding an extra memory write operation.
Performance Breakdown
| Metric / Scenario | StringBuilder |
StringBuffer (Uncontended) |
StringBuffer (4 Contending Threads) |
|---|---|---|---|
| Synchronization | None | synchronized per method |
synchronized per method |
| CPU Instructions | Standard MOV |
Atomic LOCK CMPXCHG (CAS) |
CAS + thread parking (futex / kernel mutex) |
| 1M Appends Latency | ~8–12 ms | ~20–30 ms (+150%) | ~150–300 ms (+2000%) |
| OS Context Switches | 0 | 0 | Thousands of kernel context switches |
| Recommendation | Default for 100% of local code | Anti-pattern in modern code | Anti-pattern (use thread-local buffers) |
Compiler Compensations: Lock Elision & Lock Coarsening
The HotSpot C2 JIT compiler attempts to mitigate StringBuffer latency through two optimizations:
- Lock Elision: If Escape Analysis proves an object is strictly method-local (
NoEscape), the compiler stripsmonitorenterandmonitorexitinstructions from generated assembly. - Lock Coarsening: If sequential synchronized methods are called on the same object (
sb.append("a").append("b")), C2 may merge adjacent monitor blocks into a single outer critical section.
Why Compiler Optimizations Are Insufficient:
Escape analysis frequently fails in real-world applications: passing StringBuffer to a method that exceeds inlining limits, or storing a reference in an object field, immediately classifies it as ArgEscape or GlobalEscape, disabling lock elision.
🔴 Senior Level
Monitor Evolution in HotSpot and JEP 374 (Java 15)
To understand performance at the hardware level, we must trace monitor transitions in the object header (Mark Word, 64 bits on x86-64):
- Biased Locking (Removed in Java 15 via JEP 374):
- Prior to Java 15: The JVM recorded the thread’s
ThreadIDdirectly into the object header. Repeated acquisitions by that thread required only a bitwise check without atomic instructions. - Why It Was Removed: Revoking biased locks required Stop-The-World safepoint pauses that crippled modern low-latency garbage collectors (ZGC, Shenandoah).
- Prior to Java 15: The JVM recorded the thread’s
- Lightweight Locking (Thin Lock):
- In modern Java (17, 21+), every
StringBuffermethod invocation executes an atomic CAS (LOCK CMPXCHG) to update the Mark Word with a pointer to the thread’s stack (BasicObjectLock). - The hardware
LOCKprefix serializes the CPU pipeline, forces store buffer drains, and flushes out-of-order execution queues.
- In modern Java (17, 21+), every
- Heavyweight Locking (Inflated / Fat Lock):
- When multiple threads contend for the buffer, CAS attempts fail. Following a brief spin-lock phase, the monitor inflates: HotSpot allocates a native C++
ObjectMonitorstructure in C-Heap. - Contending threads are parked via OS kernel system calls (
futexon Linux,pthread_mutex_lock). - Each OS thread context switch costs 1 to 5 microseconds, causing application throughput to plummet.
- When multiple threads contend for the buffer, CAS attempts fail. Following a brief spin-lock phase, the monitor inflates: HotSpot allocates a native C++
flowchart TD
Unlocked["Unlocked Object\n(Mark Word: Normal)"] -->|JEP 374: Direct Lightweight| Lightweight["Lightweight Lock\n(Atomic CAS: LOCK CMPXCHG)"]
Lightweight -->|Under Thread Contention| FatLock["Inflated Fat Lock\n(Native ObjectMonitor, OS Futex Parking)"]
CPU Cache Thrashing & False Sharing
When multiple threads concurrently invoke methods on a shared StringBuffer, processor cores continuously contend for the 64-byte cache line containing the object’s Mark Word and count field:
- Each atomic CAS changes the cache line to
Modified(M) in the MESI/MOESI cache coherence protocol. - All other CPU cores receive Invalidation (RFO) requests and must reload the cache line over the inter-socket interconnect (QPI/UPI).
- This results in Cache Thrashing, severely saturating the server’s memory bus.
4 Tricky Interview Questions
1. Why can’t the C2 JIT compiler simply lift locks outside a loop containing 10,000 sb.append() calls (Lock Coarsening across loops)?
Answer:
The compiler is strictly prohibited from hoisting synchronization outside loops containing Safepoint Polls.
In Java, loop iterations (with the exception of unrolled counted loops) contain safepoint polling checks where threads must be capable of pausing for Stop-The-World GC phases.
If C2 broadened the lock around the entire 10,000-iteration loop, the thread would hold the monitor continuously across multiple safepoints. If the thread stalled on a GC safepoint inside the loop, all other threads waiting for that monitor would remain blocked indefinitely, triggering massive GC latency spikes and risking deadlocks. Consequently, Lock Coarsening operates only over short, linear, safepoint-free code blocks.
2. Why does Escape Analysis frequently fail to elide locks on locally created StringBuffer instances?
Answer:
Escape Analysis (EA) in HotSpot C2 is intentionally conservative. An instance escapes (GlobalEscape or ArgEscape) whenever:
- The buffer is passed as a parameter into a method that C2 fails to inline (e.g., inlining depth exceeds
-XX:MaxInlineLevel=9, method bytecode exceeds-XX:MaxInlineSize=35, or the call site is megamorphic with >2 target implementations). Because the called body cannot be analyzed, C2 must assume the foreign method could leak the reference to another thread. - The buffer reference is written to an instance field of another object whose lifecycle is not strictly method-local.
In either scenario, Lock Elision is completely disabled, and full atomic CAS locking instructions are executed.
3. What happens at the CPU hardware level when LOCK CMPXCHG executes on an uncontended StringBuffer?
Answer:
- The CPU core asserts exclusive ownership over the target 64-byte cache line (containing the object’s Mark Word) by broadcasting a Request For Ownership (RFO) to all other cores via MESI/MOESI cache coherence.
- The processor serializes execution: all pending writes in the CPU Store Buffer are drained into L1D cache before the instruction completes.
- Speculative and out-of-order execution past the instruction boundary is temporarily halted.
This pipeline stall costs 15 to 40 CPU clock cycles, compared to just 1 cycle for an unsynchronizedMOVinstruction executed byStringBuilder.
4. What is the physical memory footprint difference between StringBuilder and StringBuffer on 64-bit HotSpot?
Answer:
On a 64-bit JVM with Compressed OOPs enabled:
StringBuilder:- Object Header: 12 bytes (8 bytes Mark Word + 4 bytes Compressed Klass Pointer).
byte[] valuereference: 4 bytes.byte coder: 1 byte.- 8-byte alignment padding: 3 bytes.
int count: 4 bytes.- Total: Exactly 24 bytes on the Java Heap (+
byte[]array size).
StringBuffer:- Includes an additional
private transient String toStringCachepointer (4 bytes). - Aligned to an 8-byte boundary, its instance size expands to 32 bytes (33% larger heap overhead).
- Upon monitor inflation (Fat Lock), an additional native C++
ObjectMonitorstructure is allocated in C-Heap (off-heap native memory), consuming an extra 128 to 200+ bytes per contended object.
- Includes an additional
🎯 Interview Cheat Sheet
30-Second Summary
“
StringBufferis slower thanStringBuilderbecause all of its public mutating methods are decorated withsynchronized. Even in single-threaded execution, the JVM executes atomic CAS instructions (LOCK CMPXCHG), memory barriers, and monitor state checks. Following the removal of Biased Locking in Java 15 (JEP 374), this uncontended synchronization penalty increased. Under multi-threaded contention, performance degrades drastically due to monitor inflation and OS thread parking.StringBuilderhas no synchronization, writing directly to memory without locking overhead.”
Key Concepts Checklist
- Root Cause: Public mutating methods in
StringBufferare synchronized onthis. - Single-Thread Cost: Atomic CAS instructions, store buffer flushes, and pipeline stalls.
- Multi-Thread Cost: Monitor inflation into native
ObjectMonitorand OS kernel context switches (1–5 $\mu$s penalty). - JEP 374 Impact: Removal of Biased Locking in Java 15 makes uncontended synchronization measurably slower in Java 17/21+.
- Escape Analysis Limits: Lock Elision fails if methods cannot be inlined or if references escape local scope.
Common Pitfalls & Red Flags
- ❌ “StringBuffer is slower because it allocates more intermediate objects.” (False: both use the identical
AbstractStringBuilderdynamic byte buffer). - ❌ “The JIT compiler always eliminates StringBuffer locks via Escape Analysis.” (False: inlining limits frequently disable lock elision in real code).
- ❌ “Biased locking makes StringBuffer free in single-threaded Java 17/21.” (False: Biased Locking was removed in Java 15).
Related Topics
- When to Use StringBuilder vs StringBuffer — Architectural selection
- Why String is Immutable — Immutability rationale
- What Happens When Concatenating Strings with + Operator — Compiler concatenation mechanics
- What are Compact Strings in Java 9+ — Compact string encoding