🔤 Section 12 · Question #5

When to Use StringBuilder vs StringBuffer

Both StringBuilder and StringBuffer represent mutable sequences of characters. Unlike the immutable String class, where any modification or concatenation allocates a new object...


🟢 Junior Level

Both StringBuilder and StringBuffer represent mutable sequences of characters. Unlike the immutable String class, where any modification or concatenation allocates a new object on the Java Heap, StringBuilder and StringBuffer mutate characters directly inside an internal dynamic buffer, drastically reducing memory churn and Garbage Collection pressure.

The Essence in 30 Seconds

The fundamental distinction between them is thread safety:

  • StringBuilder (introduced in Java 5): Not thread-safe. None of its methods are synchronized. It operates at maximum execution speed and is the recommended default choice in 99.9% of application tasks where string assembly occurs within a single thread (such as local method variables).
  • StringBuffer (present since Java 1.0): Thread-safe. Almost all public mutating methods are decorated with the synchronized modifier. It incurs locking overhead (monitor acquisition, memory barriers). It is considered a legacy class and is rarely used in modern architectures.
// 1. Recommended: StringBuilder for single-threaded string assembly (99.9% of use cases)
public String buildReport(List<String> items) {
    // Pre-allocate estimated capacity when size is approximately known
    StringBuilder sb = new StringBuilder(items.size() * 32);
    sb.append("HEADER\n");
    for (String item : items) {
        sb.append("- ").append(item).append("\n");
    }
    return sb.toString();
}

// 2. Legacy: StringBuffer for shared cross-thread buffers
public class ThreadSafeLogger {
    // Shared between multiple concurrent threads:
    private final StringBuffer sharedBuffer = new StringBuffer();

    public void log(String threadName, String message) {
        // Individual append calls are synchronized on 'this' monitor:
        sharedBuffer.append("[").append(threadName).append("] ").append(message).append("\n");
    }
}

Simple Rule of Thumb

  • Modifying a string within a method body, loop, or single thread? 👉 Always use StringBuilder.
  • Need to share string building across threads? 👉 In modern Java, prefer thread-confined StringBuilder instances inside ThreadLocal, or assemble messages locally and publish them to a concurrent queue (BlockingQueue). StringBuffer is largely obsolete.

🟡 Middle Level

Internal Architecture: The AbstractStringBuilder Foundation

Both StringBuilder and StringBuffer subclass the package-private class java.lang.AbstractStringBuilder and implement CharSequence, Appendable, Serializable, and (since Java 11) Comparable.

Inside AbstractStringBuilder, the internal state is governed by three primary fields:

abstract sealed class AbstractStringBuilder implements Appendable, CharSequence {
    // In Java 9+ (Compact Strings JEP 254), byte[] replaces char[]
    byte[] value;
    // Encoding: LATIN1 (0) or UTF16 (1)
    byte coder;
    // Number of characters actually written (distinct from capacity!)
    int count;
}

Prior to Java 9, the buffer was a char[] value. Under JEP 254 (Compact Strings), HotSpot uses a byte[] value. If all appended characters fit within ISO-8859-1 (Latin-1), coder = 0, consuming 1 byte per character. If any non-Latin-1 character (such as Cyrillic, Chinese, or emoji) is introduced, the buffer is inflated to UTF-16 (coder = 1), allocating 2 bytes per character.

Dynamic Buffer Resizing Mechanics

When instantiated via the default constructor new StringBuilder(), the buffer allocates an initial capacity of 16 characters:

public StringBuilder() {
    super(16);
}

When new characters exceed the current capacity of value, the JVM invokes ensureCapacityInternal().

Capacity Growth Formula: \(\text{newCapacity} = (\text{oldCapacity} \ll 1) + 2 = \text{oldCapacity} \times 2 + 2\)

If that expanded capacity is still insufficient to hold the incoming text, the buffer expands to the exact required minimum (count + str.length()).

Default capacity growth progression:
16  -->  (16 * 2) + 2 = 34  -->  (34 * 2) + 2 = 70  -->  (70 * 2) + 2 = 142 ...

Each buffer resize triggers:

  1. Allocation of a new byte[] array on the Java Heap.
  2. Fast memory copy via native System.arraycopy().
  3. Abandonment of the old array to Garbage Collection.

Comprehensive Comparison

Feature StringBuilder StringBuffer
Introduced In Java 5 (J2SE 5.0) Java 1.0
Thread Safety ❌ No ✅ Yes (via synchronized methods)
Overhead Minimal (direct array and field writes) Monitor lock acquisition, memory barriers
Single-Thread Speed 1.5x – 2.5x faster Noticeably slower due to monitor synchronization
Primary Use Case All local string building and formatting Legacy code bases, rare shared mutable buffers
Method Synchronization None Public mutating methods marked synchronized
toString() Caching ❌ No caching ✅ Cached via private transient String toStringCache

Common Performance Pitfalls

  1. Default Capacity in Large Text Assembly:
    // BAD: Reallocates and copies arrays ~10 times for a 10,000-character payload:
    StringBuilder sb = new StringBuilder(); 
       
    // GOOD: Exactly 1 heap allocation, zero intermediate array copies:
    StringBuilder sb = new StringBuilder(estimatedLength);
    
  2. String Concatenation with + inside append():
    // BAD: Compiles to an extra intermediate StringBuilder for (a + " : " + b):
    sb.append(a + " : " + b); 
       
    // GOOD: Fluent chained calls with zero intermediate heap objects:
    sb.append(a).append(" : ").append(b);
    

🔴 Senior Level

Method Implementation Differences in HotSpot

Examining the OpenJDK implementations reveals the synchronization cost:

// java.lang.StringBuilder: direct unsynchronized delegate
@Override
@IntrinsicCandidate
public StringBuilder append(String str) {
    super.append(str);
    return this;
}

// java.lang.StringBuffer: monitor acquisition + cache invalidation
@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

Key low-level differences in StringBuffer:

  1. Monitor Locking: The synchronized keyword generates monitorenter and monitorexit bytecode instructions. Even without contention, a thread must execute atomic instructions (e.g., LOCK CMPXCHG on x86) to update the object’s Mark Word.
  2. toStringCache Optimization: StringBuffer holds a private transient String toStringCache field. Calling toString() caches the resulting String. If no mutations occur, subsequent toString() calls return the cached reference without copying the array. However, any mutating method (append, delete, insert) resets toStringCache = null.
  3. StringBuilder.toString() Defensive Copy: StringBuilder.toString() always creates a new String via Arrays.copyOfRange(value, 0, count). The internal byte[] value can never be passed directly into the String object because subsequent mutations on the builder would corrupt the immutable String.

JIT Optimizations: Escape Analysis & Lock Elision

The HotSpot C2 JIT compiler performs Escape Analysis to determine if an object escapes its allocating thread:

  • NoEscape: The object remains strictly within the allocating method frame.
  • ArgEscape: The object is passed as an argument to other methods but does not escape globally.
  • GlobalEscape: The object escapes to a field, static reference, or method return value.

If C2 proves that a StringBuffer is NoEscape:

public String buildLocal() {
    StringBuffer sb = new StringBuffer(); // NoEscape
    sb.append("A").append("B");
    return sb.toString();
}

C2 applies Lock Elision (-XX:+EliminateLocks), stripping monitorenter and monitorexit opcodes from compiled assembly.
Why is StringBuffer still slower?

  • Lock elision only applies after C2 JIT compilation thresholds are reached; early interpretations still execute locks.
  • Complex control flows, deep method calls, or non-inlined methods degrade analysis to ArgEscape/GlobalEscape, disabling lock elision.
  • Invalidation writes to toStringCache inhibit register allocation optimizations.

Impact of Biased Locking Removal (Java 15+ JEP 374)

Historically, HotSpot used Biased Locking to eliminate atomic CAS instructions on uncontended monitor acquisitions by biasing the object’s Mark Word to the executing thread.
In Java 15 (JEP 374), Biased Locking was disabled and subsequently removed from the JVM:

  • The complex safepoint pauses required to revoke biased locks severely degraded latency in modern concurrent collectors (ZGC, Shenandoah).
  • Consequence for StringBuffer: Without biased locking, uncontended synchronized calls fall back to thin locks (atomic CAS operations), widening the performance gap between StringBuffer and StringBuilder in modern runtimes (Java 17, 21+).

Catastrophic Multithreading Failures with StringBuilder

Sharing an unsynchronized StringBuilder across threads produces severe non-deterministic defects:

  1. Lost Updates: The increment count += len is a non-atomic read-modify-write operation. Two threads read the same count, write their characters to identical array indices, and overwrite each other’s data.
  2. ArrayIndexOutOfBoundsException: Thread A verifies capacity via ensureCapacityInternal(). The OS preempts Thread A, and Thread B appends characters, advancing count. When Thread A resumes, its write exceeds the physical bounds of value.
  3. Silent Data Corruption & Null Byte Holes: Characters may be written at advanced offsets, leaving uninitialized \0 null bytes inside the string without throwing any runtime exceptions.

4 Tricky Interview Questions

1. What happens if multiple threads invoke sb.append() on the same StringBuilder instance simultaneously? Is an exception guaranteed?

Answer:
No exception is guaranteed. The behavior is non-deterministic and depends on OS thread scheduling:

  • Scenario 1 (Exception): ArrayIndexOutOfBoundsException or NegativeArraySizeException occurs when concurrent threads pass capacity validation simultaneously, but the combined writes push count beyond the allocated array bounds.
  • Scenario 2 (Silent Data Corruption): No exception is thrown, but data is corrupted. Threads overwrite each other’s characters (lost updates), the final sb.length() is less than the sum of appended string lengths, and uninitialized \0 null bytes may be embedded in the result.

2. Does StringBuffer guarantee thread safety for compound operations (check-then-act) without external synchronization?

Answer:
No, it does not. StringBuffer guarantees atomicity only for individual method invocations (append(), delete()).
Compound operations that perform a check followed by an action (check-then-act) remain susceptible to race conditions:

// RACE CONDITION despite using StringBuffer!
if (sharedBuffer.length() >= 5) {
    // Another thread can clear sharedBuffer between length() and delete()!
    // Result: StringIndexOutOfBoundsException!
    sharedBuffer.delete(0, 5);
}

To guarantee thread safety for compound operations, caller code must explicitly synchronize on the StringBuffer monitor:

synchronized (sharedBuffer) {
    if (sharedBuffer.length() >= 5) {
        sharedBuffer.delete(0, 5);
    }
}

3. Why does StringBuilder.toString() always perform an array copy instead of returning the internal byte[] value directly via a package-private String constructor?

Answer:
To uphold the immutability contract of java.lang.String.
If StringBuilder.toString() shared its internal byte[] value directly with the newly created String, any subsequent call to sb.append() or sb.setCharAt() would mutate the characters inside the supposedly immutable String object. This would introduce severe security vulnerabilities (tampering with validated URLs or file paths) and break hash table invariants.
Therefore, StringBuilder.toString() executes Arrays.copyOfRange(value, 0, count) to ensure complete memory isolation.

4. What is the operational difference between sb.setLength(0) and new StringBuilder() when reusing buffers in high-throughput loops?

Answer:

  • sb.setLength(0): Resets the logical counter count = 0 without deallocating the underlying byte[] value array. This allows zero-allocation buffer reuse in high-throughput loops, saving GC churn.
    • Risk: If a single iteration appends an unusually large payload (e.g., 20 MB), that expanded array remains retained in the heap indefinitely (Memory Retention / Leak).
  • new StringBuilder(): Allocates a fresh object with default capacity (16 characters), releasing the previous buffer to the Garbage Collector. It prevents memory retention of oversized buffers at the cost of minor GC allocations.

🎯 Interview Cheat Sheet

30-Second Summary

“StringBuilder and StringBuffer represent mutable character sequences backed by a dynamic array. The core distinction is that StringBuffer synchronizes all public methods, making it thread-safe but slower due to monitor locking overhead. StringBuilder is unsynchronized, executes 1.5x–2.5x faster, and is the default choice in 99.9% of application tasks. StringBuffer is a legacy Java 1.0 class; in modern concurrency, developers prefer thread-local StringBuilder instances or lock-free message passing via queues.”

Key Concepts Checklist

  1. Thread Safety: StringBuilder is unsynchronized; StringBuffer synchronizes on this.
  2. Compact Strings (Java 9+): Both extend AbstractStringBuilder managing byte[] value + byte coder (Latin-1 vs UTF-16).
  3. Resizing Formula: $(\text{oldCapacity} \ll 1) + 2$ (progression: 16 $\to$ 34 $\to$ 70 $\to$ 142).
  4. Biased Locking Removal (Java 15): JEP 374 removed biased locking, increasing the single-threaded overhead of StringBuffer.
  5. Defensive Copying in toString(): StringBuilder always copies its array to preserve String immutability.

Common Pitfalls & Red Flags

  • ❌ “Always use StringBuffer in multithreaded apps to be safe.” (Sharing mutable string buffers across threads is an anti-pattern; use StringBuilder locally).
  • ❌ “StringBuffer prevents all race conditions.” (It only synchronizes individual methods; compound check-then-act operations still require explicit locks).
  • ❌ “StringBuilder uses char[] under the hood in modern Java.” (Since Java 9, it uses byte[] with Compact Strings).
  • ❌ “StringBuilder without initial capacity is fine for massive payloads.” (Causes ~10 redundant memory allocations and array copies).