“Make it immutable” usually means one of three different things in Java, and the three factories people reach for — Collections.unmodifiableList(list), List.of(...), and List.copyOf(list) — are not interchangeable even though all three refuse to let you call .add(...) on the result. One of them is a promise about an object you still hold a mutable reference to elsewhere. One of them is a real, independent snapshot. Mixing them up is how a “frozen” config list changes underneath a running service, or a cache key silently stops matching itself.
This article is the three factories, what they actually guarantee, what they cost, and the one place — a record field — where this distinction bites hardest in code written today. Everything in it was run. The companion module is asmhatre/java-core-examples/immutable, backed by a 12-test suite, with every quoted line taken verbatim from output/.
Versions this was tested against. JDK 25.0.4.1+1 (Temurin, LTS) —List.of/Map.of/Set.ofshipped in Java 9 (2017) andList.copyOf/Map.copyOf/Set.copyOfin Java 10 (2018); both behave identically on 25. Guava 33.7.2-jre (newest GA on Maven Central at the time of writing). JMH 1.37, JUnit Jupiter 5.11.0, Maven 3.9.11. Benchmarks ran on a shared, multi-tenant sandbox — see the ratio-not-absolute note before the numbers below.
Three factories that look interchangeable and aren’t
All three read as “give me a list I can’t modify.” All three are correct about that much. Where they stop agreeing is the question that actually matters in practice: is the data itself frozen, or just this particular reference to it?
List<String> backing = new ArrayList<>(List.of("a", "b"));
List<String> view = Collections.unmodifiableList(backing); // wraps 'backing'
List<String> literal = List.of("a", "b"); // owns its own storage
List<String> snapshot = List.copyOf(backing); // copies 'backing' right now
Full source for every claim in this section: ViewsVsCopiesDemo.java.
| Factory | Rejects null elements? | View or copy? | Since |
|---|---|---|---|
Arrays.asList(...) | No | View over the array | Java 1.2 |
Collections.unmodifiableList(l) | No (defers to l) | View over l | Java 1.2 |
List.of(...) | Yes | Owns its own storage | Java 9 |
List.copyOf(l) | Yes | Copy of l (or same instance if l is already immutable) | Java 10 |
Going deeper on this section:
Map.of/Map.copyOfandSet.of/Set.copyOffollow the identical view-vs-copy split — demonstrated forMapin ViewsVsCopiesDemo.java.- Oracle’s List.of() Javadoc is the primary source for the “unmodifiable” wording this page keeps testing against real behaviour rather than quoting.
Null handling: two of the four check, two don’t
This is the difference that throws at the least convenient time — usually in production, on the one request whose data happened to have a missing field.
List<String> viaAsList = Arrays.asList("a", null, "c");
ArrayList<String> backing = new ArrayList<>();
backing.add("a"); backing.add(null); backing.add("c");
List<String> unmodView = Collections.unmodifiableList(backing);
try {
List.of("a", null, "c");
} catch (NullPointerException e) { /* ... */ }
try {
List.copyOf(viaAsList); // viaAsList already contains a null
} catch (NullPointerException e) { /* ... */ }
Full source: NullHandlingDemo.java.
=== List.of(...) - rejects null at construction time ===
List.of("a", null, "c") threw NullPointerException
=== Arrays.asList(...) - allows null, it's just a view over the array ===
Arrays.asList("a", null, "c") = [a, null, c]
=== Collections.unmodifiableList(...) - allows whatever the backing list allows ===
unmodifiableList over a list already containing null = [a, null, c]
(it has no null-check of its own - it only blocks structural writes, see below)
=== List.copyOf(...) - rejects null, same as List.of ===
List.copyOf(aListContainingNull) threw NullPointerException
-> List.copyOf() re-validates elements, it does not just wrap and trust the source
Captured run: output/01-null-handling.txt.
The rule that actually predicts this.List.ofandList.copyOfvalidate every element up front because they are building a new, permanent object and have one chance to reject bad input.Arrays.asListandCollections.unmodifiableListnever look at the elements at all — they only intercept the mutator methods on the wrapper itself. Whatever was already in the backing structure,nullincluded, passes straight through.
Going deeper on this section:
- This is also why
Collections.unmodifiableListcannot retroactively make a list “safe” to hand to untrusted code that already has its own reference to the backing list — the next section is exactly that problem, generalised beyond justnull. - Guava’s
ImmutableListmatchesList.ofhere too; see the Guava section further down.
Views vs copies: the distinction that actually bites
Collections.unmodifiableList blocks writes through itself. It says nothing about writes through any other reference to the same backing list — and because it’s a view, those other writes show up immediately.
ArrayList<String> backing = new ArrayList<>(List.of("a", "b", "c"));
List<String> view = Collections.unmodifiableList(backing);
backing.add("d");
// view now shows [a, b, c, d] - nobody touched 'view' directly
List<String> source = new ArrayList<>(List.of("x", "y", "z"));
List<String> copy = List.copyOf(source);
source.add("w");
// copy still shows [x, y, z] - it stopped watching 'source' the moment copyOf() ran
Full source: ViewsVsCopiesDemo.java.
=== unmodifiableList is a VIEW: changes to the backing list show through ===
backing = [a, b, c]
view = [a, b, c]
after backing.add("d"):
backing = [a, b, c, d]
view = [a, b, c, d] <- changed, with no code touching 'view' directly
=== List.copyOf is a SNAPSHOT: changes to the source do NOT show through ===
source = [x, y, z]
copy = [x, y, z]
after source.add("w"):
source = [x, y, z, w]
copy = [x, y, z] <- unchanged, it was a real copy at the moment copyOf() ran
Captured run: output/02-views-vs-copies.txt.
The fingerprint of this bug. Code that wraps a collection inunmodifiableListonce, early — often while building a response object or a cached value — and later notices its “frozen” data changed with no write anywhere near it. The write happened somewhere else entirely, through the original mutable reference nobody remembered was still live. If the goal was to protect against exactly that,unmodifiableListwas the wrong tool from the start.
Reference depth: List.copyOf’s documented no-op on an already-immutable source
List.copyOf‘s own Javadoc documents an optimisation worth knowing about if you ever chain these calls: if the argument is already one of the JDK’s unmodifiable list implementations, copyOf returns that exact same instance rather than allocating a second copy. Checked directly:
=== List.copyOf has a documented optimization: copying an already-immutable list is a no-op ===
List.copyOf(List.of(...)) returns the SAME instance: true
List.copyOf(new ArrayList<>(...)) returns a DIFFERENT instance: true
Captured run: output/02-views-vs-copies.txt.
Going deeper on this section:
- Full demo, including the
Mapversion of the same view-vs-copy split: ViewsVsCopiesDemo.java. - JEP 431’s
reversed(), covered in this site’s Sequenced Collections post, is the exact same view-not-copy trap wearing a different method name — worth reading if this section felt familiar.
How much does any of this actually cost?
Two separate questions, and they have opposite answers. Does wrapping-vs-copying cost anything at construction time? Yes, and it scales with size. Does reading through a wrapper cost anything per call, afterward? Measured here: no, not at a scale the JIT doesn’t erase.
Benchmark (size) Mode Cnt Score Error Units
ImmutableOverheadBenchmark.construct_guavaImmutableListCopyOf 10 thrpt 6 16108.571 ± 1054.221 ops/ms
ImmutableOverheadBenchmark.construct_guavaImmutableListCopyOf 1000 thrpt 6 1532.302 ± 53.937 ops/ms
ImmutableOverheadBenchmark.construct_listCopyOf 10 thrpt 6 14442.559 ± 783.408 ops/ms
ImmutableOverheadBenchmark.construct_listCopyOf 1000 thrpt 6 372.757 ± 63.463 ops/ms
ImmutableOverheadBenchmark.construct_unmodifiableListWrap 10 thrpt 6 29294.827 ± 3954.701 ops/ms
ImmutableOverheadBenchmark.construct_unmodifiableListWrap 1000 thrpt 6 21757.029 ± 2062.127 ops/ms
ImmutableOverheadBenchmark.read_immutableCopy_get 1000 thrpt 6 2146.271 ± 121.977 ops/ms
ImmutableOverheadBenchmark.read_plainArrayList_get 1000 thrpt 6 2141.758 ± 115.453 ops/ms
ImmutableOverheadBenchmark.read_unmodifiableWrapper_get 1000 thrpt 6 2309.174 ± 116.629 ops/ms
Source: ImmutableOverheadBenchmark.java. Full raw JMH report: output/05-jmh-raw.txt.
Wrapping stays roughly flat — both sizes land in the same 20–30k ops/ms neighbourhood, because unmodifiableList allocates one small wrapper object regardless of how big the backing list is. Both copyOf implementations fall sharply as size grows, because copying is proportional to the number of elements; at 1,000 elements the JDK’s List.copyOf is roughly 58× slower than wrapping the same list, and Guava’s is roughly 14× slower. None of that shows up on the read side — read_plainArrayList_get, read_unmodifiableWrapper_get and read_immutableCopy_get all land within noise of each other (~2,100–2,300 ops/ms) at 1,000 elements, which means the one extra delegation hop inside unmodifiableList.get(i) is cheap enough that the JIT erases the difference in a tight loop.
Read the ratios, not the absolute numbers. This ran on a shared, multi-tenant sandbox, not an isolated box — the error margins in output/05 are wide enough that the exact ops/ms figures will move on a rerun. The shape holds up regardless: wrap stays flat with size, copy does not, and read-path cost across all three is a wash at this scale.
Going deeper on this section:
- The benchmark harness, including the two separate
@Stateclasses for construction vs read, all four JMH annotations controlling warmup/measurement/forks: ImmutableOverheadBenchmark.java. - OpenJDK JMH — the harness itself, if you want to run this on your own hardware rather than trust a shared sandbox’s numbers.
Guava’s ImmutableList, briefly
Guava’s immutable collections predate List.of by roughly a decade — Guava’s collections shipped around 2010, List.of landed in Java 9 in 2017 — and most of what made Guava’s version worth adding as a dependency back then is now in java.util itself.
try {
ImmutableList.of("a", null, "c");
} catch (NullPointerException e) { /* same as List.of */ }
ImmutableList<Integer> built = ImmutableList.<Integer>builder()
.add(1).add(2).add(3).add(4).add(5)
.build();
Full source: GuavaComparisonDemo.java.
=== Both reject null, same as List.of ===
ImmutableList.of("a", null, "c") threw NullPointerException - same as List.of
=== Guava's builder tolerates a size that grows past what you declared; List.of has no builder ===
ImmutableList.builder() result = [1, 2, 3, 4, 5]
-> java.util has no equivalent builder; you either know all elements up front for List.of(...)
or you build an ArrayList and call List.copyOf(...) at the end - this demo's RecordDemo does exactly that.
Captured run: output/03-guava-comparison.txt.
The one practical gap left: a builder. List.of(...) needs every element up front as arguments; there is no java.util equivalent of accumulating elements one at a time into something that only becomes immutable when you’re done. The JDK’s own answer is the pattern this post’s record section uses anyway — build into a plain ArrayList, then call List.copyOf(...) once at the end. Guava’s builder skips that intermediate mutable list; whether that’s worth a dependency depends on how often your codebase builds lists incrementally versus from a known set of elements.
Going deeper on this section:
- Guava also ships
ImmutableMultimap,ImmutableTable, and other collection shapes the JDK has no equivalent for at all — those, notImmutableListitself, are the more durable reason a codebase keeps Guava as a dependency today. - Guava’s ImmutableList Javadoc.
The record field that looks immutable but isn’t
This is where the view-vs-copy distinction costs people the most in code written today, because a record looks finished and safe the moment it compiles — and a List<String> field on it is not immutable just because the record is.
record LeakyOrder(String id, List<String> items) {
// no compact constructor - items is stored exactly as passed in
}
record SafeOrder(String id, List<String> items) {
SafeOrder {
items = List.copyOf(items); // snapshot + reject nulls, same guarantee List.of gives you
}
}
Full source: DefensiveCopyRecordDemo.java.
=== LeakyOrder: a record field that LOOKS immutable but isn't ===
leaky.items() right after construction = [widget, gadget]
caller mutates their OWN list reference afterwards: callerList.add(...)
leaky.items() now = [widget, gadget, SNEAKY EXTRA ITEM] <- the record's field changed too, because it's the SAME list object
=== LeakyOrder.items() also returns the live mutable reference - callers can mutate it directly ===
leaky.items() after leaky.items().add(...) = [widget, gadget, SNEAKY EXTRA ITEM, MUTATED THROUGH THE ACCESSOR]
=== SafeOrder: List.copyOf(...) in a compact constructor closes both holes ===
safe.items() right after construction = [widget, gadget]
caller mutates their OWN list reference afterwards: callerList2.add(...)
safe.items() now = [widget, gadget] <- unchanged, it's an independent copy
=== And the accessor's result rejects mutation too, since List.copyOf returns an immutable list ===
safe.items().add(...) threw UnsupportedOperationException
Captured run: output/04-defensive-copy-record.txt.
Whyfinaldoesn’t save you here. A record’s field is implicitlyfinal, which meansthis.itemscan never be reassigned to point at a different list after construction. It says nothing about whether the list object itself can change.final List<String> itemsand “items can never change” are two different claims, and only a defensive copy in the constructor — not the field modifier — closes the gap between them.
Going deeper on this section:
- The constructor still rejects
nullelements, becauseList.copyOfdoes the same validation it always does — confirmed in the same output file above. - This is specifically about mutable collection-typed fields; a record field holding an immutable type (a
String, anint, another record) needs no defensive copy because there’s nothing mutable to leak.
Should you reach for this?
Default to List.of()/List.copyOf() for anything you hand to code you don’t control, and reserve unmodifiableList for one narrow case: exposing a read-only view of a collection YOU keep mutating internally, on purpose. If you’re writing a record, a public API return value, or anything that should stop being “yours to mutate” at a boundary, reach forcopyOf— the null-check and the real snapshot are both worth the construction cost measured above, and that cost is only large relative to itself; in absolute terms it’s microseconds. Don’t add Guava today forImmutableListalone; the JDK now covers that case.
Further reading
- Companion repository,
immutablemodule: full source, demos, JMH benchmark and captured output. - Sequenced Collections in Java 21+: getFirst, getLast and reversed() — the same view-vs-copy question, on
reversed()instead ofcopyOf. - Top 40 Java Collections Interview Questions — broader collections-framework coverage, including fail-fast iterators this page’s view semantics connect to.
- Oracle Javadoc: List.of(), List.copyOf(), Collections.unmodifiableList().
- Guava ImmutableList Javadoc.
No Comments yet!