Fundamentals 16 min read

How Many Objects Does Java '+' Concatenation Secretly Create? JVM Internals Explained

This article explains Java String immutability, the hidden object creation behind '+' concatenation across JDK versions, StringBuilder and StringBuffer internals, constant pool behavior, and the invokedynamic optimization in JDK 9+, with bytecode examples and performance best practices.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
How Many Objects Does Java '+' Concatenation Secretly Create? JVM Internals Explained

String Immutability and Internal Storage

JDK 8: char[] Backing Array

In JDK 8, String stores characters in a private final char value[] array, along with a cached hash field. The class is declared final, preventing inheritance that could break immutability.

JDK 9+: Compact Strings (JEP 254)

JDK 9 introduced Compact Strings, replacing char[] with a byte[] value plus a byte coder field ( LATIN1=0 for single-byte, UTF16=1 for double-byte). This halves memory for pure ASCII/Latin-1 text; strings containing non-Latin characters (e.g., Chinese) fall back to UTF-16 encoding.

Why String Is Immutable

String constant pool reuse – immutability allows safe sharing across the application.

HashMap key safety – hashCode never changes after insertion, avoiding rehashing.

Thread safety – immutable objects are inherently thread-safe for read access.

Security – class loaders, file paths, database URLs rely on String; mutability would enable injection attacks.

hashCode caching – the private int hash field computes once and reuses forever.

Immutability's cost: every "modification" creates a new object. This is the root cause of string concatenation performance issues and the reason StringBuilder exists.

"+" Concatenation Under the Hood

Compile-Time Constant Folding

When all operands are compile-time constants, the compiler folds them into a single literal:

String s = "a" + "b" + "c";
// Compiles to:
String s = "abc"; // Directly into constant pool

Bytecode shows a single ldc instruction loading the combined string.

JDK 8: Variable Concatenation Compiles to StringBuilder

With any variable operand, the compiler generates a StringBuilder chain:

String a = getUser();
String b = getOrder();
String c = a + "-" + b;
// Equivalent to:
String c = new StringBuilder().append(a).append("-").append(b).toString();

Bytecode reveals new StringBuilder, dup, invokespecial, multiple append calls, and toString.

Classic Pitfall: Concatenation Inside a Loop

String result = "";
for (int i = 0; i < 10000; i++) {
    result = result + i; // New StringBuilder each iteration!
}

Each iteration allocates a new StringBuilder and a new String, copying the entire previous content. Time complexity approaches O(n²); 100,000 iterations cause noticeable pauses and flood the young generation with short-lived objects.

Correct pattern: hoist a pre-sized StringBuilder outside the loop:

StringBuilder sb = new StringBuilder(100000);
for (int i = 0; i < 10000; i++) {
    sb.append(i);
}
String result = sb.toString();

JDK 9+: invokedynamic (JEP 280)

JDK 9 replaces the fixed StringBuilder translation with an invokedynamic call site:

String c = a + "-" + b;
// Bytecode:
0: aload_1
1: ldc #2 // String -
3: aload_2
4: invokedynamic #3, 0 // InvokeDynamic #0:makeConcatWithConstants
9: astore_3

The bootstrap method StringConcatFactory.makeConcatWithConstants selects the concatenation strategy at runtime (e.g., StringBuilder, direct byte[] filling). The strategy can be overridden via -Djava.lang.invoke.stringConcat=.

Crucially, the loop pitfall remains: invokedynamic still executes per iteration, allocating objects each time. Moving the builder outside the loop is still the correct practice on any JDK version.

StringBuilder Source Code Analysis

Inheritance Hierarchy

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    byte[] value; // JDK 9+, JDK 8 uses char[]
    int count;    // Used length
}

public final class StringBuilder extends AbstractStringBuilder
    implements java.io.Serializable, Comparable<StringBuilder>, CharSequence {}

public final class StringBuffer extends AbstractStringBuilder
    implements java.io.Serializable, Comparable<StringBuffer>, CharSequence {}

Both StringBuilder and StringBuffer extend AbstractStringBuilder, which holds a resizable byte array and a count of used length.

Initial Capacity

public StringBuilder() { super(16); } // Default capacity 16
public StringBuilder(int capacity) { super(capacity); }
public StringBuilder(String str) { super(str.length() + 16); append(str); }

Default capacity is 16, so building long strings inevitably triggers multiple expansions.

append and Expansion Logic

public AbstractStringBuilder append(String str) {
    if (str == null) str = "null";
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getBytes(0, len, value, count); // Copy into array
    count += len;
    return this;
}

Expansion (JDK 9+ uses ArraysSupport.newLength):

private int newCapacity(int minCapacity) {
    int oldCapacity = value.length >> coder;
    int newCapacity = (oldCapacity << 1) + 2; // ~2x + 2
    if (newCapacity < minCapacity) newCapacity = minCapacity;
    return newCapacity;
}

Each expansion copies the entire old array via Arrays.copyOf. Pre-estimating the final length and passing it to the constructor avoids repeated copying:

// Not recommended: default 16, triggers many expansions
StringBuilder sb = new StringBuilder();

// Recommended: pre-size
StringBuilder sb = new StringBuilder(1024);

toString Implementation

public String toString() {
    return new String(value, 0, count);
}

Only toString() creates the final String object; all prior append calls mutate the same underlying array.

Common Methods

StringBuilder sb = new StringBuilder();
sb.append("a");          // Append
sb.insert(0, "x");       // Insert at index
sb.delete(0, 1);         // Delete range
sb.deleteCharAt(0);      // Delete single char
sb.reverse();            // Reverse
sb.setLength(0);         // Clear (reuse array)
sb.replace(0, 1, "y");   // Replace range
int n = sb.length();     // Length
String s = sb.toString(); // Convert to String

StringBuffer: Thread Safety

Synchronized Methods

StringBuffer

mirrors StringBuilder but adds synchronized to every method:

public final class StringBuffer extends AbstractStringBuilder {
    @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

    @Override
    public synchronized String toString() {
        if (toStringCache == null) {
            toStringCache = Arrays.copyOfRange(value, 0, count);
        }
        return new String(toStringCache);
    }
}

Why It Is Rarely Used

String concatenation almost always occurs in method-local variables, which are thread-local by nature – no contention exists.

Synchronization adds pointless overhead.

Even for shared scenarios, prefer immutable String passing or StringJoiner over a shared mutable StringBuffer.

Since JDK 1.5 introduced StringBuilder, 99% of new code should use StringBuilder . StringBuffer persists mainly in legacy code written before StringBuilder existed.

Three-Way Comparison

Mutability : String – immutable; StringBuilder – mutable; StringBuffer – mutable.

Underlying storage : String – final byte[] (JDK 9+); StringBuilder – resizable byte[]; StringBuffer – resizable byte[].

Thread safety : String – safe (immutable); StringBuilder – unsafe; StringBuffer – safe (synchronized).

Concatenation performance : String – poor (new object each time); StringBuilder – best; StringBuffer – slightly slower (locking).

Typical use cases : String – constants, few concatenations, map keys; StringBuilder – vast majority of concatenation; StringBuffer – almost never.

String Constant Pool and intern()

Constant Pool Basics

The string constant pool is a JVM-maintained hash table in the heap storing references to frequently used strings. Double-quoted literals check the pool first; if present, the existing reference is used, otherwise a new entry is created:

String a = "hello";
String b = "hello";
System.out.println(a == b); // true, same pooled object

String c = new String("hello");
System.out.println(a == c); // false, new forces heap allocation

intern() Behavior

intern()

attempts to place the string into the pool:

JDK 7+ : Pool resides in heap; if absent, the pool stores a reference to the existing heap object (no copy).

JDK 6 and earlier : Pool in permgen (method area); a copy is made.

String s1 = new String("a") + new String("b"); // "ab" not in pool
String s2 = s1.intern();                         // Place into pool
String s3 = "ab";
System.out.println(s1 == s2); // JDK 7+ true (pool holds s1 reference)
System.out.println(s2 == s3); // true

Concatenation and Constant Pool Interaction

String a = "ab";
String b = "a" + "b";         // Compile-time constant folding
System.out.println(a == b);     // true

String x = "a";
String y = x + "b";             // Variable at runtime, new object
System.out.println(y == "ab"); // false

final String z = "a";           // Final constant
String w = z + "b";             // Compile-time determinable, folded
System.out.println(w == "ab"); // true
Rule: Only compile-time determinable literals are folded into the constant pool; concatenations involving ordinary variables produce new objects at runtime.

Understanding String immutability, StringBuilder expansion, the constant pool, and invokedynamic constitutes core advanced Java knowledge and frequent interview topics. Mastering these internals enables deeper insight into wrapper auto-boxing, lambda dynamic generation, and the JVM memory model.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaJVMinvokedynamicStringConstant PoolStringBuilderString ConcatenationStringBuffer
Java Tech Workshop
Written by

Java Tech Workshop

Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.