Why is String Class Immutable
In Java, java.lang.String is designed to be strictly immutable due to four foundational architectural pillars: 4. Cached hashCode(): Immutability guarantees that an object's has...
🟢 Junior Level
30-Second Summary
In Java, java.lang.String is designed to be strictly immutable due to four foundational architectural pillars:
- Memory Efficiency (String Pool): Identical string literals share a single object in the Heap via the Flyweight pattern. Without immutability, modifying a string in one place would silently corrupt all other parts of the application referencing that literal.
- Security: Strings carry sensitive operational parameters such as file paths, database URLs, network sockets, and class names loaded by
ClassLoader. Immutability prevents argument hijacking and unauthorized resource access. - Thread Safety: Immutable strings can be shared across multiple threads without locks, synchronization, or volatile memory barriers.
- Cached
hashCode(): Immutability guarantees that an object’s hash never changes, allowing the hash code to be computed once and permanently cached for $O(1)$ lookups inHashMapandHashSet.
Any method that appears to modify a String (concat(), replace(), toLowerCase(), substring()) actually allocates and returns a new String object in the Heap, leaving the original instance untouched.
Practical Demonstration
public class StringImmutabilityDemo {
public static void main(String[] args) {
String original = "Java";
// concat() returns a brand new String; the original string is unchanged!
original.concat(" 21");
System.out.println("original: " + original); // Prints "Java"
// To preserve the result, explicitly reassign the reference:
String modified = original.concat(" 21");
System.out.println("modified: " + modified); // Prints "Java 21"
}
}
Memory Layout (Heap Representation):
[Stack Frame] [Heap Memory]
original ───────► [ String Object: "Java" ]
▲
│ (Unmodified!)
modified ───────► [ String Object: "Java 21" ]
🟡 Middle Level
1. String Constant Pool Architecture & Memory Savings
The String Constant Pool is a specialized hashtable in the Java Heap implementing the Flyweight pattern:
String s1 = "interview";
String s2 = "interview";
System.out.println(s1 == s2); // true — both references point to the exact same pool instance!
If String were mutable, executing s1.setCharAt(0, 'I') would simultaneously alter s2 as well as any JDK classes or external libraries referencing "interview", leading to silent, catastrophic bugs.
2. Defense Against Time-of-Check to Time-of-Use (TOCTOU) Attacks
Strings form the security substrate of the Java runtime:
public void writeFile(String filePath, byte[] data) {
// 1. Time-of-Check: security validation
if (!securityManager.isAccessAllowed(filePath)) {
throw new SecurityException("Access denied to: " + filePath);
}
// 💥 VULNERABILITY HAZARD:
// If String were mutable, a malicious concurrent thread could mutate
// filePath to "/etc/shadow" right between the check and use!
// 2. Time-of-Use: execution of operation
osFileSystem.write(filePath, data);
}
Because String is immutable, the runtime is guaranteed that filePath points to the exact, unchanged path validated by the security check. Similarly, ClassLoader is protected from having class names swapped between resolution and loading (Class.forName(className)).
3. HashCode Caching for Hash-Based Collections
Inside java.lang.String, the hash code is cached in a private field:
public final class String {
private int hash; // Defaults to 0
public int hashCode() {
int h = hash;
if (h == 0 && !isEmpty()) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
hash = h;
}
return h;
}
}
- On the first call to
hashCode(), calculation takes $O(N)$ time using polynomial rolling hashing ($s[0]\cdot 31^{n-1} + \dots$). - All subsequent calls return the cached integer in $O(1)$ time.
- This makes
Stringthe most optimized and reliable key forHashMapandHashSet.
4. Why Passwords Should NEVER Be Stored in a String
Because strings are immutable and pooled, a string holding a plaintext password like "SuperSecret123" remains pinned in Heap memory until an unpredictable GC cycle occurs and the memory page is overwritten. In the event of an unexpected Heap Dump (.hprof) or process memory inspection (/proc/kcore), attackers can extract passwords in plaintext.
Secure Pattern: Use char[] or byte[], which can be immediately overwritten after authentication:
char[] password = console.readPassword("Enter password: ");
try {
authenticate(password);
} finally {
Arrays.fill(password, '0'); // Physically zero out plaintext credentials from RAM
}
🔴 Senior Level
Internal Storage Evolution: Compact Strings (Java 9+, JEP 254)
Prior to Java 9, strings used a two-byte character array:
// Java 8 and earlier:
private final char[] value; // UTF-16: always 2 bytes per char
Java 9 introduced Compact Strings, cutting heap usage in typical enterprise systems by 15–30%:
// Java 9+ (JEP 254):
@Stable
private final byte[] value; // 1 byte per char for Latin-1, 2 bytes for UTF-16
private final byte coder; // 0 = LATIN1, 1 = UTF16
private int hash; // Cached hash code (0 = uncomputed or computed as 0)
private boolean hashIsZero; // Added in Java 13 for strings whose hash code is genuinely 0
Immutability allows the JVM to trust that the backing byte[] and the coder flag remain immutable throughout the entire object lifecycle.
JMM §17.5 and Lazy hash Initialization
The hash field in String is neither final nor volatile. How is thread safety preserved under the Java Memory Model?
- Under JMM §17.7, writes and reads to 32-bit
intvalues are strictly atomic across all JVM-compliant hardware. - The hash computation algorithm is a pure, deterministic function dependent entirely on the immutable
byte[] value. - If two threads invoke
hashCode()simultaneously on an unhashed string:- Both threads independently compute the exact same 32-bit integer $H$.
- Both threads write $H$ to
hash. - This constitutes a benign data race: no thread will ever observe a torn, partial, or corrupted state.
Hardware Vector SIMD Intrinsics
The HotSpot C2 JIT compiler provides specialized intrinsic substitution rules for String operations:
- Methods like
equals(),compareTo(), andindexOf()are compiled into vectorized assembly instructions using AVX-2 / AVX-512 (x86-64) or Neon (ARM64). - The JVM compares 32-byte or 64-byte chunks in a single CPU clock cycle rather than executing char-by-char loops.
- Immutability allows C2 to rely on static array bounds and immutable byte contents, enabling aggressive loop unrolling and auto-vectorization.
Escape Analysis and Allocation Elimination
In chain operations that produce intermediate strings:
public String formatFullName(String first, String last) {
return (first + " " + last).trim();
}
C2 executes Escape Analysis. If intermediate string buffers do not escape the local stack frame, HotSpot applies Scalar Replacement, eliminating Heap allocations entirely and placing intermediate characters directly into CPU registers or stack memory.
Unified String Deduplication (JEP 192, JDK-8267186)
Available via -XX:+UseStringDeduplication across G1 (Java 8u20+) as well as ZGC, Parallel GC, and Serial GC (Java 18+):
- The garbage collector inspects candidate strings reaching the Tenured/Old generation.
- If two distinct
Stringobjects share identical hashes and equivalentbyte[] valuecontents, the GC updates the internal pointer of oneStringto point to the other’s backing array. - The redundant
byte[]array is collected on the next GC cycle. This deduplication is only possible because the backingvaluearray is guaranteed to be immutable.
🎯 Interview Cheat Sheet
The 4 Pillars of String Immutability
| Pillar | Architectural Motivation | Consequence If Strings Were Mutable |
| :— | :— | :— |
| String Pool | Massive heap memory savings (Flyweight pattern) | Modifying one literal silently corrupts state across the entire JVM |
| Security (TOCTOU) | Secures ClassLoaders, file paths, and network sockets | Attackers swap validated file paths before OS execution |
| Thread Safety | Lock-free reading across concurrent threads | Data races requiring explicit synchronized locks on all string access |
| HashMap / HashCode | Permanent caching of pre-calculated hash ($O(1)$ lookup) | Mutating a string key corrupts hash buckets, making entries unreachable |
4 Tricky Interview Questions
1. How does lazy hash initialization in String work without volatile or synchronization, and why is it legal under JMM?
Answer: The field is declared as private int hash;. JMM §17.7 guarantees atomic 32-bit reads and writes. Because the hash calculation is a pure function of the immutable byte[] value, concurrent threads calculating hashCode() simultaneously will always arrive at the identical integer. A reader seeing 0 will simply recompute the same value. This intentional benign data race avoids memory fence overhead associated with volatile writes while preserving thread safety.
2. What happens when executing new String("hello") vs literal "hello"? Does it duplicate the backing array?
Answer: The literal "hello" is resolved from the String Constant Pool. Calling new String("hello") forces the allocation of an entirely new String wrapper object on the Heap outside the pool. In modern Java, the constructor shares or copies the backing byte[] depending on JVM version and compaction, but allocating strings via new String() is an anti-pattern that bloats the heap with redundant wrapper references.
3. How could mutable strings destroy ClassLoader sandboxing?
Answer: Class loaders invoke ClassLoader.loadClass(String name). If String were mutable, an application could pass name = "com.myapp.SafeComponent". After the security manager verifies permissions for that class, a malicious background thread could mutate the character array in-place to "java.lang.System". The class loader would then load or replace a privileged core class, breaking the JVM sandbox.
4. Why was the private field hashIsZero added to String in Java 13?
Answer: Prior to Java 13, hashCode() checked if (hash == 0) to detect whether the hash needed computing. However, some strings mathematically hash to 0 (e.g., empty string "" or collision strings like "DA" and "EB"). For these strings, hashCode() evaluated to 0, causing the method to re-traverse the entire array in $O(N)$ time on every subsequent invocation. The boolean flag hashIsZero distinguishes an uncomputed hash from a computed hash of 0, restoring $O(1)$ performance.
Red Flags (DO NOT Say)
- ❌ “Calling
s.concat()ors.substring()modifies the existing String instance.” (They always allocate and return a newString). - ❌ “The String Pool is located in PermGen.” (PermGen was removed in Java 8; the String Pool has lived in the regular Java Heap since Java 7).
- ❌ “Storing passwords in String is safe as long as the variable is marked private.” (Strings persist in Heap memory and can be recovered via heap dumps; use
char[]and wipe withArrays.fill(pwd, '0')). - ❌ “The
hashfield in String isvolatile.” (It is a standard primitiveint; thread safety is achieved through atomic 32-bit assignment and pure function idempotence).
Related Topics
- What are the Consequences of String Immutability — Downstream effects on GC and performance
- How Does String Pool Work and How is It Related to Immutability — String pool internals
- Can You Change String Value via Reflection — Reflection attacks and JEP 403
- Can You Use Immutable Objects as Keys in HashMap — Bucket stability in hash maps