Dataslope logoDataslope

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 HashSet element 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:

Code Block
Java 8 (Update 492)

These factories return purpose-built, compact implementations — not just a wrapper. Mutator methods throw UnsupportedOperationException.

A few rules to know

  • They reject null keys, values, and elements. (If you need nulls, you almost always have a modeling bug to fix anyway.)
  • Set.of(...) and Map.of(...) reject duplicates at construction, throwing IllegalArgumentException.
  • 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):

Code Block
Java 8 (Update 492)

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:

Code Block
Java 8 (Update 492)

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 copy

List.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.
Code Block
Java 8 (Update 492)

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

Code Block
Java 8 (Update 492)

Practice

Challenge
Java 8 (Update 492)
Make `Roster` leak-proof

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

QuestionSelect one

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

QuestionSelect one

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

QuestionSelect one

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

On this page