Java Tutorial
šŸ”

Java Immutable Strings

Java Immutable Strings

In Java, once you create a String, its content cannot be changed. Not a single character. Not the length. Nothing. This is what immutability means for Strings — the object is fixed the moment it is created.

This sounds like a restriction. It is actually one of the most useful design decisions in the Java language. Immutability is what makes the String Pool possible, what makes Strings safe to share between threads without locks, what makes them reliable as HashMap keys, and what prevents a category of security vulnerabilities where someone could change a value after validation. Every senior Java developer understands why String is immutable — and every Java interview asks about it.

What Immutability Means for Strings

When a String is created, its characters are stored in a final char[] (or byte[] in Java 9+) inside the object. That array cannot be replaced, resized, or modified. There is no setCharAt() method on String. There is no way to swap characters. The object is sealed at creation.

String creation and immutability:

  String name = "Priya";

  Memory:
  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
  │  String object                               │
  │  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”   │
  │  │  value (char[]): ['P','r','i','y','a']│   │ ← sealed — cannot change
  │  │  hash (int)    : 0 (computed lazily) │   │
  │  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜   │
  │  No setter for 'value' exists               │
  │  No method can replace or modify this array │
  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

  What "name = name.toUpperCase()" ACTUALLY does:

  Step 1: name.toUpperCase() creates a NEW String: "PRIYA"
          ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
          │  new String object: "PRIYA"          │
          ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

  Step 2: name variable now points to the new String
          name ──────────────────────────────► "PRIYA" (new object)
          Old "Priya" object ─────────────────► still exists until GC

Proof of Immutability — Methods Return New Strings

Every method on String that appears to modify text actually creates and returns a new String. The original object is completely untouched.

// File: ImmutabilityProof.java public class ImmutabilityProof { public static void main(String[] args) { String original = " hello, world! "; System.out.println("=== Every method returns a NEW String ==="); System.out.println("Original : '" + original + "'"); // None of these modify 'original' — each returns a new object String trimmed = original.trim(); String upper = original.toUpperCase(); String replaced = original.replace("world", "Java"); String substring = original.substring(2, 7); System.out.println("After trim() : '" + trimmed + "'"); System.out.println("After upper() : '" + upper + "'"); System.out.println("After replace() : '" + replaced + "'"); System.out.println("After substring : '" + substring + "'"); System.out.println(); System.out.println("Original STILL : '" + original + "'"); // unchanged System.out.println(); // Identity hash codes prove they are different objects System.out.println("=== Different Objects ==="); System.out.println("original id : " + System.identityHashCode(original)); System.out.println("trimmed id : " + System.identityHashCode(trimmed)); // different System.out.println("upper id : " + System.identityHashCode(upper)); // different System.out.println("replaced id : " + System.identityHashCode(replaced)); // different System.out.println(); // The most common beginner mistake — calling method without capturing result System.out.println("=== The Most Common Mistake ==="); String username = " rohan_mehta "; username.trim(); // result thrown away — username unchanged username.toUpperCase(); // result thrown away — username unchanged System.out.println("Without capture: '" + username + "'"); // still padded // Correct — always capture the return value username = username.trim().toUpperCase(); System.out.println("With capture : '" + username + "'"); // correct } }
Output:
=== Every method returns a NEW String ===
Original        : '  hello, world!  '
After trim()    : 'hello, world!'
After upper()   : '  HELLO, WORLD!  '
After replace() : '  hello, Java!  '
After substring : 'hello'

Original STILL  : '  hello, world!  '

=== Different Objects ===
original id : 1173230247
trimmed  id : 856419764
upper    id : 2018699554
replaced id : 1311053135

=== The Most Common Mistake ===
Without capture: '  rohan_mehta  '
With capture   : 'ROHAN_MEHTA'

Four different identity hash codes confirm four different objects in memory. The original is always at address 1173230247 — none of the method calls touched it.

Why Java Made String Immutable — Five Reasons

Reason 1 — The String Pool

If strings were mutable, sharing them in a pool would be catastrophically unsafe. Two variables pointing to the same pooled String — if one could change the characters, the other would silently see different content.

// File: PoolSafetyDemo.java public class PoolSafetyDemo { public static void main(String[] args) { // Both point to the SAME pool entry String city1 = "Delhi"; String city2 = "Delhi"; System.out.println("city1 == city2 : " + (city1 == city2)); // true — same object // If Strings were mutable (hypothetical — this does NOT work in Java): // city1.setCharAt(0, 'B'); // would change "Delhi" to "Belhi" // System.out.println(city2); // would print "Belhi" — silent corruption! // Because String is immutable: city1 = city1.replace("Delhi", "Mumbai"); // creates NEW String — pool entry unchanged System.out.println("city1 : " + city1); // Mumbai System.out.println("city2 : " + city2); // Delhi — still safe in the pool } }
Output:
city1 == city2 : true
city1 : Mumbai
city2 : Delhi

Reason 2 — Thread Safety Without Locks

Immutable objects can be shared across threads freely — no synchronisation needed. Since no thread can change a String's content, concurrent reads are always safe.

// File: ThreadSafetyDemo.java public class ThreadSafetyDemo { // This String is shared by multiple threads private static final String CONFIG_URL = "https://api.meesho.com/v2"; public static void main(String[] args) throws InterruptedException { Runnable task = () -> { // All threads read the same String — completely safe // No thread can modify CONFIG_URL — it is immutable String processed = CONFIG_URL.toUpperCase(); // creates a NEW String per thread System.out.println(Thread.currentThread().getName() + " → URL: " + CONFIG_URL + " | Upper: " + processed); }; Thread[] threads = new Thread[4]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(task, "Worker-" + (i + 1)); } for (Thread t : threads) t.start(); for (Thread t : threads) t.join(); // CONFIG_URL is exactly what it was — no thread could have changed it System.out.println("\nCONFIG_URL intact: " + CONFIG_URL); } }
Output:
Worker-1 → URL: https://api.meesho.com/v2 | Upper: HTTPS://API.MEESHO.COM/V2
Worker-3 → URL: https://api.meesho.com/v2 | Upper: HTTPS://API.MEESHO.COM/V2
Worker-2 → URL: https://api.meesho.com/v2 | Upper: HTTPS://API.MEESHO.COM/V2
Worker-4 → URL: https://api.meesho.com/v2 | Upper: HTTPS://API.MEESHO.COM/V2

CONFIG_URL intact: https://api.meesho.com/v2

Reason 3 — Safe HashMap Keys

HashMap and HashSet rely on a key's hashCode() never changing after insertion. If a String used as a key could be mutated, the hash would change, and HashMap.get() would look in the wrong bucket and return null — even though the entry still exists.

// File: HashMapKeyDemo.java import java.util.HashMap; public class HashMapKeyDemo { public static void main(String[] args) { // String as HashMap key — always safe because String is immutable HashMap<String, String> userRoles = new HashMap<>(); String userId = "USR-001"; userRoles.put(userId, "ADMIN"); // Even if we "change" userId, the original String is untouched // userId = "USR-002" only changes the VARIABLE, not the String in the map userId = "USR-002"; // variable now points to a different String // The map still has USR-001 because String is immutable System.out.println("Get USR-001: " + userRoles.get("USR-001")); // ADMIN System.out.println("Get USR-002: " + userRoles.get("USR-002")); // null // Why this matters — if String were mutable like StringBuilder: // The hashCode of the key would change, making the entry unfindable // Immutability guarantees the hash never changes after insertion System.out.println(); System.out.println("Map is safe because String hashCode never changes."); System.out.println("String 'USR-001' hashCode: " + "USR-001".hashCode()); System.out.println("Same value, same hash always: " + ("USR-001".hashCode() == "USR-001".hashCode())); // always true } }
Output:
Get USR-001: ADMIN
Get USR-002: null

Map is safe because String hashCode never changes.
String 'USR-001' hashCode: 1426657780
Same value, same hash always: true

Reason 4 — Security

Many security-critical operations use Strings — file paths, passwords, network addresses, class names for reflection. If Strings were mutable, a thread could change the value after it was validated but before it was used — a classic time-of-check to time-of-use (TOCTOU) vulnerability.

// File: SecurityDemo.java public class SecurityDemo { // Validates and opens a file public static void openSecureFile(String filePath) { // Step 1 — validate if (!filePath.startsWith("/secure/") || filePath.contains("..")) { throw new SecurityException("Access denied: " + filePath); } // If String were mutable, another thread could change filePath // between the validation above and the use below // e.g., change "/secure/report.pdf" to "/etc/passwd" // Step 2 — use — safe because String is immutable System.out.println("Opening file: " + filePath); // actual file operation would go here } public static void main(String[] args) { // Safe — filePath cannot be changed between validation and use String path = "/secure/reports/q4-sales.pdf"; openSecureFile(path); // This creates a NEW String — the old validated path is untouched path = "/secure/reports/q3-sales.pdf"; openSecureFile(path); System.out.println(); System.out.println("Security benefit: validated value cannot be changed"); System.out.println("after validation by any other code or thread."); } }
Output:
Opening file: /secure/reports/q4-sales.pdf
Opening file: /secure/reports/q3-sales.pdf

Security benefit: validated value cannot be changed
after validation by any other code or thread.

Reason 5 — Cached HashCode

String caches its hashCode() after the first computation. This is safe only because the content never changes — the cached value is always valid. Every subsequent call to hashCode() on the same String object returns the cached value instantly, with zero recomputation.

// File: HashCodeCacheDemo.java public class HashCodeCacheDemo { public static void main(String[] args) { String text = "DevStackFlow"; // First call — computes and caches the hash long start1 = System.nanoTime(); int hash1 = text.hashCode(); long time1 = System.nanoTime() - start1; // Subsequent calls — returns cached value immediately long start2 = System.nanoTime(); int hash2 = text.hashCode(); long time2 = System.nanoTime() - start2; System.out.println("hash1 : " + hash1); System.out.println("hash2 : " + hash2); System.out.println("Same : " + (hash1 == hash2)); // always true System.out.println(); System.out.println("This caching is safe only because the String cannot change."); System.out.println("If characters could be modified, the cached hash would be wrong."); } }
Output:
hash1 : -1285326154
hash2 : -1285326154
Same  : true

This caching is safe only because the String cannot change.
If characters could be modified, the cached hash would be wrong.

Why String Is Declared final

The String class is declared final in Java:

public final class String implements ... { ... }

final on a class prevents subclassing. Without it, a developer could create a MutableString extends String that overrides methods to allow modification — breaking every assumption that libraries, frameworks, and the JVM make about String immutability. Making the class final closes this loophole entirely.

// File: FinalClassDemo.java public class FinalClassDemo { public static void main(String[] args) { // You cannot extend String // class CustomString extends String { } // compile error // You cannot create a mutable subtype that disguises as String // This is intentional — it protects all code that trusts String immutability // What final on String guarantees: // 1. String's behaviour cannot be overridden by subclasses // 2. Every String IS-A String — no tricks, no surprises // 3. Libraries can trust that any String parameter behaves as documented System.out.println("String is final — cannot be subclassed."); System.out.println("This protects immutability at the language level."); // Demonstrate final field inside String (conceptually) // The internal char[] value is private final — cannot be replaced String s = "hello"; System.out.println("String s: " + s); // s.value = new char[]{'w','o','r','l','d'}; // not possible — private final System.out.println("Content is sealed at: " + System.identityHashCode(s)); } }
Output:
String is final — cannot be subclassed.
This protects immutability at the language level.
String s: hello
Content is sealed at: 1173230247

Immutability vs Reassignment — The Key Distinction

This is the most important nuance: the variable can change; the String object cannot.

// File: VariableVsObjectDemo.java public class VariableVsObjectDemo { public static void main(String[] args) { String message = "Hello"; System.out.println("message id: " + System.identityHashCode(message)); // Reassignment — the VARIABLE changes, the original OBJECT does not message = "Hello" + ", World"; // creates a NEW String System.out.println("message id: " + System.identityHashCode(message)); // different id // The String "Hello" still exists in the pool — it was not modified String hello = "Hello"; // retrieves the SAME pooled "Hello" from before System.out.println("'Hello' id: " + System.identityHashCode(hello)); System.out.println(); // What changes: What does NOT change: // ───────────────────── ──────────────────────────────────────── // Which object the The content of any String object // variable points to The characters in the char[] array // The hashCode of any String object // The String Pool entries // Practical impact: String url = "https://api.razorpay.com"; processUrl(url); System.out.println("After method call: " + url); // unchanged — method cannot modify it } static void processUrl(String u) { // u = u.replace("razorpay", "malicious"); // only changes local variable u // caller's 'url' is completely unaffected System.out.println("Processing: " + u.toUpperCase()); // returns new String } }
Output:
message id: 1173230247
message id: 856419764
'Hello' id: 1173230247

Processing: HTTPS://API.RAZORPAY.COM
After method call: https://api.razorpay.com

"Hello" and "Hello" resolve to the same pool entry — same identity hash code. The variable message changed to point to a new object, but the "Hello" pool entry was never touched.

Immutability — Benefits vs Tradeoffs Comparison Table

AspectBenefit of ImmutabilityTradeoff
String PoolSafe sharing — multiple variables point to one objectCannot modify shared entry
Thread safetyNo synchronisation needed for concurrent readsCannot update a shared String value in place
HashMap keysStable hashCode — reliable lookups alwaysCannot use as a key when content must evolve
SecurityValidated value cannot be changed after validationWorkarounds needed for secure mutable text (char[])
HashCode cachingComputed once, reused forever — fast repeated hashingSlight memory overhead to store cached value
Subclassingfinal class prevents subtype attackCannot extend String for custom behaviour
Memory in loopsPredictable — every concat creates a known new objectMore GC pressure if building strings iteratively
Method return safetyCannot leak internal state — every method returns a copyCallers must always capture return values

Basic Example

public class Main { public static void main(String[] args) { String greeting = "Hello"; System.out.println("Before: " + greeting); // toUpperCase() does NOT change greeting — it returns a NEW String String upper = greeting.toUpperCase(); System.out.println("Original after call: " + greeting); // unchanged System.out.println("New string returned : " + upper); System.out.println("Same object? " + (greeting == upper)); // false } }

Output

Before: Hello
Original after call: Hello
New string returned : HELLO
Same object? false

How it works:

  1. ›greeting is created once and holds the text "Hello".
  2. ›Calling greeting.toUpperCase() does not modify greeting — String has no method that can change its own characters.
  3. ›Instead, toUpperCase() builds a brand new String object, "HELLO", and returns it.
  4. ›greeting == upper is false, proving they are two separate objects in memory — the original was never touched.

Best Practices

Declare String constants as static final. Application-wide values — base URLs, error codes, configuration keys, status codes — should be public static final String. They are loaded once at class initialisation, stored in the pool, and shared safely by every part of the application without copying.

Always capture the return value of String methods. str.trim() does nothing useful unless you write str = str.trim() or String clean = str.trim(). The most common String-related bug in beginner code is calling a method and discarding its result. Every method on String returns a new object — if you do not capture it, the operation has no effect.

Use char[] instead of String for sensitive data like passwords. String is immutable — you cannot clear its content after use. A password stored in a String stays in memory until garbage collected, and may even be in the String Pool indefinitely. A char[] can be explicitly zeroed: Arrays.fill(passwordChars, '\0'). This is why JPasswordField.getPassword() returns char[], not String.

Use StringBuilder when building strings iteratively. Immutability means every + in a loop creates a new String. For 1,000 iterations, that is 1,000 discarded objects. StringBuilder uses a single mutable buffer throughout and creates one String at the end. Immutability makes String correct for values; StringBuilder makes it efficient for building.

Common Mistakes

Mistake 1 — Assuming a Method Modified the String

String phone = " 98765-43210 "; phone.trim(); // returns trimmed String — but it is thrown away phone.replace("-", ""); // returns cleaned String — also thrown away System.out.println(phone); // " 98765-43210 " — still unchanged // Fix — capture every return value phone = phone.trim().replace("-", ""); System.out.println(phone); // "9876543210"

Mistake 2 — Storing Passwords as String

// BAD — String stays in memory, in pool, cannot be cleared String password = getPasswordFromUser(); // might be pooled verifyPassword(password); password = null; // does not wipe the String from memory // GOOD — char[] can be explicitly cleared char[] passwordChars = getPasswordCharsFromUser(); verifyPassword(new String(passwordChars)); // convert only when needed java.util.Arrays.fill(passwordChars, '\0'); // zero out — sensitive data gone

Mistake 3 — Using += in a Loop

// Immutability makes this O(n²) — creates a new String every iteration String output = ""; for (int i = 0; i < 10000; i++) { output += "item" + i + "\n"; // 10,000 String objects created and discarded } // Fix — use StringBuilder, call toString() once StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append("item").append(i).append("\n"); } String output = sb.toString(); // one String created at the end

Mistake 4 — Confusing Variable Reassignment With Object Mutation

void process(String value) { value = value.trim(); // only changes local parameter — caller unchanged System.out.println("Inside: " + value); // trimmed } String data = " hello "; process(data); System.out.println("Outside: " + data); // still " hello " — method did not mutate it

A method that reassigns its String parameter only changes the local variable. The caller's reference still points to the original. Immutability reinforces this — even if the local variable points to a new String, the original object is untouched. To communicate a modified value back, the method must return the new String.

Interview Questions

Q1. Why is String immutable in Java?

Java made String immutable for five reasons. First, it enables the String Pool — if strings were mutable, sharing them in a pool would be unsafe because one variable could change content seen by all others. Second, it provides thread safety — immutable objects can be shared across threads without any synchronisation. Third, it makes Strings reliable as HashMap and HashSet keys — the hashCode() never changes after creation. Fourth, it provides a security guarantee — validated strings cannot be modified between validation and use. Fifth, it allows hashCode() to be cached after first computation — safe only because the content never changes.

Q2. What does it mean for String to be immutable?

Once a String object is created, its character content is fixed and cannot be changed. The internal char[] (or byte[] in Java 9+) is private final — it cannot be replaced or modified. No method exists to change a character at a position. Every method that appears to modify a String — toUpperCase(), replace(), trim(), substring() — creates and returns a brand new String object with the result. The original String is completely untouched.

Q3. What is the difference between String immutability and the final keyword on a variable?

String immutability means the String object's content cannot change. final on a variable means the variable cannot be reassigned to point to a different object. They are independent. final String name = "Priya" means name cannot point to a different String, AND the String's content cannot change. String name = "Priya" (without final) means the variable can be reassigned — name = "Rohan" — but the original "Priya" String object in the pool remains unchanged. Two orthogonal concepts.

Q4. Why is String declared as a final class?

To prevent subclassing. Without final, a developer could write class MutableString extends String and override methods to allow content modification. Any code that accepted a String would then have to worry about whether it actually received a mutable subtype. Making the class final closes this completely — every String reference is guaranteed to be an instance of java.lang.String exactly, with all its immutability guarantees intact.

Q5. Why should you use char[] instead of String for passwords?

String is immutable — you cannot clear its content after use. A password stored in a String remains in heap memory until garbage collected (unpredictable timing) and may persist in the String Pool indefinitely. During this window, a memory dump or reflection could expose it. A char[] can be explicitly zeroed — Arrays.fill(chars, '\0') — immediately after use, reducing the exposure window to near-zero. This is why security-conscious APIs like JPasswordField.getPassword() return char[].

Q6. Does immutability mean you cannot change a String variable in Java?

No. The variable can be reassigned to point to a different String — name = name.toUpperCase() is valid. What cannot change is the content of the String object itself. After name = name.toUpperCase(), the variable name points to a new String with uppercase characters. The original String object that name previously pointed to is unchanged in memory — it still exists until garbage collected. Immutability is a property of the object, not the variable.

FAQs

Can reflection be used to modify a String's internal char array?

Technically yes — with setAccessible(true) on the private value field and direct array modification. But this is explicitly undefined behaviour, strongly discouraged, breaks the JVM's internal assumptions, and is illegal under the Java module system's strong encapsulation in Java 9+. In production code, String immutability is an absolute guarantee for all practical purposes.

If String is immutable, why does String concatenation work?

Concatenation does not modify either String — it creates a new String that contains the characters of both. "Hello" + " World" produces a new String "Hello World". The original "Hello" and " World" strings are unchanged. The + operator is syntactic sugar that the compiler converts to StringBuilder.append().toString() behind the scenes.

Does immutability affect String comparison?

Yes, positively. Because a String's content never changes, hashCode() can be computed once and cached. This makes HashMap and HashSet lookups fast — the hash does not need to be recomputed on every access. It also means two String variables with the same content always return the same hashCode() — which satisfies the equals()–hashCode() contract reliably.

Is String immutability a Java-specific concept?

No. Many modern languages make their primary string type immutable — including Python, Kotlin, Go, and Rust. The benefits of predictability, safety, and pool-sharing are language-agnostic. Java was among the earliest mainstream languages to enforce this with a final class and a private final internal array, making the guarantee not just a convention but a structural constraint.

What happens to the old String when you reassign a String variable?

The old String object becomes eligible for garbage collection if nothing else holds a reference to it. If the old String was a literal and is in the String Pool, it typically stays in the pool for the lifetime of the class that uses it — pool entries are generally not collected during normal application execution. Heap String objects (created with new String() or runtime operations) are collected normally when unreferenced.

Summary

String immutability is not a limitation — it is a carefully engineered guarantee that enables String Pool sharing, thread-safe concurrent access, stable HashMap keys, security isolation between validation and use, and efficient hashCode() caching. The final class declaration adds a structural lock that prevents subclasses from bypassing these guarantees.

The practical implications every developer should know: every String method that appears to modify text returns a new String — always capture the return value. Variables can be reassigned; the objects they pointed to cannot be changed. Use char[] for passwords. Use StringBuilder for iterative building. Use static final String for application-wide constants.

For interviews, be ready to name all five reasons for immutability, explain the difference between the variable being reassigned and the object being mutated, explain why final on the class is necessary, and describe the char[] pattern for secure password handling.

What to Read Next

Practice Programs

Put this topic into practice — each program has full code, output and a step-by-step explanation you can run online.

Browse all Java programs →