immutable: List.of vs unmodifiableList vs copyOf - null handling, views vs copies, JMH wrap/copy overhead, a Guava comparison, and the record defensive-copy trap

This commit is contained in:
2026-10-01 11:32:13 +00:00
parent 64de44fa65
commit 2eaa464bad
18 changed files with 731 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
=== 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
=== Collections.unmodifiableList is a VIEW, not a copy: mutating the backing list shows through ===
before: unmodView = [a, null, c]
after backing.set(0,...) and backing.add("d"): unmodView = [A-CHANGED, null, c, d]
-> the "unmodifiable" promise is about the VIEW's own mutator methods, not about the data being frozen
=== unmodifiableList's own mutators still throw, even though the backing list is mutable ===
unmodView.add("e") threw UnsupportedOperationException
+21
View File
@@ -0,0 +1,21 @@
=== 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
=== 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
=== Map and Set follow the same pattern as List ===
unmodMapView after mutating backing map = {one=1, two=2} (view: changed)
mapCopy after mutating the ORIGINAL map = {one=1} (copy: unchanged)
+19
View File
@@ -0,0 +1,19 @@
=== 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.
=== Element limit: List.of has none in practice; the old Arrays.asList(E...) varargs ceiling is long gone ===
List.of(... 5000 elements ...).size() = 5000 (List.of(E...) switched to a varargs array long ago - the old 1-10 overloads are just JIT-friendlier for small sizes)
=== Guava's ImmutableList also exposes a reverse() view and asList() - conceptually close to JEP 431's reversed() ===
built.reverse() = [5, 4, 3, 2, 1] (Guava's reverse() predates java.util's reversed() by over a decade)
=== Where they genuinely differ: Guava's collections are NOT in java.base, so they ship as a real dependency ===
Adding Guava to a project to get ImmutableList today mostly buys you nothing List.of/List.copyOf
doesn't already give you for the collection types themselves - the builder pattern above is the
real remaining reason, plus ImmutableList/ImmutableMap/ImmutableSet having first-class equivalents
for ImmutableMultimap, ImmutableTable and other Guava-only collection types the JDK has no analog for.
@@ -0,0 +1,18 @@
=== 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
=== The constructor itself still rejects null elements, same as List.of ===
new SafeOrder(..., listContainingNull) threw NullPointerException from inside List.copyOf(...)
+10
View File
@@ -0,0 +1,10 @@
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
+4
View File
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.ankurm.immutable.ImmutableCollectionsTest
-------------------------------------------------------------------------------
Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.106 s -- in com.ankurm.immutable.ImmutableCollectionsTest