How to Create an Immutable Class in Java
To create an immutable class in Java, you must ensure that its state cannot be modified after construction. The standard blueprint consists of Joshua Bloch's 5 Rules (Effective...
🟢 Junior Level
30-Second Summary
To create an immutable class in Java, you must ensure that its state cannot be modified after construction. The standard blueprint consists of Joshua Bloch’s 5 Rules (Effective Java):
- Provide no mutator methods (no setters or state-altering APIs).
- Prevent subclassing (declare the class
final, or use private constructors with static factories). - Make all fields
final(enforces single assignment and triggers JMM freeze action for safe publication). - Make all fields
private(prevents direct external access to internal state). - Ensure exclusive access to mutable components (perform defensive copies in constructors and getters).
Classic Implementation: UserProfile
Consider a class containing primitive, immutable (String), and mutable (List<String>) components:
package com.example.model;
import java.util.List;
import java.util.Objects;
// 1. Declare class as final to prevent extension
public final class UserProfile {
// 2. Declare all fields private and final
private final long id;
private final String username;
private final List<String> roles;
// 3. Initialize all fields in the constructor with defensive copies
public UserProfile(long id, String username, List<String> roles) {
this.id = id;
this.username = Objects.requireNonNull(username, "Username must not be null");
// Defensive copy on entry: prevents external caller from mutating our internal list
this.roles = (roles == null) ? List.of() : List.copyOf(roles);
}
// 4. Expose only getter accessors (no setters)
public long getId() {
return id;
}
public String getUsername() {
return username;
}
// 5. Return an immutable representation (List.copyOf returns an unmodifiable list)
public List<String> getRoles() {
return roles;
}
}
🟡 Middle Level
Handling Mutable State & Defensive Copying
The most frequent error in creating immutable classes is leaking direct references to mutable objects.
1. Collections
In modern Java (10+), List.copyOf(), Set.copyOf(), and Map.copyOf() are standard:
- If the supplied collection is already an unmodifiable platform collection,
copyOfreturns it directly with zero allocation overhead. - Prior to Java 10, the correct defensive pattern required both a clone and a wrapper:
// Pre-Java 10 defensive copy pattern: this.roles = Collections.unmodifiableList(new ArrayList<>(roles));
[!WARNING] Simply calling
Collections.unmodifiableList(roles)without creating a new backing list is unsafe. The wrapper is merely an unmodifiable view; any modification to the original caller’s list will instantly mutate the internal state of your “immutable” object!
2. Arrays
Arrays in Java are always mutable, even if the reference is declared final. Whenever accepting or returning an array, always clone it:
public final class CryptographicKey {
private final byte[] keyBytes;
public CryptographicKey(byte[] keyBytes) {
Objects.requireNonNull(keyBytes, "Key bytes must not be null");
// Defensive copy on constructor input
this.keyBytes = keyBytes.clone();
}
public byte[] getKeyBytes() {
// Defensive copy on getter output
return keyBytes.clone();
}
}
3. Date & Time Types
Avoid legacy java.util.Date and Calendar (which are mutable and require new Date(date.getTime())). Use Java 8+ java.time types (Instant, LocalDate, LocalDateTime, ZonedDateTime), which are deeply immutable by design.
Java 14/16+ Records
Java records provide a compact syntax for immutable data carriers:
public record UserAccount(long id, String email, List<String> permissions) {
// Compact constructor for validation and defensive copying
public UserAccount {
Objects.requireNonNull(email, "Email must not be null");
// Records assign parameters to fields as-is; defensive copy is mandatory for mutable collections:
permissions = (permissions == null) ? List.of() : List.copyOf(permissions);
}
}
Key Nuance: A record enforces shallow immutability by default:
- In bytecode, all component fields are generated as
private final. - The class is implicitly
finaland inherits fromjava.lang.Record. - Accessor methods match component names (e.g.,
account.email()). - However, if a record component holds a mutable object (like
ArrayListorbyte[]), caller mutations will still compromise your data integrity unless defensive copies are made in the compact constructor.
The Builder Pattern for Complex Immutable Classes
When an immutable class has numerous optional fields, constructors become unmaintainable (the telescoping constructor anti-pattern). Use a static inner Builder:
public final class ServerConfig {
private final String host;
private final int port;
private final int timeoutMs;
private final boolean tlsEnabled;
private ServerConfig(Builder builder) {
this.host = builder.host;
this.port = builder.port;
this.timeoutMs = builder.timeoutMs;
this.tlsEnabled = builder.tlsEnabled;
}
public String getHost() { return host; }
public int getPort() { return port; }
public int getTimeoutMs() { return timeoutMs; }
public boolean isTlsEnabled() { return tlsEnabled; }
public static class Builder {
private String host = "localhost";
private int port = 8080;
private int timeoutMs = 5000;
private boolean tlsEnabled = true;
public Builder host(String host) {
this.host = Objects.requireNonNull(host, "Host cannot be null");
return this;
}
public Builder port(int port) {
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("Port out of range: " + port);
}
this.port = port;
return this;
}
public Builder timeoutMs(int timeoutMs) {
this.timeoutMs = timeoutMs;
return this;
}
public Builder tlsEnabled(boolean tlsEnabled) {
this.tlsEnabled = tlsEnabled;
return this;
}
public ServerConfig build() {
return new ServerConfig(this);
}
}
}
🔴 Senior Level
JMM §17.5 Final Field Semantics & Memory Barriers
Under the Java Memory Model (JSR-133), final fields receive special hardware-level guarantees:
- When an object is constructed, HotSpot executes a Freeze Action at the end of the constructor.
- The JIT compiler inserts a
StoreStorememory barrier between the stores tofinalfields and the assignment of the object reference to a globally visible reference variable. - Any thread that reads a non-null reference to a properly published object is guaranteed to observe the initialized values of all
finalfields without explicit synchronization (volatile,synchronized, or locks).
[Thread 1: Constructor Execution]
1. Store field1 = val1
2. Store field2 = val2
[Freeze Action: StoreStore Barrier]
3. Publish reference: sharedRef = this;
│
▼ (Safe Publication Across Cores)
[Thread 2: Reader Thread]
1. Load sharedRef
[LoadLoad Barrier]
2. Read sharedRef.field1 -> Guaranteed val1 (never default 0 or null!)
Critical Hazard: Constructor Escape (Premature Publication)
If the this reference escapes before the constructor completes, JMM guarantees are completely voided:
public final class LeakingPublisher {
private final int value;
public LeakingPublisher(EventBus bus) {
this.value = 42;
// 💥 DISASTER: 'this' escapes before constructor completion!
bus.registerListener(this);
}
public int getValue() {
return value;
}
}
Hardware-Level Vulnerability:
Without constructor completion, the compiler and CPU memory subsystem may reorder stores. The pointer this can be published to the EventBus and picked up by a worker thread in L1 cache before the store this.value = 42 is flushed. The worker thread calls listener.getValue() and reads 0 (the default primitive value), completely breaking immutability.
Hardware Cache Coherence (MESI) & High Throughput
In multi-socket, multi-core CPU architectures, immutable objects achieve superior performance due to CPU cache protocols:
- MESI Protocol: When multiple CPU cores read immutable objects, the corresponding cache lines stay in the Shared (S) state.
- No Invalidation Traffic: Because immutable fields are never written to after construction, cores never send Request For Ownership (RFO) bus messages or invalidate peer caches into the Invalid (I) state.
- Cache Bouncing & False Sharing Eliminated: Memory reads hit local L1/L2 caches without memory bus contention or cache line bouncing.
Alternative to final class: Private Constructors and Static Factories
Declaring a class final is not the only way to prevent uncontrolled subclassing. A powerful alternative is private constructors with static factory methods:
public class ComplexNumber {
private final double real;
private final double imaginary;
// Private constructor: prevents subclassing outside this class
private ComplexNumber(double real, double imaginary) {
this.real = real;
this.imaginary = imaginary;
}
public static ComplexNumber of(double real, double imaginary) {
return new ComplexNumber(real, imaginary);
}
public static ComplexNumber ofReal(double real) {
return new ComplexNumber(real, 0.0);
}
}
Architectural Advantages over final class:
- Polymorphic Flexibility: Allows package-private or nested subclasses (e.g., specialized implementations like
RegularImmutableMapvs.EmptyImmutableMapin Google Guava). - Instance Caching / Pooling: Unlike constructors (which must allocate new heap memory every time), factory methods can return cached singletons (e.g.,
BigDecimal.ZERO,Boolean.TRUE). - Self-Documenting API: Factories have descriptive names (
Complex.fromPolar(...)vs.Complex.fromCartesian(...)) eliminating constructor overloading ambiguity.
Breaking Immutability: Reflection and Unsafe
Historically, private final fields could be overwritten via reflection:
Field field = UserProfile.class.getDeclaredField("username");
field.setAccessible(true);
field.set(profile, "hacked");
In modern JVMs, this is mitigated:
- JEP 403 (Java 16+ Strong Encapsulation): Direct reflection on JDK internals throws
InaccessibleObjectExceptionunless explicit JVM options (--add-opens) are supplied. - C2 JIT Constant Folding & Trust Final Fields: HotSpot C2 treats
finalfields as compile-time constants. During loop optimization and aggressive method inlining, C2 loads the field value into a CPU register or embeds it directly as an immediate constant in native assembly. Even if the field in heap RAM is mutated viaUnsafe.putAddress(), JIT-compiled native code will continue reading the cached compile-time constant!
🎯 Interview Cheat Sheet
5 Rules Implementation Checklist
- Prevent Extension: Mark class
finalOR make constructorsprivatewith static factories. - Make Fields
private final: Ensures strict encapsulation and triggers JMM Freeze Action. - No Mutators: Zero setters, in-place update methods, or mutating operations.
- Defensive Copies on Input: Wrap incoming collections via
List.copyOf()or clone arrays. - Defensive Copies on Output: Return unmodifiable collections (
List.copyOf(),Collections.unmodifiableList()) or array clones. - Zero Constructor Escape: Never publish
thisto threads, listeners, or external methods in constructors.
Comparison of Immutable Construction Strategies
| Strategy | Pros | Cons / Trade-offs |
| :— | :— | :— |
| Standard final class | Complete control, explicit custom getters, familiar pattern | High boilerplate; requires manual equals, hashCode, toString |
| record (Java 16+) | Ultra-concise, auto-generated equals/hashCode/toString, bytecode-level final | Shallow immutability only; mutable collections still need defensive copy in compact constructor |
| Builder Pattern | Clean syntax for 5+ optional fields, readable fluent API, strict validation | Extra builder allocation and auxiliary class boilerplate |
4 Tricky Interview Questions
1. Why does Joshua Bloch recommend private constructors + static factories over final class?
Answer: Making constructors private restricts subclassing just as effectively (subclasses cannot invoke super()), but grants immense architectural flexibility:
- Static factories can return pre-cached instances (Flyweight pattern) or canonical singletons without heap allocation.
- They allow returning different internal subtypes hidden behind a public interface (e.g.,
Collections.emptyList()or Guava’s size-specialized immutable collections). - Factory methods have expressive names, avoiding constructor overload confusion.
2. If a class is final with private final fields, but lazily computes and caches int hash in a non-final field, is it still immutable and thread-safe?
Answer: Yes, this is observational immutability (used by java.lang.String). The class appears strictly immutable to external callers:
- Computing
hashCode()is a pure, idempotent function: multiple threads will always produce the identical 32-bit integer. - A benign race condition may recalculate the hash concurrently, but all threads write the exact same value.
- Writing a 32-bit
intis atomic under JMM §17.7, eliminating torn reads without needingvolatileor locks.
3. What happens at the C2 JIT compiler level if you overwrite a final field using Unsafe or Reflection?
Answer: Behavior becomes desynchronized and unpredictable:
- HotSpot C2 treats
finalfields of trusted/stable classes as immutable constants (TrustFinalFields). - Under constant folding, inlining, and loop invariant code motion, C2 replaces field reads with hardcoded constants or keeps them in CPU registers.
- Even if RAM in heap memory is modified via
Unsafe, native JIT-compiled loops will never re-read the field from memory, resulting in different parts of the application seeing conflicting values simultaneously.
4. How does this escape in a constructor violate JMM §17.5 final field semantics?
Answer: JMM §17.5 safe publication guarantees hold only after normal constructor completion:
- If
thisis passed to a listener, static collection, or thread before completion, the JIT/CPUStoreStoreFreeze Action has not yet executed. - The reference
thismay be published and observed by another core before the writes to the object’sfinalfields are flushed to cache. - The observer thread will read default values (
0for primitives,nullfor references), and subsequently observe the field “change” to its initialized value moments later, violating the fundamental invariant of immutability.
Red Flags (DO NOT Say)
- ❌ “Making all fields
finalis sufficient to make any class immutable.” (Afinalfield referencing a mutableArrayListallows callers to modify state viagetList().add()). - ❌ “Java records are deeply immutable out-of-the-box.” (Records provide shallow immutability; collections inside a record are mutable unless copied via
List.copyOf()in a compact constructor). - ❌ “Arrays marked
finalcannot be modified.” (finalonly freezes the array reference; individual array elements can be reassigned freely). - ❌ “Use
clone()for defensive copying of general objects.” (clone()is architecturally flawed and vulnerable to malicious subclass overriding; use copy constructors or static copy methods).
Related Topics
- What is an Immutable Object — Fundamental definitions
- What Advantages Do Immutable Objects Provide — Thread safety and cache benefits
- Why is String Class Immutable — JDK case study
- Is It Enough to Make All Fields final for Immutability — Deep vs shallow immutability
- What is Defensive Copy — Defensive copying mechanics
- What is Record and How Does It Help Create Immutable Classes — Record semantics