Immutability in Java: Designing Safe Classes and the String Pool
Interview Question: "Walk me through how you design a strictly immutable class in Java. Why is String immutable, how do you handle defensive copying properly, and how do Java 14+ Records fit in?"
The Quick Answer
"An immutable class is one whose internal state cannot be modified after instantiation. In Java, you achieve this by marking the class final, all fields private final, providing no mutating methods, and performing defensive copies on all mutable references. String is immutable for four primary reasons: memory optimization (the String Pool), security, multithreaded safety, and hashcode caching."
The 5 Rules for Handcrafting an Immutable Class
- Mark the class
final: Prevents subclasses from overriding methods or exposing mutable state. - Make all fields
private: Enforces encapsulation by hiding state from direct external access. - Make all fields
final: Guarantees single assignment and triggers the Java Memory Model's "freeze action" for safe publication across threads without synchronization. - Provide no setter methods: Never expose methods that mutate internal state.
- Enforce Defensive Copying (The Interview Trap):
- In the constructor: Always clone incoming mutable objects (like
ArrayList,Date, or custom objects) before storing them. (Senior Tip: Copy before parameter validation to prevent Time-of-Check to Time-of-Use [TOCTOU] attacks). - In getters: Never return a direct reference to a mutable internal field. Return an unmodifiable view (e.g.,
Collections.unmodifiableList()) orList.copyOf()(Java 10+).
- In the constructor: Always clone incoming mutable objects (like
Modern Java: What About Records (Java 14+)?
Interviewers will ask: "Why manually write immutable classes when modern Java has record?"
A record (introduced in Java 14/16) automatically provides:
- An implicitly
finalclass. private finalfields.- Canonical constructor, getters,
equals(),hashCode(), andtoString().
The Record Trap: Records do not automatically defensively copy mutable fields!
If you declare record Portfolio(String name, List<String> stocks) {}, client code can pass an ArrayList and mutate it externally. You must define a compact constructor to enforce defensive copying:
public record Portfolio(String name, List<String> stocks) {
public Portfolio {
stocks = List.copyOf(stocks); // Defensively copies and enforces immutability
}
}
Why is String Immutable in Java?
- The String Pool (Heap Optimization): Reuses identical string literals. If strings were mutable, changing a string through one reference would silently corrupt all other references pointing to that literal.
- Security: Strings hold sensitive arguments (database credentials, socket URLs, file paths). If mutable, a background thread could alter a file path between authentication check and file opening (race condition).
- Thread Safety: Immutable objects can be shared freely across concurrent threads without locks or synchronization.
- HashCode Caching:
Stringcaches its hash code during the firsthashCode()call. Because its characters can never change, the hash is computed once and reused for instant lookups inHashMapandHashSet.
Crucial Nuance: The Reflection Backdoor
Can an immutable string ever be changed? Yes, via reflection.
Using Field.setAccessible(true) on java.lang.reflect.Field, code with sufficient JVM security permissions can access the internal byte[] value of a String and alter it directly in heap memory. Immutability in Java is a language and type safety contract, not hardware-level memory protection.
Clean Code Example
Here is how immutable objects are created across languages:
- Java
- C++
- Python
import java.util.Collections;
import java.util.List;
import java.util.ArrayList;
public final class ImmutablePortfolio {
private final String investor;
private final List<String> stocks;
public ImmutablePortfolio(String investor, List<String> stocks) {
this.investor = investor;
// Defensive copy in constructor to break external reference
this.stocks = new ArrayList<>(stocks);
}
public String getInvestor() {
return investor;
}
// Defensive view in getter prevents external additions
public List<String> getStocks() {
return Collections.unmodifiableList(stocks);
}
}
#include <iostream>
#include <string>
#include <vector>
// In C++, immutability is enforced via 'const' references and member qualifiers
class ImmutablePortfolio {
private:
const std::string investor;
const std::vector<std::string> stocks;
public:
// Takes ownership by value or creates a copy via initializer list
ImmutablePortfolio(std::string inv, std::vector<std::string> stk)
: investor(inv), stocks(stk) {}
std::string getInvestor() const { return investor; }
// Returns a const reference to prevent external mutation
const std::vector<std::string>& getStocks() const { return stocks; }
};
from dataclasses import dataclass
from typing import Tuple
# In Python, @dataclass(frozen=True) creates an immutable class.
# Use immutable Tuple instead of mutable List to ensure deep immutability:
@dataclass(frozen=True)
class ImmutablePortfolio:
investor: str
stocks: Tuple[str, ...] # Immutable collection
# Usage:
portfolio = ImmutablePortfolio("Alice", ("AAPL", "GOOGL"))
# portfolio.investor = "Bob" # Raises FrozenInstanceError!