Immutable Objects & Defensive Copying
The five conditions for a genuinely immutable class, the leaked-collection bug that defeats final, why records are only shallowly immutable, and what final fields guarantee across threads.
On this page
"Make it immutable" is advice everyone repeats and fewer can implement correctly, because Java gives you three ways to think you have done it while a caller can still change your object's state.
Key Takeaways
- Five conditions:
finalclass,private finalfields, no mutators, copy in the constructor, copy out of the getters. finalon a field freezes the reference, not the object it points at. Afinal Listis still mutable.Collections.unmodifiableListis a view;List.copyOfis a snapshot. Only the second is a defensive copy.- A
recordis shallowly immutable. Mutable components leak. - Final fields give safe publication — a correctly constructed immutable object crosses threads with no synchronisation.
The five conditions
public final class Reservation { // 1. final: no subclass can add mutability
private final String guest; // 2. private final fields
private final LocalDate arrival;
private final List<String> roomNumbers;
public Reservation(String guest, LocalDate arrival, List<String> roomNumbers) {
this.guest = Objects.requireNonNull(guest);
this.arrival = Objects.requireNonNull(arrival);
// 4. Defensive copy IN. Without this the caller keeps a live handle
// to our internal list and can mutate it after construction.
this.roomNumbers = List.copyOf(roomNumbers);
}
// 3. No setters, and no method that changes state.
public List<String> roomNumbers() {
// 5. Defensive copy OUT. List.copyOf() already returned an immutable
// list, so returning the field directly is safe here — but say so,
// because it stops being safe the moment someone changes line 13.
return roomNumbers;
}
/** Mutation returns a new instance — the "wither" pattern. */
public Reservation withArrival(LocalDate newArrival) {
return new Reservation(guest, newArrival, roomNumbers);
}
}Condition 1 exists for a reason people miss: without final on the class, a subclass can add a
mutable field and override an accessor, and callers holding your declared type cannot tell.
Conditions 4 and 5 are the ones that actually get skipped. Here is the bug in isolation:
List<String> rooms = new ArrayList<>(List.of("101"));
Reservation r = new Reservation("ada", LocalDate.now(), rooms);
rooms.add("102"); // caller still holds the reference...
r.roomNumbers(); // ...and the "immutable" object just changedfinal did nothing here, because final freezes the reference and says nothing about the object it
points to. This is the single most common misconception about immutability in Java, and a reliable
interview probe.
Unmodifiable is not immutable
List<String> source = new ArrayList<>(List.of("a", "b"));
List<String> view = Collections.unmodifiableList(source);
List<String> snapshot = List.copyOf(source);
source.add("c");
view.size(); // 3 — the view sees through to the original
snapshot.size(); // 2 — independent copy
view.add("d"); // UnsupportedOperationException
snapshot.add("d"); // UnsupportedOperationExceptionBoth reject mutation through them. Only List.copyOf is independent of the source. unmodifiableList
is the right tool for returning a read-only window onto state you own and intend to keep changing —
which is a different job from defensive copying.
Three more distinctions worth having ready:
Arrays.asList(a, b)is fixed-size but mutable —set()works,add()throws, and it writes through to the backing array.List.of(...)is genuinely immutable and rejects nulls, which surprises code migrating fromArrays.asList.List.copyOfreturns the argument unchanged if it is already one of the immutable implementations, so the copy is free in the common case.
Records are shallowly immutable
public record Order(String id, List<String> lines) { }
List<String> lines = new ArrayList<>();
Order order = new Order("A-1", lines);
lines.add("injected"); // mutates the "immutable" record
order.lines(); // ["injected"]A record generates final fields and no setters, which handles conditions 1–3. It does nothing about
4 and 5. The fix is a compact constructor plus a copying accessor:
public record Order(String id, List<String> lines) {
// Compact constructor: runs before the fields are assigned, so
// reassigning the parameter changes what gets stored.
public Order {
Objects.requireNonNull(id);
lines = List.copyOf(lines); // copy IN
}
// Not needed here — List.copyOf already produced an immutable list — but
// required whenever a component is an array or another mutable type.
@Override
public List<String> lines() { return lines; }
}An array component always needs both directions, because there is no immutable array:
public record Payload(byte[] bytes) {
public Payload { bytes = bytes.clone(); }
@Override public byte[] bytes() { return bytes.clone(); }
}Note that a record's generated equals() compares arrays by reference, so a record with an array
component also needs a hand-written equals/hashCode using Arrays.equals. Records are a
convenience for value types with immutable components; the moment a component is mutable you are back
to writing the class properly.
Thread safety for free
This is the payoff, and the reason immutability is worth the copying.
The Java Memory Model makes a specific promise about final fields: if an object is correctly
constructed — meaning the this reference does not escape the constructor — then any thread that
sees a reference to that object is guaranteed to see the fully initialised values of its final
fields, without any synchronisation.
public final class Config {
private final Map<String, String> values;
public Config(Map<String, String> values) {
this.values = Map.copyOf(values);
// 'this' must not escape here — no registering listeners,
// no starting threads, no adding to a static registry.
}
public String get(String key) { return values.get(key); }
}
// Elsewhere: publishing through a plain non-volatile field is safe.
static Config current;
current = new Config(loaded); // another thread reading `current` either
// sees null or a fully built ConfigWithout final fields, a reader on another core could legally observe a non-null reference whose
fields are still at their defaults — the classic unsafe-publication bug that made double-checked
locking broken before Java 5. Immutability plus final fields removes the whole class of problem.
Where it costs you
Immutability is not free, and an interviewer who asks about it usually wants to hear you say so.
Every "mutation" allocates. For a hot loop building a value step by step, that is real garbage — the
standard answer is a mutable builder that produces one immutable result at the end, which is exactly
what StringBuilder is to String.
Deep copies of large collections on every construction can dominate a request. Persistent data structures (structural sharing, as in Vavr or Clojure's collections) avoid it, at the cost of a dependency and unfamiliar types.
And ORM entities cannot be immutable in the usual sense — Hibernate needs a no-arg constructor and field access. The pragmatic pattern is a mutable entity at the persistence boundary and an immutable record as the domain and API type, with an explicit mapping between them.
Frequently Asked Questions
Is a record immutable?
What is the difference between Collections.unmodifiableList and List.copyOf?
Why do immutable objects not need synchronisation?
Related tutorials
- Generics, Type Erasure and WildcardsWhat the compiler removes and what it inserts, why you cannot create an array of a generic type, PECS explained by what it enables, bridge methods, and heap pollution from unchecked varargs.
- Comparable, Comparator and Sorting ContractsNatural order versus external order, the total-ordering contract and the exception thrown when you break it, comparator chaining, the integer-overflow bug, and TimSort stability.
- static, final and Class Initialisation OrderThe exact order the JVM initialises a class, why a static final String can survive deleting the class that declared it, effectively final, and how two classes can deadlock while loading.
- Exception Handling, finally and try-with-resourcesChecked versus unchecked and when each is right, how finally silently discards an exception or a return value, and suppressed exceptions in try-with-resources.