Immutable Collections
Why "no one can change it" is one of the most powerful properties a collection can have — and how Java gives it to you
A mutable collection is the default in Java, and most days that is exactly what you want. But when a collection crosses a trust boundary — returned from a library, stored in a cache, shared between threads, used as a map key, exposed to "the rest of the codebase" — mutability becomes a liability.
This chapter is about three intertwined ideas:
- Truly immutable collections, created with
List.of,Set.of,Map.of. - Unmodifiable views over a mutable collection
(
Collections.unmodifiableList). - Defensive copies — what you do at the boundaries of your code to keep your invariants safe.
Why immutability matters
Mutable shared state is the source of more bugs than almost any other software-engineering vice:
- Someone you didn't expect mutates a list you handed out, and your internal invariant breaks much later, in unrelated code.
- A
HashSetelement is mutated after being added, and now its hash points to the wrong bucket — silently lost forever. - A second thread sees your collection halfway through a mutation and reads garbage.
Immutable collections make every one of those bugs impossible by construction.
Immutable factories: List.of, Set.of, Map.of
Java 9 introduced concise factories that return truly immutable collections:
These factories return purpose-built, compact implementations — not
just a wrapper. Mutator methods throw UnsupportedOperationException.
A few rules to know
- They reject
nullkeys, values, and elements. (If you need nulls, you almost always have a modeling bug to fix anyway.) Set.of(...)andMap.of(...)reject duplicates at construction, throwingIllegalArgumentException.- They are unmodifiable, including through any iterator you obtain.
What about more than 10 entries?
Map.of has overloads up to 10 entries. For arbitrary numbers, use
Map.ofEntries(...) or Map.copyOf(existingMap):
Unmodifiable views: Collections.unmodifiableX
Before Java 9, the idiomatic way to publish a "read-only" collection
was Collections.unmodifiableList(list). It is still common, and
still useful — but it is a view, not a copy:
The view forbids callers from mutating, but the owner of
backing can still mutate. That is sometimes exactly right (a
controller publishes a live read-only view), and sometimes very wrong
(you wanted nobody to mutate it ever).
When you want a snapshot that genuinely cannot change, copy first:
return List.copyOf(backing); // returns an immutable copyList.copyOf, Set.copyOf, and Map.copyOf are no-ops if the
argument is already immutable, and return a fresh immutable copy
otherwise.
Defensive copies at the boundary
Defensive copying is a discipline:
- In: when a constructor or setter accepts a collection, immediately copy it. Otherwise the caller can keep their reference and mutate "your" data later.
- Out: when a getter returns a collection, return either an immutable copy or an unmodifiable view. Otherwise the caller can mutate your internal state.
That's a 12-line class with no bugs.
Mermaid: the three states a published collection can be in
The further right you go, the safer your API.
Multi-file example: a safe Catalog
Practice
The class Roster is currently leaky in two ways: the constructor stores the caller's list reference, and the getter exposes the internal list directly. Fix both: take a defensive copy in the constructor, and return an immutable view from names().
Expected output:
[Ada, Grace]
external mutation didn't leak
caller mutation was blocked
Test your understanding
What is the key difference between List.of(...) and Collections.unmodifiableList(backing)?
They are the same
List.of is truly immutable (no backing to mutate); Collections.unmodifiableList is an unmodifiable view over a list whose owner can still mutate it
Collections.unmodifiableList is faster
List.of allows nulls
Why is it a good habit to take a defensive copy of a collection passed into a constructor?
To save memory
Because otherwise the caller still holds a reference to the same list and can mutate "your" state after construction
Because Java requires it
Because lists are not serializable otherwise
What happens if you call Set.of("a", "b", "a")?
It silently dedups to {a, b}
It returns {a, b, a}
It throws IllegalArgumentException at construction
It throws NullPointerException