RizTech Academy logo
RizTech Academy
CollectionsLesson 9 of 1130 min

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.copyOf on something already produced by List.copyOf returns 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

  1. Write the leaky DeliveryBatch, keep a reference to the input list, and add to it after construction. Confirm the batch changed.
  2. Call add on the value returned by its getter.
  3. Fix both leaks with List.copyOf and repeat both attacks.
  4. Build an unmodifiable view over an ArrayList, then add to the backing list and print the view.
  5. Put a null in List.of(...). Then call contains(null) on one.
  6. Make a List.copyOf of a list of StringBuilder, mutate one, and print the copy.
  7. Write DeliveryBatch as a record with a compact constructor that copies. Confirm it is now safe.
  8. Call List.copyOf twice on the same immutable list and compare the references with ==.
  9. Compare List.of() returned twice with ==.
  10. 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