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:
- 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.
- Hashable safely. Putting a mutable
Listin aHashSetis a bug — if the list's content changes, itshashCodechanges, and the set loses the element. Unmodifiable collections sidestep this entirely. - 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.ofEntries — true 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:
- No
nullelements, nonullkeys, nonullvalues.List.of("a", null)throwsNullPointerExceptionat construction. If you need to represent "absent," useOptionalor omit the key from the map. - No duplicates for
Set.ofandMap.of.Set.of("a", "a")throwsIllegalArgumentException. They're meant for literal data you control. Map.ofhas overloads only up to 10 entries. For 11 or more, useMap.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
| Pattern | Independent of source? | Allows null? | Use when |
|---|---|---|---|
List.of("a", "b") | n/a (no source) | No | Literal constants |
List.copyOf(source) | Yes — own storage | No | Snapshot at a moment in time |
Collections.unmodifiableList(source) | No — view | Yes | Expose 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:
Set.ofandMap.ofdeliberately 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. UseList.of(which preserves the literal order) or aLinkedHashSet/LinkedHashMapwrapped withCollections.unmodifiable*when you actually need order.Set.of(a, b)andSet.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 threadadds toarrayList, the read through the view may see a torn state. For thread-safe immutability,List.copyOf(orList.of) is the right tool — they own private storage. - It does not make
.equalsorder-independent. AListreturned byList.ofis 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.
What to take from the run:
List.ofandList.copyOfboth 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.unmodifiableListview rejectedview.addbut acceptedbacking.addthrough 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 forcopyOfin untrusted code. - The shallowness trap is real: the
int[]elements of an immutableList<int[]>are themselves mutable, and editing one rewrites the "frozen" list. If you want deep immutability, your elements must already be immutable. Set.ofrejected the duplicate, andMap.ofrejected thenullvalue — both at construction. These collections fail fast and noisily; that's a feature.List.copyOfof 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
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?