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.
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 poolBytecode 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_3The 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 StringStringBuffer: Thread Safety
Synchronized Methods
StringBuffermirrors 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 allocationintern() 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); // trueConcatenation 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"); // trueRule: 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
