Immutable collections and defensive copies
A collection you hand out is a collection somebody can change. This lesson is about the several ways to stop that, which are not equivalent, and the bug that catches everybody the first time.
The bug
public final class DeliveryBatch {
private final List<Delivery> deliveries;
public DeliveryBatch(List<Delivery> deliveries) {
this.deliveries = deliveries;
}
public List<Delivery> deliveries() {
return deliveries;
}
}
final field, no setter. It looks immutable. It is not — twice over:
List<Delivery> input = new ArrayList<>();
input.add(a);
DeliveryBatch batch = new DeliveryBatch(input);
input.add(b); // the caller still holds the list
batch.deliveries().add(c); // and we handed it out again
final means the reference cannot be reassigned. It says nothing about the
object. The batch now contains three deliveries and never agreed to any of
them.
The fix: copy in, copy out
public final class DeliveryBatch {
private final List<Delivery> deliveries;
public DeliveryBatch(List<Delivery> deliveries) {
// Copy on the way in, so the caller's later changes are not ours.
this.deliveries = List.copyOf(deliveries);
}
public List<Delivery> deliveries() {
// Already immutable, so this is safe to hand out directly.
return deliveries;
}
}
List.copyOf does both jobs: it takes a snapshot and the result is
unmodifiable, so the getter needs no second copy. That is why it is the right
default.
Three kinds of "unmodifiable", which are not the same
This is where people get caught.
List.of(...) — genuinely immutable
List<String> names = List.of("Priya", "Arjun");
names.add("Kavita"); // UnsupportedOperationException
Nothing can change it. Also: it rejects null elements and contains(null)
throws NullPointerException, which surprises people migrating from
Arrays.asList.
Collections.unmodifiableList(list) — an unmodifiable view
List<String> backing = new ArrayList<>(List.of("Priya"));
List<String> view = Collections.unmodifiableList(backing);
view.add("Arjun"); // UnsupportedOperationException — good
backing.add("Arjun"); // allowed — and `view` now has two elements
The view is a window, not a copy. Whoever holds the backing list can still change what the view shows. It is a decorator, from the patterns module, and it only protects against changes through the view.
That is fine when you own the backing list and are handing out a read-only window. It is not protection if the backing list escapes.
List.copyOf(list) — a snapshot that is also immutable
List<String> backing = new ArrayList<>(List.of("Priya"));
List<String> copy = List.copyOf(backing);
backing.add("Arjun");
// copy still has one element
What you want almost always.
| Blocks changes through it | Isolated from the original | |
|---|---|---|
List.of(...) |
yes | n/a — built from scratch |
Collections.unmodifiableList |
yes | no |
List.copyOf |
yes | yes |
Shallow, not deep
A copy copies the references:
List<StringBuilder> originals = List.of(new StringBuilder("Priya"));
List<StringBuilder> copy = List.copyOf(originals);
originals.get(0).append(" Kulkarni");
System.out.println(copy.get(0)); // "Priya Kulkarni"
The list cannot be changed. The objects in it can.
Which is why immutable elements matter more than immutable collections. The
capstone stores List<Delivery>, and Delivery is a record of a LocalDate, a
String and an int — all immutable. A List.copyOf of those is genuinely
safe all the way down. A list of mutable objects is not, whatever you wrap it
in.
Records do not copy for you
public record DeliveryBatch(List<Delivery> deliveries) { }
A record gives you a final field and an accessor — and neither copies. The compact constructor is where you fix it:
public record DeliveryBatch(List<Delivery> deliveries) {
public DeliveryBatch {
deliveries = List.copyOf(deliveries); // reassign before assignment
}
}
Assigning to the parameter inside a compact constructor is how you transform a value before it becomes the field. It reads oddly the first time; it is the intended idiom.
When not to copy
Copying is not free — it is O(n) and an allocation.
Skip it when:
- The elements and the collection are already immutable.
List.copyOfon something already produced byList.copyOfreturns the same instance, so this is cheap anyway. - The collection is enormous and the ownership is clear — a method taking a million rows to sum them does not need a copy.
- It is genuinely private and never escapes.
Copy at the boundary — constructors, and methods returning internals to callers you do not control. Not on every internal hop. That is the same rule as validation from the best-practices module, applied to mutability.
Returning an empty collection
public List<Delivery> deliveriesFor(String customer) {
List<Delivery> found = index.get(customer);
return found == null ? List.of() : List.copyOf(found);
}
List.of() rather than null, always — and it costs nothing, because
List.of() returns a shared singleton instance every time.
Check your work
What final on a collection field protects: the reference, not the
contents.
The two leaks in the naive class: the constructor keeps the caller's list, and the getter hands it back.
Why List.copyOf is the default: it snapshots and returns something
immutable, so the getter needs no second copy.
How Collections.unmodifiableList differs: it is a view, so the backing
list can still change what it shows.
What List.of rejects: null elements, and contains(null) throws.
Why copies are shallow: the references are copied, not the objects — so immutable elements matter more than an immutable collection.
Why records need a compact constructor: they do not copy, and assigning to the parameter is the idiom.
Where to copy: at the boundary, not on every internal hop.
Why List.of() beats null: it works in a loop and a stream, and it is a
shared instance.
Practice
- Write the leaky
DeliveryBatch, keep a reference to the input list, and add to it after construction. Confirm the batch changed. - Call
addon the value returned by its getter. - Fix both leaks with
List.copyOfand repeat both attacks. - Build an unmodifiable view over an
ArrayList, then add to the backing list and print the view. - Put a
nullinList.of(...). Then callcontains(null)on one. - Make a
List.copyOfof a list ofStringBuilder, mutate one, and print the copy. - Write
DeliveryBatchas a record with a compact constructor that copies. Confirm it is now safe. - Call
List.copyOftwice on the same immutable list and compare the references with==. - Compare
List.of()returned twice with==. - Find a getter in your own code returning a mutable collection field. Decide whether it needs fixing, and say why or why not.
Next: what each of these actually costs.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship