W3docs

Java Unmodifiable Collections

Create immutable collections in Java with List.of, Set.of, Map.of and the Collections.unmodifiable* wrappers.

Java Unmodifiable Collections

A modifiable collection lets anyone with a reference change its contents. An unmodifiable collection does not — calling add, remove, put, clear, or set throws UnsupportedOperationException. Java has two complementary ways to build one: the .of(...) factories introduced in Java 9 (List.of, Set.of, Map.of, Map.ofEntries) and the older Collections.unmodifiable* wrappers. They look similar at the call site but they behave differently in two ways that matter, and the right tool depends on what you actually need.

This chapter closes the collections framework part by giving you a clean, modern recipe for "give me a constant" and "give me a snapshot."

Why immutability

Three concrete wins worth the pattern:

  1. Safe sharing. Hand an unmodifiable list to a constructor, a worker thread, or an event consumer and you don't have to worry about them mutating your state. The compiler-grade type doesn't say "read-only," but the runtime does.
  2. Hashable safely. Putting a mutable List in a HashSet is a bug — if the list's content changes, its hashCode changes, and the set loses the element. Unmodifiable collections sidestep this entirely.
  3. Better API design. Returning an unmodifiable view from a getter says "this is mine — read it, don't change it." Without that, every caller has to decide whether to defensively copy.

The two strategies

List.of, Set.of, Map.of, Map.ofEntriestrue immutable collections

Added in Java 9. They build a new collection with its own internal storage. Nothing else has a reference to it:

List<String> roles  = List.of("admin", "editor", "viewer");
Set<Integer> primes = Set.of(2, 3, 5, 7, 11);
Map<String, Integer> ages = Map.of("alice", 30, "bob", 25);

Map<String, Integer> many = Map.ofEntries(
    Map.entry("alice", 30),
    Map.entry("bob",   25),
    Map.entry("carol", 28)
);

Use these for constants and literals — small fixed collections you write into the code. The class JIT-compiles them to highly compact, low-overhead representations (often a single inline array). The cost is zero per allocation past what the literal itself takes.

Three constraints to remember:

  1. No null elements, no null keys, no null values. List.of("a", null) throws NullPointerException at construction. If you need to represent "absent," use Optional or omit the key from the map.
  2. No duplicates for Set.of and Map.of. Set.of("a", "a") throws IllegalArgumentException. They're meant for literal data you control.
  3. Map.of has overloads only up to 10 entries. For 11 or more, use Map.ofEntries(Map.entry(...), Map.entry(...), ...).

Collections.unmodifiableList(coll) etc. — views of an existing collection

Wrap a collection in a read-only view. The original is still mutable, and changes through the original are visible through the view:

List<String> mutable = new ArrayList<>(List.of("a", "b", "c"));
List<String> view    = Collections.unmodifiableList(mutable);

view.add("d");                      // throws UnsupportedOperationException
mutable.add("d");                   // legal — and the view sees the change
System.out.println(view);            // [a, b, c, d]

Use these when you want to expose an internal collection without copying it and without giving callers permission to mutate. The classic pattern is a getter:

public List<String> getNames() {
  return Collections.unmodifiableList(this.names);
}

The caller can't change this.names through the returned view. You can. If you want to also forbid yourself, copy:

return List.copyOf(this.names);

…which is the third strategy.

List.copyOf, Set.copyOf, Map.copyOf — snapshot, then freeze

A shorthand for "copy the current contents into a new immutable collection":

List<String> snapshot = List.copyOf(mutable);

After this call, snapshot is fully independent of mutable. Subsequent changes to mutable are invisible through snapshot. There's also a clever optimisation: if the source is already an unmodifiable collection produced by List.of / List.copyOf, the call returns the source itself — zero allocation.

copyOf rejects null elements, same as of. If your source might contain null, use Collections.unmodifiableList(new ArrayList<>(source)) instead.

The three patterns in summary

PatternIndependent of source?Allows null?Use when
List.of("a", "b")n/a (no source)NoLiteral constants
List.copyOf(source)Yes — own storageNoSnapshot at a moment in time
Collections.unmodifiableList(source)No — viewYesExpose internal state read-only

When the call site reads "is this literally hard-coded data?" reach for of. When it reads "I want a frozen snapshot of what's there right now," reach for copyOf. When it reads "I want the current contents to be observable but not modifiable through this reference," reach for unmodifiableList.

Shallow, not deep

All three strategies are shallow — they freeze the collection's structure, not the elements inside it.

List<int[]> arrays = List.of(new int[]{1, 2}, new int[]{3, 4});
arrays.add(new int[]{5});                 // UnsupportedOperationException
arrays.get(0)[0] = 99;                    // OK — and now the list contains {99, 2}

If you want deep immutability, you need to pick element types that are themselves immutable. Records with primitive or String fields are. Records with mutable fields are not. This is the same caveat that applies to final references in general: the binding is fixed, the target may not be.

Set.of and Map.of have unspecified iteration order

Two intentional design choices catch people:

  1. Set.of and Map.of deliberately randomise iteration order across runs of the same JVM. If you write code that depends on a specific order from these, you'll see flaky tests. Use List.of (which preserves the literal order) or a LinkedHashSet/LinkedHashMap wrapped with Collections.unmodifiable* when you actually need order.
  2. Set.of(a, b) and Set.of(b, a) may iterate differently even in the same run if the values hash differently. Don't compare-by-toString.

This is by design — Java is preventing you from accidentally depending on order so the implementation is free to change it.

What unmodifiability does not give you

  • It's not thread-safe across reads of mutable element fields. If the elements are mutable and another thread is changing them, you need synchronisation regardless.
  • It does not make the underlying collection thread-safe. Collections.unmodifiableList(arrayList) is a view of a non-thread-safe list; if another thread adds to arrayList, the read through the view may see a torn state. For thread-safe immutability, List.copyOf (or List.of) is the right tool — they own private storage.
  • It does not make .equals order-independent. A List returned by List.of is still equal-by-position to other lists, not by content.

A worked example: literals, snapshots, views, the shallowness trap

The program below shows all three strategies side by side, demonstrates the "view sees mutations" surprise, the "copy is independent" promise, and the shallowness trap that catches everyone the first time.

java— editable, runs on the server

What to take from the run:

  • List.of and List.copyOf both produce a real immutable collection — they reject all mutation. They differ only in whether you supplied the data literally or copied it from somewhere else.
  • The Collections.unmodifiableList view rejected view.add but accepted backing.add through the original reference. Changes through the backing list became visible through the view. That's the defining feature of a view, and the reason this strategy isn't a replacement for copyOf in untrusted code.
  • The shallowness trap is real: the int[] elements of an immutable List<int[]> are themselves mutable, and editing one rewrites the "frozen" list. If you want deep immutability, your elements must already be immutable.
  • Set.of rejected the duplicate, and Map.of rejected the null value — both at construction. These collections fail fast and noisily; that's a feature.
  • List.copyOf of an already immutable list returned the same instance without allocating. That's the JDK's optimisation, and it's why "always copy on the way out" is cheap when the source is already immutable.

What's next — and onward to Part 12

That closes the Collections Framework part. You now know every implementation (ArrayList, LinkedList, HashMap, TreeMap, the queues, the deques, and the rest), every interface (Collection, List, Set, Map, Queue, Deque), the iteration cursors (Iterator, ListIterator), the ordering interfaces (Comparable, Comparator), the static toolbox (Collections), and the immutability story.

The next part — Functional Programming — switches gears. Instead of how to store data, it covers how to express transformations on data. The first chapter, Functional Programming in Java, introduces the mental model: functions as values, immutability, pure functions, and composition. From there the part builds up lambdas, method references, the built-in functional interfaces (Function, Predicate, Consumer, Supplier), Optional, and streams — which use the collections you just learned as their source and sink.

Most of the patterns in this part — list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), stream().filter(...).toList() — are already functional in flavour. Part 12 makes the flavour explicit and shows you how to use it for everything else.

Practice

Practice

You have a `private List<String> names = new ArrayList<>()` field and you want a getter that lets callers *read* the current contents but never mutate them, and which also reflects later additions to `names` made by the owning class. Which return expression fits?