Question 5 Β· Section 12

When to Use StringBuilder vs StringBuffer?

Both classes are designed for modifying strings. Unlike regular String, they can change their content without creating new objects.

Language versions: English Russian Ukrainian

🟒 Junior Level

Both classes are designed for modifying strings. Unlike regular String, they can change their content without creating new objects.

Main difference: StringBuffer is thread-safe (synchronized), StringBuilder is not.

Example:

// StringBuilder β€” use in 99% of cases
StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append(" ");
sb.append("World");
String result = sb.toString(); // "Hello World"

// StringBuffer β€” only if string is used from multiple threads
StringBuffer sb2 = new StringBuffer();
sb2.append("Hello");

Rule: Use StringBuilder by default. StringBuffer only makes sense when one buffer is shared between threads. In legacy code it appears more often, but this is a historical artifact, not a recommendation.


🟑 Middle Level

How it works

Both classes inherit from AbstractStringBuilder and work with an internal byte[] array (Java 9+) or char[] (before Java 9):

// Internal structure
AbstractStringBuilder {
    byte[] value;   // Data array
    int count;      // Current character count
}

Initial capacity: 16 characters by default. On overflow, the array expands: newCapacity = (oldCapacity << 1) + 2 (doubling + 2).

Practical application

// StringBuilder β€” building a string in a single thread (typical case)
StringBuilder sb = new StringBuilder();
for (User user : users) {
    sb.append(user.getName()).append(", ");
}

Typical mistakes

  1. Mistake: Using StringBuffer β€œjust in case” Solution: Synchronization adds overhead even in single-threaded code

  2. Mistake: Not specifying capacity when size is known Solution: new StringBuilder(1024) avoids unnecessary reallocations

Comparison

| Criterion | StringBuilder | StringBuffer | | β€”β€”β€”β€”β€”β€” | ————– | β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” | | Thread safety | No | Yes (synchronized) | | Speed (1 thread) | Faster | 1.5-2.5x slower. Each synchronized method requires monitorenter/monitorexit + memory barriers. | In single-threaded mode, JVM can partially elide locks, but not completely. | | Speed (N threads) | Race condition | Correct, but contention | | When introduced | Java 5 | Java 1.0 | | Recommendation | By default | Only for real shared access |


πŸ”΄ Senior Level

Internal Implementation

// StringBuffer β€” all methods are synchronized
@Override
public synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}

// StringBuilder β€” without synchronization
@Override
public StringBuilder append(Object obj) {
    return append(String.valueOf(obj));
}

Key difference: every StringBuffer method has synchronized, meaning object monitor acquisition on each call.

Architectural Trade-offs

StringBuffer:

  • Pros: Thread-safe, legacy compatibility
  • Cons: Monitor on every method, memory barriers, false sharing under contention

StringBuilder:

  • Pros: No synchronization overhead, optimal performance
  • Cons: Not thread-safe β€” race condition on parallel access

JVM optimizations: Lock Elision

Modern JVM (HotSpot) uses Escape Analysis + Lock Elision:

void method() {
    StringBuffer sb = new StringBuffer(); // Escape: doesn't escape method scope
    sb.append("a");                       // JIT may remove synchronized
    sb.append("b");
}

If JIT proved the object doesn’t β€œescape” the thread β€” synchronization is removed. But:

  • This is not guaranteed (depends on -XX:+EliminateLocks, compilation budget)
  • The analysis itself costs CPU cycles
  • StringBuilder is fast β€œout of the box” without JIT magic

Edge Cases

  1. Biased Locking (removed in Java 15): Previously JVM β€œbiased” the monitor to the first thread, making subsequent captures free. This means in Java 15+ the StringBuffer overhead in single-threaded mode is even higher than before.

  2. Resize formula: (oldCapacity << 1) + 2. For capacity=16 β†’ 34 β†’ 70 β†’ 142. For known sizes, always set capacity in constructor.

  3. toStringCache in StringBuffer: StringBuffer caches the last toString() result. If buffer hasn’t changed, repeated toString() returns cache β€” but this is a micro-optimization.

  4. Java 9+ Compact Strings: Both classes use byte[] with coder flag. Latin characters = 1 byte, others = 2 bytes.

Performance (JMH benchmarks)

| Operation | StringBuilder | StringBuffer (no contention) | StringBuffer (4 threads) | | β€”β€”β€”β€”- | β€”β€”β€”β€”- | β€”β€”β€”β€”β€”β€”β€”β€”β€”- | β€”β€”β€”β€”β€”β€”β€”β€” | | append 1M | ~15ms | ~25ms | ~120ms | | toString | ~2ms | ~2ms (cache) | ~2ms | | Memory | ~2MB | ~2MB + monitor | ~2MB + monitor overhead |

Production Experience

Scenario: Rendering HTML report (50KB total) from 10,000 records:

  • Without capacity: 12 realloc + copy operations β†’ 15ms
  • With new StringBuilder(51200): 0 realloc β†’ 3ms
  • StringBuffer in same scenario: 20ms (lock elision helped, but not fully)

Best Practices for Highload

  • Always specify initialCapacity if size is predictable
  • In loops: StringBuilder outside the loop, append inside
  • StringBuffer β€” only if buffer is shared between threads (extremely rare case)
  • For concatenation outside loops: Java 9+ invokedynamic is often more efficient than manual StringBuilder

🎯 Interview Cheat Sheet

Must know:

  • StringBuilder β€” mutable, NOT thread-safe, use in 99% of cases
  • StringBuffer β€” mutable, thread-safe (synchronized), legacy from Java 1.0
  • Both work with internal byte[] (Java 9+) / char[] (before Java 9), expand on overflow: newCapacity = (old << 1) + 2
  • Default initial capacity β€” 16 characters. For known size β€” set in constructor
  • StringBuilder is 1.5-2.5x faster than StringBuffer in single-threaded mode
  • In loops: create StringBuilder BEFORE the loop, append INSIDE

Frequent follow-up questions:

  • Why is StringBuffer slower? β€” Every method is synchronized: monitor acquisition, memory barriers, release. Even in a single thread.
  • When does expansion happen? β€” On overflow: 16 β†’ 34 β†’ 70 β†’ 142. Each expansion = new array allocation + System.arraycopy.
  • Can you use StringBuffer for safety? β€” If each thread uses its own buffer β€” StringBuilder is safe. StringBuffer is only needed for real shared access.
  • What does JVM do to optimize StringBuffer? β€” Lock Elision via Escape Analysis: if object doesn’t β€œescape” the thread β€” JIT may remove synchronized. But not guaranteed.

Red flags (DON’T say):

  • ❌ β€œStringBuffer β€” the recommended choice” β€” outdated, StringBuilder is the default
  • ❌ β€œStringBuilder is thread-safe” β€” no, race condition on parallel access
  • ❌ β€œNo need to set capacity” β€” without capacity: 12+ realloc for a 50KB string
  • ❌ β€œStringBuffer is needed for every multithreaded app” β€” only if ONE buffer is shared between threads

Related topics:

  • [[6. Why StringBuffer is Slower than StringBuilder]]
  • [[4. Why String is Immutable]]
  • [[7. What Happens When Concatenating Strings with + Operator]]