☕ Java

Mutable String

Java provides two mutable string classes for scenarios where String's immutability would be inefficient: StringBuilder and StringBuffer. Both maintain an internal character buffer that can be modified in place — characters can be appended, inserted, deleted, and replaced without creating new objects. StringBuilder is the modern choice for single-threaded use; StringBuffer is the legacy thread-safe version with synchronised methods. This entry covers the internal buffer mechanics, the full API of both classes, performance characteristics, when to use each, thread safety implications, and the complete patterns for efficient string construction.

StringBuilder — Internal Buffer Mechanics

StringBuilder maintains a resizable char array internally. The array has a capacity — the total number of characters it can hold — and a length — the number of characters currently stored. When append() operations fill the array, StringBuilder automatically grows its buffer: it allocates a new array approximately twice the previous capacity and copies all existing characters into it. This amortised growth strategy means that n append operations have O(n) total cost, not O(n²) as with repeated String concatenation. The default initial capacity is 16 characters. If the final string length is known in advance, passing the expected length to the constructor avoids reallocations. For example, when building a string from n items each of average length k, initialise the builder with new StringBuilder(n * (k + 2)) to pre-allocate sufficient capacity. StringBuilder's fluent API returns this from every mutating method — append(), insert(), delete(), replace(), reverse(). This enables method chaining: sb.append("Hello").append(", ").append(name).append("!"). The chaining is purely a convenience; each append() call modifies the same internal buffer and returns the same StringBuilder object, so no intermediate objects are created.
Java
// ── StringBuilder internal mechanics ────────────────────────────────
StringBuilder sb = new StringBuilder();   // capacity=16, length=0
sb.append("Hello");                       // capacity=16, length=5
sb.append(" World");                      // capacity=16, length=11
sb.append(" and more text here");         // capacity grows (1634), length=28

System.out.println(sb.length());    // 28 — characters currently stored
System.out.println(sb.capacity());  // 34 — total buffer size (or larger)

// ── Pre-allocate for known size ───────────────────────────────────────
List<String> parts = List.of("alpha", "beta", "gamma");
int estimatedSize = parts.stream().mapToInt(String::length).sum()
    + parts.size() * 2;   // 14 chars + 6 for separators
StringBuilder sb2 = new StringBuilder(estimatedSize);
for (String part : parts) {
    sb2.append(part).append(", ");
}
// No reallocations — single allocation at construction

// ── Fluent API — every mutating method returns 'this' ─────────────────
String result = new StringBuilder()
    .append("Hello")
    .append(", ")
    .append("World")
    .append("!")
    .toString();
System.out.println(result);   // Hello, World!

// ── Growth strategy ───────────────────────────────────────────────────
// new capacity = Math.max(oldCapacity * 2 + 2, requiredCapacity)
// So a single append that exceeds current capacity may grow to exactly
// what is needed rather than doubling (if the needed size is larger)

StringBuilder sb3 = new StringBuilder(4);  // capacity = 4
sb3.append("Hello World");                 // needs 11, grows to max(10, 11) = 11
System.out.println(sb3.capacity());        // 11

// ── trimToSize() — reclaim excess buffer memory ───────────────────────
StringBuilder large = new StringBuilder(1000);
large.append("short");
large.trimToSize();           // reduces capacity to match length (5)
System.out.println(large.capacity());  // 5

StringBuilder API — Complete Method Reference

StringBuilder provides a rich API for all in-place string manipulation. The append() method is overloaded for all primitive types (int, long, double, boolean, char), char arrays, CharSequences, and Objects — it calls toString() for arbitrary objects. The insert() method inserts content at any position, shifting existing characters right. The delete() method removes a range. The replace() method replaces a range with a new string. The reverse() method reverses all characters in place. The indexOf() and lastIndexOf() methods search for substrings within the builder, returning the index of the first or last occurrence. The charAt() and setCharAt() methods provide direct character-level access. The deleteCharAt() method removes a single character. The substring() and subSequence() methods extract portions as new Strings or CharSequences. These operations all work on the internal buffer without creating intermediate String objects, which is the entire reason for StringBuilder's existence. The object creation happens only at toString(), which creates the final immutable String from the builder's buffer.
Java
// ── append() — all overloads ─────────────────────────────────────────
StringBuilder sb = new StringBuilder();
sb.append("Hello");            // String
sb.append(' ');                // char
sb.append(42);                 // int
sb.append(3.14);               // double
sb.append(true);               // boolean
sb.append(new char[]{'!','!'});// char[]
sb.append((Object) null);      // Object → appends "null"
System.out.println(sb);        // Hello 423.14true!!null

// ── insert() — insert at any position ────────────────────────────────
StringBuilder sb2 = new StringBuilder("Hello World");
sb2.insert(5, ",");         // insert at index 5
System.out.println(sb2);   // Hello, World

sb2.insert(0, ">>> ");     // insert at start
System.out.println(sb2);   // >>> Hello, World

sb2.insert(sb2.length(), " <<<");  // append via insert
System.out.println(sb2);           // >>> Hello, World <

// ── delete() and deleteCharAt() ──────────────────────────────────────
StringBuilder sb3 = new StringBuilder("Hello, World!");
sb3.delete(5, 7);          // remove indices [5, 7): removes ", "
System.out.println(sb3);   // HelloWorld!

sb3.deleteCharAt(5);       // remove character at index 5
System.out.println(sb3);   // Helloorld!

// ── replace() ────────────────────────────────────────────────────────
StringBuilder sb4 = new StringBuilder("Hello World");
sb4.replace(6, 11, "Java");  // replace "World" with "Java"
System.out.println(sb4);     // Hello Java

// ── reverse() ────────────────────────────────────────────────────────
StringBuilder sb5 = new StringBuilder("abcde");
sb5.reverse();
System.out.println(sb5);   // edcba

// ── charAt() and setCharAt() ──────────────────────────────────────────
StringBuilder sb6 = new StringBuilder("Hello");
System.out.println(sb6.charAt(1));   // 'e'
sb6.setCharAt(0, 'h');               // in-place modification!
System.out.println(sb6);             // hello

// ── indexOf() and lastIndexOf() ──────────────────────────────────────
StringBuilder sb7 = new StringBuilder("abcabcabc");
System.out.println(sb7.indexOf("bc"));      // 1 — first occurrence
System.out.println(sb7.lastIndexOf("bc"));  // 7 — last occurrence
System.out.println(sb7.indexOf("bc", 3));   // 4 — first after index 3

// ── substring() — extract as new String ──────────────────────────────
StringBuilder sb8 = new StringBuilder("Hello World");
System.out.println(sb8.substring(6));       // "World"
System.out.println(sb8.substring(0, 5));    // "Hello"

StringBuffer — The Thread-Safe Alternative

StringBuffer is the original mutable string class introduced in Java 1.0, predating StringBuilder which arrived in Java 1.5. Every method in StringBuffer is synchronized — append(), insert(), delete(), replace(), and all others acquire the object's intrinsic lock before modifying the buffer and release it after. This synchronisation makes StringBuffer safe to use from multiple threads writing to the same instance simultaneously. The cost of this thread safety is performance overhead. Every method call involves lock acquisition and release, even in single-threaded code where no contention exists. Benchmarks consistently show StringBuilder to be 10-50% faster than StringBuffer for single-threaded use due to eliminated lock overhead. The performance gap narrows under contention because both classes must serialize access when threads compete. The practical guidance is unambiguous: use StringBuilder everywhere except when the same builder instance is explicitly shared between threads and concurrently written. Sharing a StringBuilder between threads for concurrent writing requires external synchronisation anyway — the individual method synchronisation in StringBuffer is usually insufficient for compound operations (check length, then append) which need an external lock to be atomic. ThreadLocal<StringBuilder> is a better pattern when per-thread builders are needed for performance.
Java
// ── StringBuffersynchronized methods ──────────────────────────────
StringBuffer sbuf = new StringBuffer();
sbuf.append("Thread-safe ");
sbuf.append("append");

// Every method is synchronized — safe for concurrent writes to SAME instance
// But usually still needs external sync for compound operations

// ── Performance comparison: StringBuilder vs StringBuffer ─────────────
int N = 1_000_000;
String[] parts = {"hello", " ", "world"};

// StringBuilder — no locking
long start = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < N; i++) {
    sb.setLength(0);   // clear without reallocation
    sb.append(parts[0]).append(parts[1]).append(parts[2]);
}
System.out.println("StringBuilder: " + (System.nanoTime() - start) / N + "ns/op");

// StringBuffer — lock on every method
start = System.nanoTime();
StringBuffer sbuf2 = new StringBuffer();
for (int i = 0; i < N; i++) {
    sbuf2.setLength(0);
    sbuf2.append(parts[0]).append(parts[1]).append(parts[2]);
}
System.out.println("StringBuffer:  " + (System.nanoTime() - start) / N + "ns/op");
// StringBuilder typically 30-50% faster

// ── Decision: which to use ────────────────────────────────────────────
//
// StringBuilder99% of cases:
//   - Single-threaded code
//   - Builder not shared between threads
//   - Method-local builders (most common case)
//
// StringBuffer — rare:
//   - Same instance written from multiple threads
//   - Legacy API compatibility
//   - (Still requires external sync for compound atomicity)

// ── ThreadLocal pattern — per-thread builders ─────────────────────────
// Better than StringBuffer for per-thread string building
private static final ThreadLocal<StringBuilder> THREAD_LOCAL_SB =
    ThreadLocal.withInitial(() -> new StringBuilder(256));

public static String buildString(List<String> parts) {
    StringBuilder sb2 = THREAD_LOCAL_SB.get();
    sb2.setLength(0);  // clear for reuse
    for (String part : parts) sb2.append(part);
    return sb2.toString();
}

// ── setLength() — reuse builder across multiple calls ────────────────
StringBuilder reusable = new StringBuilder(128);
for (String item : items) {
    reusable.setLength(0);       // clear — does NOT release the buffer
    reusable.append("[").append(item).append("]");
    process(reusable.toString());
}
// One StringBuilder object, one char[] buffer, reused for all iterations

Practical Patterns for Efficient String Building

The right choice between String, StringBuilder, and StringBuffer depends on the usage pattern. String for all cases where the value does not change after creation — which is the vast majority of string usage. StringBuilder for all cases where a string is built incrementally from pieces — query building, report generation, CSV construction, template filling. StringBuffer only for the rare case of concurrent writes to the same builder instance. Several practical patterns recur in production code. The delimiter pattern — joining items with a separator — is cleaner with String.join() or StringJoiner than with manual StringBuilder logic. The conditional append pattern builds strings based on conditions without intermediate strings. The reuse pattern clears a builder with setLength(0) between uses instead of creating a new builder for each call, reusing the allocated buffer.
Java
// ── Pattern 1: Building delimited lists ──────────────────────────────
// Using StringJoiner — purpose-built for delimited lists
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("Alice");
sj.add("Bob");
sj.add("Carol");
System.out.println(sj);  // [Alice, Bob, Carol]

// Optional values — setEmptyValue handles the empty case
StringJoiner sj2 = new StringJoiner(", ");
sj2.setEmptyValue("(none)");
System.out.println(sj2);  // (none) — no items added

// ── Pattern 2: Conditional append ────────────────────────────────────
public static String buildWhereClause(SearchParams params) {
    StringBuilder sb = new StringBuilder("WHERE 1=1");

    if (params.getName() != null) {
        sb.append(" AND name = '").append(params.getName()).append("'");
    }
    if (params.getStatus() != null) {
        sb.append(" AND status = '").append(params.getStatus()).append("'");
    }
    if (params.getMinAge() > 0) {
        sb.append(" AND age >= ").append(params.getMinAge());
    }
    if (params.getMaxAge() > 0) {
        sb.append(" AND age <= ").append(params.getMaxAge());
    }
    return sb.toString();
}

// ── Pattern 3: CSV row builder ────────────────────────────────────────
public static String toCsv(Object[] values) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < values.length; i++) {
        if (i > 0) sb.append(',');
        String val = String.valueOf(values[i]);
        if (val.contains(",") || val.contains(""") ||
                val.contains("
")) {
            sb.append('"');
            sb.append(val.replace(""", """"));
            sb.append('"');
        } else {
            sb.append(val);
        }
    }
    return sb.toString();
}

// ── Pattern 4: Efficient repeated operation with reuse ────────────────
public List<String> processAll(List<String> inputs) {
    List<String> results = new ArrayList<>(inputs.size());
    StringBuilder sb = new StringBuilder(256);  // single allocation

    for (String input : inputs) {
        sb.setLength(0);                // clear — reuse buffer
        sb.append("[").append(input.trim().toUpperCase()).append("]");
        results.add(sb.toString());
    }
    return results;
}

// ── Pattern 5: StringBuilder as a formatter ───────────────────────────
public String formatTable(List<String[]> rows, int[] widths) {
    StringBuilder sb = new StringBuilder();
    String separator = "-".repeat(
        Arrays.stream(widths).sum() + widths.length * 3 + 1);

    sb.append(separator).append('
');
    for (String[] row : rows) {
        sb.append('|');
        for (int i = 0; i < row.length; i++) {
            sb.append(String.format(" %-" + widths[i] + "s |", row[i]));
        }
        sb.append('
').append(separator).append('
');
    }
    return sb.toString();
}

Related Topics in Strings

String Class
String is one of the most fundamental classes in Java — used in virtually every program, yet deeply misunderstood by many developers. A String represents an immutable sequence of Unicode characters. It is not a primitive type but a full class in java.lang, automatically imported into every Java file. Understanding String means understanding how it is stored in memory, why it is immutable, how the string pool works, what the difference between == and equals() means for strings, and how to use the class efficiently. This entry covers String's nature as a class, its internal representation, the critical distinction between reference equality and value equality, String's place in the type hierarchy, and the design decisions that make String behave the way it does.
String Pool
The string pool (also called the string intern pool or string constant pool) is a special memory region maintained by the JVM that stores a single copy of each unique string value. When two string literals have the same content, they refer to the same object in the pool rather than two separate objects. The pool is a flyweight pattern applied at the language level — it dramatically reduces memory consumption in applications that use many repeated string values, which is nearly every application. This entry covers how the pool works, where it lives in JVM memory, how to interact with it programmatically, the intern() method, performance implications, and when to use or avoid pool entries.
Immutable String
String immutability is the most important design decision in Java's String class. Once a String object is created, its character sequence can never change. No method on String modifies the string; every method that appears to modify returns a new String object containing the result. This design decision drives thread safety, enables the string pool, makes strings safe hash map keys, and simplifies reasoning about string values. Understanding why String is immutable, how immutability is enforced, and what the consequences of immutability are clarifies the behaviour of virtually every piece of Java code that handles strings.
String Methods
The String class provides over 60 methods covering character inspection, searching, comparison, transformation, splitting, joining, formatting, and encoding. Knowing these methods well eliminates the need to write manual character-by-character loops for common string operations and prevents the common mistake of reimplementing logic that the library already provides efficiently. This entry covers every major method category with precise semantics, edge cases, performance characteristics, and practical examples — from the fundamental length() and charAt() through to the modern Java 11+ additions like strip(), isBlank(), lines(), and repeat().