Skip to main content

Immutable Collections in Java: List.of vs unmodifiableList vs copyOf

Collections.unmodifiableList, List.of and List.copyOf all refuse direct mutation, but only two of them reject null elements and only one of them is a real snapshot rather than a live view over a list you still hold a mutable reference to. Covers null handling, views vs copies, real JMH construction/read overhead, a brief Guava ImmutableList comparison, and the record field that looks immutable but isn’t.

“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.of shipped in Java 9 (2017) and List.copyOf/Map.copyOf/Set.copyOf in 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.

backing (ArrayList) unmodifiableList(backing) VIEW — always reads through to backing List.of(“a”,”b”) owns its own array, no source to track List.copyOf(backing) SNAPSHOT — copied once, then forgets backing existed The solid arrow is live; the dashed arrow fired once, at construction, and is gone.
FactoryRejects null elements?View or copy?Since
Arrays.asList(...)NoView over the arrayJava 1.2
Collections.unmodifiableList(l)No (defers to l)View over lJava 1.2
List.of(...)YesOwns its own storageJava 9
List.copyOf(l)YesCopy of l (or same instance if l is already immutable)Java 10

Going deeper on this section:

  • Map.of/Map.copyOf and Set.of/Set.copyOf follow the identical view-vs-copy split — demonstrated for Map in 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.of and List.copyOf validate every element up front because they are building a new, permanent object and have one chance to reject bad input. Arrays.asList and Collections.unmodifiableList never look at the elements at all — they only intercept the mutator methods on the wrapper itself. Whatever was already in the backing structure, null included, passes straight through.

Going deeper on this section:

  • This is also why Collections.unmodifiableList cannot 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 just null.
  • Guava’s ImmutableList matches List.of here 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 in unmodifiableList once, 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, unmodifiableList was 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:

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.

Construction cost at size=10 vs size=1000 (ops/ms, higher is cheaper) unmodifiableList 21,757 29,295 copyOf (JDK) 373 14,443 copyOf (Guava) 1,532 16,109 size=10 size=1000

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 @State classes 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, not ImmutableList itself, 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.

LeakyOrder vs SafeOrder caller’s ArrayList LeakyOrder.items same object – caller’s later .add() shows up here too caller’s ArrayList SafeOrder.items List.copyOf() ran once in the constructor – separate object from here on A record’s canonical constructor stores exactly the reference you hand it – “final” means the field can’t be reassigned, not that the object it points to can’t change.
Why final doesn’t save you here. A record’s field is implicitly final, which means this.items can 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> items and “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 null elements, because List.copyOf does 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, an int, 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 for copyOf — 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 for ImmutableList alone; the JDK now covers that case.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.