How do you make a class genuinely immutable?
final fields are not enough. final freezes the reference, never the object it points at — so a class with a final List is fully mutable through both the constructor argument and the getter. Genuine immutability needs a defensive copy on the way IN and on the way OUT, and Collections.unmodifiableList gives you neither because it is a view of a list somebody else can still change.
The Answer
finalon a field freezes the reference, not the object. Afinal Listcan be cleared, appended to and reordered all day.- So immutability needs four things: fields
private final, no mutators, a defensive copy on the way in, and a defensive copy on the way out. - Both copies matter, and they close different holes. Without the first, the caller who passed the list keeps a live handle. Without the second, every reader gets one.
Collections.unmodifiableListis a view, not a copy. It blocks writes through itself and changes underneath you when the backing list changes.List.copyOfis a real copy and is what you want Java 9+since Java 9. It also rejects nulls, and returns the same instance if the input is already immutable.- The payoff is not tidiness. An immutable object is thread-safe with no synchronisation at all, and
finalfields carry a safe-publication guarantee that non-final fields do not.
Understand It
final does almost nothing you want
Two classes, identical except for two lines. Both have private final fields and no setters, which is what most codebases mean by "immutable":
List<String> caller = new ArrayList<>(List.of("keyboard"));
LeakyOrder leaky = new LeakyOrder("ORD-1", caller);
System.out.println("as constructed : " + leaky);
caller.add("added by the caller");
System.out.println("after caller.add : " + leaky);
leaky.items().add("added by a reader");
System.out.println("after reader.add : " + leaky);as constructed : ORD-1 [keyboard]
after caller.add : ORD-1 [keyboard, added by the caller]
after reader.add : ORD-1 [keyboard, added by the caller, added by a reader]Nobody reassigned items. final did its job perfectly and prevented nothing, because the field was never what was changing.
Two separate holes, and they are worth naming separately because the fixes are separate:
- The way in. The constructor stored the caller's list. The caller still holds it.
- The way out. The getter returned the live list. Every reader now holds it.
Fixing one and not the other is the commonest half-done version of this. Now the same sequence against the version that copies on the way in:
List<String> caller = new ArrayList<>(List.of("keyboard"));
SafeOrder safe = new SafeOrder("ORD-1", caller);
caller.add("added by the caller");
System.out.println("after caller.add : " + safe);
try {
safe.items().add("added by a reader");
} catch (UnsupportedOperationException e) {
System.out.println("reader.add : UnsupportedOperationException");
}
System.out.println("final state : " + safe);after caller.add : ORD-1 [keyboard]
reader.add : UnsupportedOperationException
final state : ORD-1 [keyboard]Note that only one copy was written. List.copyOf returns a genuinely immutable list, so the getter has nothing left to protect — copying out again would be wasted work. Copy on the way in is the load-bearing half; copy on the way out is what you need when the component's own type cannot be frozen.
unmodifiableList is a view, and that is the trap
This is the mistake that looks like the fix.
List<String> backing = new ArrayList<>(List.of("ravi"));
List<String> view = Collections.unmodifiableList(backing);
List<String> copy = List.copyOf(backing);
backing.add("anita");
System.out.println("unmodifiable VIEW : " + view);
System.out.println("copyOf COPY : " + copy);
try {
view.add("x");
} catch (UnsupportedOperationException e) {
System.out.println("writing to view : UnsupportedOperationException");
}unmodifiable VIEW : [ravi, anita]
copyOf COPY : [ravi]
writing to view : UnsupportedOperationExceptionCollections.unmodifiableList wraps. It stops writes through the wrapper and does nothing about the list underneath, so anyone holding the original can still change what your "unmodifiable" list contains. Returning one from a getter is safe. Storing one in a field is not, unless you are certain nobody else holds the backing list — and if you were certain of that you would not have needed the wrapper.
List.copyOf allocates a new immutable list and severs the connection. It is also cheap in the case that matters: given an input that is already immutable, it returns the same instance rather than copying again.
List<String> already = List.of("a", "b");
System.out.println("copyOf of an immutable list returns it : " + (List.copyOf(already) == already));copyOf of an immutable list returns it : trueSo List.copyOf in a constructor is not a per-construction cost when callers pass immutable lists. It is a cost only when there was a real leak to close.
Why this is worth doing at all
Tidiness is not the reason. Three concrete payoffs:
Thread safety for free. An immutable object cannot be observed in an inconsistent state, so it needs no synchronisation, no volatile, no locks, and it can be shared between any number of threads. This is the strongest thread-safety guarantee in Java and the only one that costs nothing at runtime.
Safe publication. Java 5 (1.5)+The memory model gives final fields a special guarantee: if an object is correctly constructed — meaning this does not escape the constructor — any thread that sees a reference to it sees its final fields fully initialised, with no synchronisation. Non-final fields have no such promise, and a reader can see a default value in an object that looks fully built. That difference is the original reason double-checked locking was broken before Java 5, and it is why final still matters even though it does not make you immutable on its own. See happens-before.
Safe as a map key. The moment a key's hash can change, it is one mutation away from being unreachable in its own map — counted by size(), invisible to get(). hashmap-internals has that failure in detail.
The types that fight you
Some components cannot be frozen at all, and knowing which is most of the practical skill.
| Component type | How to hold it |
|---|---|
String, Integer, any boxed primitive, enum | nothing to do — already immutable |
List, Set, Map | List.copyOf / Set.copyOf / Map.copyOf in the constructor |
java.time.* — Instant, LocalDate, Duration | nothing to do — immutable by design |
java.util.Date, Calendar | mutable. Copy on both sides, or migrate to java.time |
byte[] and every array | clone() on both sides. There is no immutable array |
| your own mutable class | copy both sides, or make that class immutable too |
BigDecimal, BigInteger | immutable |
java.util.Date deserves its own line because it still appears in old APIs and it is mutable in a way people forget:
Date when = new Date(0);
Date sameObject = when;
sameObject.setTime(86_400_000L);
System.out.println("the 'other' Date changed it : " + when.getTime());the 'other' Date changed it : 86400000Any class exposing a Date field without copying it is handing out a mutable clock.
Reference
The complete recipe
public final class Order { // 1. final class: no subclass
private final String id; // 2. private final fields
private final List<String> items;
private final Date placedAt; // a mutable legacy type
public Order(String id, List<String> items, Date placedAt) {
this.id = id;
this.items = List.copyOf(items); // 3. copy IN
this.placedAt = new Date(placedAt.getTime());
}
public String id() { return id; }
public List<String> items() {
return items; // already immutable
}
public Date placedAt() {
return new Date(placedAt.getTime()); // 4. copy OUT — Date cannot be frozen
}
// 5. no setters, and no method that mutates anything
// 6. "with" methods instead of setters: return a new instance
public Order withItem(String item) {
List<String> updated = new ArrayList<>(items);
updated.add(item);
return new Order(id, updated, placedAt);
}
}
Point 1 matters more than it looks. Without final on the class, a subclass can add mutable state and override methods, and callers holding an Order reference have no way to know. final — or sealed, see sealed-classes — is what makes the guarantee hold for the type rather than for one class.
View versus copy, at a glance
| Blocks writes through it | Sees later changes to the source | Allocates | |
|---|---|---|---|
Collections.unmodifiableList(x) | yes | yes | no |
List.copyOf(x) | yes | no | yes, unless x is already immutable |
new ArrayList<>(x) | no | no | yes |
x.stream().toList() Java 16+16+ | yes | no | yes |
Arrays.asList(a) | partly — set works, add does not | yes, it writes through to the array | no |
Arrays.asList is the one that surprises people: it is a fixed-size view of the array, so set mutates the array and add throws.
Doing it with a record
// Records give you points 1, 2 and 5 for free, and nothing else.
public record Order(String id, List<String> items, Date placedAt) {
public Order {
items = List.copyOf(items); // copy IN
placedAt = new Date(placedAt.getTime());
}
@Override
public Date placedAt() {
return new Date(placedAt.getTime()); // copy OUT
}
}
The compact constructor is the right home for the inbound copies. See record-vs-class — the fact that a record looks immutable while being freely mutable through a component is the single most common mistake with them.
When not to
// 1. Large objects mutated in a tight loop. Every "with" allocates, and
// while escape analysis often removes it, "often" is not a design.
// StringBuilder exists for exactly this reason.
// 2. JPA entities. Hibernate needs a no-arg constructor and mutable fields
// to proxy and dirty-check. Map to an immutable DTO at the boundary.
// 3. Frameworks that require setters — some serialisers, older Spring
// binding. Prefer constructor binding where it is offered.
Scenarios
A getter returns the internal list and something removes from it. Support reports orders losing line items, and the mutation is in logging code that calls order.items().removeIf(...) to filter what it prints. It never occurred to the author that this was the order's own list — the method is a getter and getters read. Returning List.copyOf or an unmodifiable wrapper turns a silent data-loss bug into an UnsupportedOperationException at the exact line. Loud beats correct-by-convention, because conventions do not survive new joiners.
Copying is called a performance problem in review. Worth taking seriously and worth measuring, because the answer is usually that there is no copy. List.copyOf returns the input unchanged when it is already immutable, so if callers construct with List.of(...) the constructor allocates nothing. Where a copy does happen it is a shallow array copy of references. If that genuinely shows up in a profile, the fix is to make the caller pass immutable data, not to remove the defence.
An "immutable" class with a mutable subclass. The class has final fields and no setters but is not final, and a subclass added a mutable cache field and overrode a getter. Every caller holding the supertype believes it has an immutable object and has no way to check. This is why the recipe starts with final class, and why sealed is the right answer when you do need a closed set of subtypes.
Immutability proposed as the fix for a concurrency bug. Often correct and occasionally not, so be precise about what it buys. An immutable object is safe to share and safe to publish. It does not make a sequence of operations atomic: swapping a volatile reference to a new immutable object is safe, but read-modify-write on that reference still needs a CAS or a lock. Immutability removes the visibility and torn-state problems and leaves the atomicity ones. See volatile-vs-synchronized.
Interviewer's Next Move
1. "Is a class with all final fields immutable?"
No. final freezes the reference, not the object behind it, so a final List field is fully mutable. You also need no mutators, a defensive copy in the constructor, a defensive copy in any getter returning a mutable type, and the class itself final so a subclass cannot add mutable state.
2. "Why is a defensive copy needed in the constructor as well as the getter?" Because they close different holes. Without the constructor copy the caller who supplied the collection still holds a live reference to it. Without the getter copy every reader receives one. Fixing only the getter is the common half-done version and leaves the original caller able to mutate your state from outside.
3. "Is Collections.unmodifiableList enough?"
Not for a field. It is a read-only view: it blocks writes through the wrapper and reflects every change made to the backing list, so anyone holding the original can still change your contents. It is fine as a return value when you control the backing list. For a field, List.copyOf actually severs the connection.
4. "What does immutability buy you at runtime?"
Thread safety with no synchronisation — an immutable object cannot be seen in an inconsistent state. Plus safe publication: final fields of a correctly constructed object are guaranteed visible to any thread that sees the object, which non-final fields are not. And it makes the object safe as a map key, because its hash cannot change out from under the table.
5. "How do you make an immutable class with a byte[] field?"
clone() on the way in and on the way out, every time — there is no immutable array in Java and no way to make one. And be aware that array equals and hashCode are identity-based, so if the field is part of equality you have to use Arrays.equals and Arrays.hashCode explicitly. Both of those together are usually a signal to wrap the bytes in a type of your own.
Code traps
public final class Config {
private final Map<String, String> settings;
public Config(Map<String, String> settings) {
this.settings = Collections.unmodifiableMap(settings);
}
public Map<String, String> settings() { return settings; }
}
Map<String, String> source = new HashMap<>(Map.of("mode", "fast"));
Config config = new Config(source);
source.put("mode", "slow");
System.out.println(config.settings());
Answer
{mode=slow}. The field holds an unmodifiable view of source, and source is still mutable and still referenced by the caller. The wrapper only blocks writes made through itself. Map.copyOf(settings) is the fix — it copies, so the connection is severed. This is the most common way a class ends up "immutable" and is not.
List<String> items = Arrays.asList("a", "b", "c");
items.set(0, "z");
items.add("d");
Answer
set succeeds and add throws UnsupportedOperationException. Arrays.asList returns a fixed-size list backed by the array: writes to existing positions go straight through to the array, and anything changing the size is refused. It is neither a copy nor immutable, which makes it a poor choice for anything you intend to keep. List.of(...) is immutable; new ArrayList<>(Arrays.asList(...)) is a real mutable copy.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "All fields final means immutable." | final freezes the reference. A final List is fully mutable. |
| "Just don't write setters." | The constructor and the getters leak references without any setter existing. |
"Collections.unmodifiableList makes it immutable." | It is a view of a list somebody else can still change. |
| "The class doesn't need to be final." | Then a subclass can add mutable state and callers cannot tell. |
| "Defensive copying is too slow." | List.copyOf returns the input unchanged when it is already immutable. Measure before removing a defence. |
| "Immutable means thread-safe, so I don't need volatile anywhere." | The object is safe; a mutable reference to it still needs volatile or a lock to publish a swap. |
| "Records are immutable." | Shallowly. A mutable component makes the record mutable. |
Check Yourself
Q1. A class has a private final List<String> items and no setters. Name the two ways a caller can still change its contents.
Answer
By keeping the list they passed to the constructor, and by mutating the list returned from the getter. final prevents neither, because neither one reassigns the field. The fixes are a copy in the constructor and either a copy or an immutable list from the getter.
Q2. What is the practical difference between Collections.unmodifiableList(x) and List.copyOf(x)?
Answer
The first is a view: it blocks writes through itself but reflects any later change to x. The second is a copy: it is disconnected from x entirely, and it returns x itself when x is already immutable, so it is free in the case that matters. Only the copy is safe to store in a field.
Q3. Beyond "it's cleaner", what does an immutable class actually give you at runtime?
Answer
Thread safety with no synchronisation, because the object cannot be observed in an inconsistent state. Safe publication, because final fields of a correctly constructed object are guaranteed visible to any thread that sees the reference. And a hash that cannot change, which is what makes it safe as a map key.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | final did not do what you think | 5 min |
| Challenge | Close both directions | 20 min |
| Production | The audit record that was edited after the fact | 45 min |
| Interview | Full round replay — immutability | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 5 (1.5)
The memory model gives final fields a safe-publication guarantee: a correctly constructed object's final fields are visible to any thread that sees the object, with no synchronisation.
Before Java 5 (1.5): There was no such guarantee. Another thread could observe a partially constructed object with its final fields still at their defaults, which is the original reason double-checked locking was broken.
- Java 9
List.of / Set.of / Map.of and List.copyOf produce genuinely immutable collections, and copyOf returns the same instance when the input is already one of them.
Before Java 9: Collections.unmodifiableList was the only option, and it wraps rather than copies — so it is a read-only VIEW of a list the caller can still modify.
- Java 16
Records make the shallow half automatic — private final fields and no setters — which makes the deep half the only remaining work, and the only remaining mistake.
Before Java 16: You wrote the fields, constructor, getters, equals and hashCode by hand, so at least the defensive copies were in front of you while you did it.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up5 min
final did not do what you think
One concept, guided. Near-impossible to fail.
- Challenge20 min
Close both directions
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The audit record that was edited after the fact
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — immutability
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
Questions that lead here
Is Java pass-by-value or pass-by-reference?
Java is always pass-by-value, with no exceptions. For an object the value copied is the reference, which is why a method can mutate your object but can never swap or replace it.
Asked constantlyjunior0–6 yrs8 min readLanguage basicsWhen do you choose an abstract class over an interface?
Not on the old answer. Since Java 8 an interface carries behaviour, and since 9 it has private helper methods, so 'interfaces have no implementation' is out of date. Two differences remain: an abstract class can hold instance state and run a constructor, and you get only one. Choose it when subtypes must share state or a construction invariant; otherwise the interface.
Asked constantlyintermediate1–10 yrs11 min readOopWhen should you use a record instead of a class?
When the type IS its data. A record is a semantic claim, not a boilerplate saving — you are declaring that two instances with the same components are the same thing, and that the components are the whole state. If that is not true, a record is the wrong shape however much typing it would save.
Asked constantlyintermediate1–10 yrs11 min readOop
Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.