Immutable & Unmodifiable Collections

Java offers two related but distinct ways to make a collection resist changes: truly immutable factory-created collections, and unmodifiable views over a still-mutable backing collection.

Since Java 9, factory methods like List.of(...), Set.of(...), and Map.of(...) create genuinely immutable collections in one line. Older code instead uses Collections.unmodifiableList(...) and friends, which wrap an existing (still mutable) collection in a read-only view. The two approaches look similar but behave differently, and confusing them is a common source of bugs.

What Is an Immutable Collection

An immutable collection cannot be changed after creation — not by you, and not by anyone else holding a reference to it. There is no backing mutable structure hiding behind it; any add/remove/set call throws UnsupportedOperationException.

import java.util.List;

List<String> colors = List.of("red", "green", "blue");
colors.add("yellow"); // throws UnsupportedOperationException

Why Immutability Matters

Immutable collections are inherently thread-safe (no synchronization needed for concurrent reads), safe to share as constants or return from APIs without defensive copying, and communicate intent clearly — the caller knows a returned collection is not meant to be modified.

List.of, Set.of, Map.of

Java 9's of() factory methods build immutable collections directly, with no intermediate mutable step.

Set.of and Map.of reject duplicate keys/elements — passing a repeated element throws IllegalArgumentException at creation time, and none of the of() factories accept null elements/keys/values (throws NullPointerException).

import java.util.*;

List<Integer> nums = List.of(1, 2, 3, 4);
Set<String> tags = Set.of("java", "cpp", "c");
Map<String, Integer> ages = Map.of("Alice", 30, "Bob", 25);

// Larger maps: Map.ofEntries avoids the key/value-pair-argument limit
Map<String, Integer> more = Map.ofEntries(
    Map.entry("Alice", 30),
    Map.entry("Bob", 25),
    Map.entry("Carl", 40)
);

Unmodifiable Views

Collections.unmodifiableList/Set/Map(...) wraps an existing collection in a read-only facade. The wrapper itself rejects modification calls, but it is only a view — if code holding a reference to the original mutable backing collection changes it, that change is visible through the unmodifiable view too.

import java.util.*;

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

view.add("c");     // throws UnsupportedOperationException
mutable.add("c");  // OK - allowed on the backing list
System.out.println(view); // [a, b, c] - the view reflects the change

Defensive Copying

If you need a collection that is guaranteed never to change — regardless of what happens to any original — you must copy the data, not just wrap it. List.copyOf(...) (Java 10+) makes an immutable copy; wrapping with Collections.unmodifiableList does not.

List.copyOf on an already-immutable List.of(...) instance is typically optimized to return the same instance rather than allocate a new one — an implementation detail, not something to rely on for identity checks.

List<String> mutable = new ArrayList<>(List.of("a", "b"));
List<String> trulyIndependent = List.copyOf(mutable);

mutable.add("c");
System.out.println(trulyIndependent); // [a, b] - unaffected, it's a real copy

Key Differences Summarized

List.of(...)          Collections.unmodifiableList(x)
Backing data         none (self-contained) x (still mutable!)
Changes via wrapper   rejected              rejected
Changes via backing    n/a                   VISIBLE through the wrapper
null elements         rejected (NPE)         allowed if x allows them
Truly immutable       yes                    no - only a read-only view

Rules and Behavior

Both approaches throw UnsupportedOperationException for mutating calls (add, remove, set, clear, put, etc.) invoked directly on them. Read-only operations (get, contains, size, iteration) work normally on both.

Iterating an immutable collection is safe from ConcurrentModificationException in the sense that nothing can structurally modify it during iteration — there is no mutation path at all.

Nested Mutability Caveat

Immutability of the collection itself does not make its elements immutable. List.of(mutableObject) prevents adding/removing list elements, but if mutableObject is itself a mutable class, its internal state can still change.

import java.util.*;

List<StringBuilder> list = List.of(new StringBuilder("hi"));
list.get(0).append(" there"); // allowed - mutates the StringBuilder, not the List
System.out.println(list.get(0)); // "hi there"

Practical Usage

Common uses: defining constant configuration data, returning collections from getters/APIs that callers shouldn't mutate, and building compile-time-safe lookup tables.

public final class Config {
    public static final List<String> SUPPORTED_LOCALES = List.of("en", "fr", "de");
}

// A getter that protects internal state:
public List<String> getTags() {
    return List.copyOf(this.tags); // caller can't mutate our internal list
}

Edge Cases

Arrays.asList(...) is neither of these — it's a fixed-size list backed by the array: set() works (it writes through to the array), but add()/remove() throw UnsupportedOperationException because the list can't resize the underlying array.

List<String> fixed = Arrays.asList("a", "b", "c");
fixed.set(0, "z");   // OK
fixed.add("d");       // throws UnsupportedOperationException

Complete Example

import java.util.*;

public class ImmutableDemo {
    private final List<String> internalNames = new ArrayList<>(List.of("Alice", "Bob"));

    public List<String> namesView() {
        return Collections.unmodifiableList(internalNames); // reflects future changes
    }

    public List<String> namesSnapshot() {
        return List.copyOf(internalNames); // frozen at call time
    }

    public static void main(String[] args) {
        ImmutableDemo demo = new ImmutableDemo();
        List<String> view = demo.namesView();
        List<String> snapshot = demo.namesSnapshot();

        demo.internalNames.add("Carl");

        System.out.println(view);     // [Alice, Bob, Carl] - live view
        System.out.println(snapshot); // [Alice, Bob] - frozen
    }
}

Mental Model

Think of List.of(...) as pouring concrete — the shape is fixed forever from the moment of creation. Think of Collections.unmodifiableList(x) as a locked glass window onto x — you can't reach through it to change anything, but if someone else has a key to x itself, you'll see the room behind the glass change.

Quick Reference

List.of(a, b, c)                        immutable, self-contained
Set.of(a, b, c)                         immutable, no duplicates allowed
Map.of(k1, v1, k2, v2)                  immutable, small map
Map.ofEntries(Map.entry(k, v), ...)     immutable, larger map
List.copyOf(existing)                   immutable snapshot/copy
Collections.unmodifiableList(existing)  read-only VIEW, backing still mutable
Arrays.asList(array)                    fixed-size, writes through to array

Key Takeaways

List.of/Set.of/Map.of create truly immutable, self-contained collections; they reject null elements and (for Set/Map) reject duplicates.

Collections.unmodifiableX creates a read-only view over a still-mutable backing collection — changes to the backing collection remain visible through the view.

List.copyOf makes a real, independent immutable snapshot — use it when you need a guarantee nothing else can ever change your data.

Immutability of a collection does not make its contained objects immutable.

All mutating calls on an immutable or unmodifiable collection throw UnsupportedOperationException.

List<String> view = Collections.unmodifiableList(source); // assume view can never change source.add("new item"); // view now unexpectedly contains the new item

List<String> snapshot = List.copyOf(source); // snapshot is frozen regardless of future changes to source

An unmodifiable view is not immune to changes made through the original backing collection; use copyOf for a real, independent snapshot.

List<String> list = List.of("a", null, "c"); // throws NullPointerException

List<String> list = new ArrayList<>(Arrays.asList("a", null, "c")); // allows null if you truly need it

The of() factory methods reject null elements/keys/values outright, unlike ArrayList.

List<String> list = Arrays.asList("a", "b"); list.add("c"); // throws UnsupportedOperationException, surprising many devs

List<String> list = new ArrayList<>(Arrays.asList("a", "b")); list.add("c"); // OK, wrapped in a resizable ArrayList

Arrays.asList returns a fixed-size list backed by the array — it supports set() but not structural changes like add/remove.

Set<String> s = Set.of("x", "x"); // throws IllegalArgumentException

Set<String> s = Set.of("x"); // or use a Set implementation that tolerates re-adding the same element

Set.of explicitly rejects duplicate elements at construction time, unlike adding duplicates one at a time to a HashSet (which silently no-ops).

What is the fundamental difference between List.of(...) and Collections.unmodifiableList(list)?

List.of creates a self-contained, genuinely immutable collection with no backing mutable structure. Collections.unmodifiableList wraps an existing mutable collection in a read-only view — changes made directly to the backing collection remain visible through the view.

Why does List.of(1, 2, null) throw an exception?

The of() factory methods explicitly disallow null elements (and, for Map.of, null keys or values), throwing NullPointerException immediately at creation time rather than allowing a null to be stored.

How would you give a caller a collection that is guaranteed to never change, even if your internal list changes later?

Return List.copyOf(internalList) (or a similar copy) rather than the list itself or an unmodifiable view of it — copyOf produces an independent snapshot at the moment it's called.

Does an immutable List guarantee its elements are also immutable?

No. The list's structure (which elements it holds, in what order) can't change, but if an element is itself a mutable object, its internal state can still be modified through any reference to it.

What exception is thrown when you call add() on an unmodifiable or immutable collection, and why that particular exception?

UnsupportedOperationException — because the collection type structurally supports the Collection interface's method signatures but chooses not to implement the mutating operation, which is exactly what UnsupportedOperationException is designed to signal.

Is Arrays.asList(array) immutable, unmodifiable, or something else?

Something else: it's a fixed-size list view backed directly by the array. set() is allowed (and writes through to the array), but add()/remove() throw UnsupportedOperationException because they'd require resizing the underlying array.

Why might returning List.of(...) from an internal cache be dangerous if you build it incrementally with a builder pattern?

It's not dangerous by itself, but if you meant to build it up over multiple calls before exposing it, List.of requires all elements up front — you'd typically build with an ArrayList and only call List.copyOf() once construction is finished.

Write a method freeze that takes a mutable List<String> and returns a truly independent immutable copy of it.

import java.util.List; public class FreezeDemo { static List<String> freeze(List<String> input) { return List.copyOf(input); } public static void main(String[] args) { java.util.List<String> mutable = new java.util.ArrayList<>(); mutable.add("one"); mutable.add("two"); List<String> frozen = freeze(mutable); mutable.add("three"); System.out.println(frozen); // [one, two] } }

List.copyOf produces a snapshot independent of the source list, so later mutations to the original don't affect it.

What does this print?

"caught" then [1, 2, 3]. set() on an immutable list throws UnsupportedOperationException, which is caught, and the list is unchanged.

Write a program demonstrating that Collections.unmodifiableMap reflects changes made to its backing map.

import java.util.*; public class UnmodifiableViewDemo { public static void main(String[] args) { Map<String, Integer> backing = new HashMap<>(); backing.put("a", 1); Map<String, Integer> view = Collections.unmodifiableMap(backing); System.out.println(view); // {a=1} backing.put("b", 2); // mutate the backing map directly System.out.println(view); // {a=1, b=2} - view reflects the change } }

The unmodifiable wrapper only blocks mutation through the view itself; the backing map is still fully mutable and changes made to it are visible through the view.

List<Integer> list = List.of(1, 2, 3);
try {
    list.set(0, 99);
} catch (UnsupportedOperationException e) {
    System.out.println("caught");
}
System.out.println(list);