Java Immutability: Building Safe Immutable Classes
java immutability: Practical guide to Java immutability: implementing immutable classes, defensive copying, records, and knowing when immutability is the right choice.
Java immutability is a design property that guarantees an object's state never changes after construction. When you share an immutable object across threads or pass it to other code, you know its fields will stay the same for the lifetime of the object. This article covers how to implement immutability correctly in Java, where the common implementation mistakes are, and the tradeoffs you should weigh before making every class immutable.
What Makes a Java Class Immutable
An immutable class in Java must satisfy four conditions:
- The class is declared
finalso it cannot be subclassed. - All fields are
private finaland assigned exactly once in the constructor. - No method modifies any field after construction.
- No mutable object reference escapes through a getter or constructor parameter.
The last condition is the one most often missed. A final field only guarantees that the reference does not change. It does not protect the object the reference points to. If that object is mutable, callers can change its internal state through the reference.
The Minimal Immutable Class
Here is a straightforward immutable class that satisfies all four conditions:
public final class User { private final String name; private final int age; public User(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } }
String and the primitive int are themselves immutable, so no defensive copying is needed here. The constructor assigns both fields once, and the getters return values directly. There are no setters and no way to modify name or age after construction.
This pattern is sufficient whenever every field is either a primitive or an immutable type such as String, Integer, BigDecimal, or another immutable class.
Where Immutability Commonly Breaks
The pattern above falls apart as soon as a field holds a mutable type. The two most common offenders are arrays and Collection implementations.
public final class BrokenUser { private final String name; private final List<String> roles; public BrokenUser(String name, List<String> roles) { this.name = name; this.roles = roles; } public List<String> getRoles() { return roles; } }
This class looks immutable but is not. The caller who constructs BrokenUser can mutate the roles list after construction, because the constructor stores the same reference it received. A caller who retrieves the list through getRoles() can also mutate it. The final modifier on the field only prevents the reference from being reassigned; it does nothing to prevent roles.add("admin").
The same problem applies to arrays:
public final class BrokenConfig { private final String[] values; public BrokenConfig(String[] values) { this.values = values; } public String[] getValues() { return values; } }
Both values[0] = "changed" and config.getValues()[1] = "changed" modify the object's state from outside.
Defensive Copying in Practice
The fix is to copy mutable data at both boundaries: when it enters through the constructor and when it leaves through a getter.
import java.util.ArrayList; import java.util.List; public final class SafeUser { private final String name; private final List<String> roles; public SafeUser(String name, List<String> roles) { this.name = name; this.roles = new ArrayList<>(roles); } public List<String> getRoles() { return new ArrayList<>(roles); } }
The constructor copies the incoming list, so later mutations by the caller have no effect on the stored data. The getter copies the stored list before returning it, so the caller cannot mutate the internal state through the returned reference.
Copying on every getter call has a cost. For a collection that is read frequently, that cost can be significant. A common alternative is to store an unmodifiable view and return it directly:
import java.util.ArrayList; import java.util.Collections; import java.util.List; public final class SafeUser { private final String name; private final List<String> roles; public SafeUser(String name, List<String> roles) { this.name = name; this.roles = Collections.unmodifiableList(new ArrayList<>(roles)); } public List<String> getRoles() { return roles; } }
Collections.unmodifiableList wraps the internal list so that any attempt to call add, remove, or other mutating methods throws UnsupportedOperationException. The internal ArrayList is still mutable, but no reference to it escapes, so no caller can reach it. This avoids the per-call copy while preserving immutability.
For arrays, the equivalent is Arrays.copyOf in the constructor and getter, or List.of(array) when the array is converted to a list.
Records as a Shorter Path
Since Java 16, records provide a compact way to declare immutable data carriers. A record's fields are implicitly private final, the class is implicitly final, and the compiler generates the constructor, getters, equals, hashCode, and toString.
public record UserRecord(String name, int age, List<String> roles) { public UserRecord { roles = List.copyOf(roles); } }
The compact constructor runs before the canonical constructor assigns the fields. Reassigning roles here replaces the incoming reference with an immutable copy, so the record never stores the caller's mutable list.
Note that List.copyOf returns an unmodifiable list, so the getter is safe to return directly. Records do not automatically copy mutable fields, however. If a record field is a mutable object, you must still handle it in the compact constructor. A record containing a Date or a plain ArrayList field is not immutable unless you copy it explicitly.
Records also work well with pattern matching and switch expressions, which makes them a natural fit for value-based domain objects.
Performance and Memory Considerations
Immutability changes the allocation profile of a program. Every time you need a different value, you must construct a new object rather than mutate an existing one. For objects that change frequently, this can increase allocation pressure and, in turn, garbage collection work.
The offsetting benefit is that immutable objects can be safely shared without copying. A single immutable instance can be passed to any number of consumers, cached in a map, or reused across requests. Mutable objects often require defensive copies precisely because callers cannot be trusted not to modify them.
Two patterns reduce the allocation cost of immutability:
- Caching: because an immutable object never changes, you can cache instances and return the same reference for equal inputs. This is how
Integer.valueOfandString.internreduce allocations. - Flyweight: a small set of fixed instances can be shared across an entire application. An immutable
Statusenum or a set of predefinedColorinstances avoids repeated construction.
The JVM also gives final fields a special guarantee in the memory model. When an object is safely published, the values of its final fields are visible to all threads without synchronization. This is what makes immutable objects safe to share across threads without locks or volatile fields.
Concurrency Benefits Without Synchronization
Because an immutable object's state cannot change, concurrent threads cannot observe partial updates or inconsistent field values. There is no need for synchronized blocks, volatile fields, or AtomicReference wrappers around individual fields.
This eliminates an entire class of concurrency bugs: lost updates, stale reads, and race conditions between field writes. When multiple threads read the same immutable object, they all see the same state, and that state is the state established at construction.
The practical consequence is that immutable objects can be used as shared configuration, cached values, or event payloads without any locking discipline. This is why immutable value objects are the default recommendation for data that crosses thread boundaries.
When Immutability Is the Wrong Choice
Immutability is not free, and it is not always the right design. The main cost is that updating state requires constructing a new object. For a domain object with many fields, that means copying most of the fields on every update.
Consider a mutable counter that is incremented millions of times per second. An immutable version would allocate a new object for every increment. A single long field wrapped in an AtomicLong is a better fit when the goal is high-throughput state change rather than safe sharing.
Similarly, large objects with many fields that change frequently are awkward to model immutably. A builder pattern can help construct new instances, but the boilerplate may outweigh the benefit when the object is never shared across threads.
The decision rule is straightforward: use immutable classes when the object is shared, cached, or passed across thread boundaries, or when its value semantics matter. Use mutable classes when the object is short-lived, confined to a single thread, and updated frequently. The two are not mutually exclusive; many applications use an immutable value object for the public API and a mutable builder internally during construction.