From 2eaa464bad40f0b49026007ee6716d0c3e21b61b Mon Sep 17 00:00:00 2001 From: Ankur Date: Thu, 1 Oct 2026 11:32:13 +0000 Subject: [PATCH] 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 --- README.md | 1 + immutable/README.md | 48 ++++++++ immutable/output/01-null-handling.txt | 21 ++++ immutable/output/02-views-vs-copies.txt | 21 ++++ immutable/output/03-guava-comparison.txt | 19 +++ immutable/output/04-defensive-copy-record.txt | 18 +++ immutable/output/05-jmh-raw.txt | 10 ++ immutable/output/06-tests.txt | 4 + immutable/pom.xml | 89 +++++++++++++++ immutable/scripts/run-all.sh | 24 ++++ immutable/scripts/run.sh | 7 ++ .../immutable/DefensiveCopyRecordDemo.java | 71 ++++++++++++ .../ankurm/immutable/GuavaComparisonDemo.java | 54 +++++++++ .../immutable/ImmutableOverheadBenchmark.java | 105 +++++++++++++++++ .../ankurm/immutable/NullHandlingDemo.java | 68 +++++++++++ .../ankurm/immutable/ViewsVsCopiesDemo.java | 62 ++++++++++ .../immutable/ImmutableCollectionsTest.java | 108 ++++++++++++++++++ pom.xml | 1 + 18 files changed, 731 insertions(+) create mode 100644 immutable/README.md create mode 100644 immutable/output/01-null-handling.txt create mode 100644 immutable/output/02-views-vs-copies.txt create mode 100644 immutable/output/03-guava-comparison.txt create mode 100644 immutable/output/04-defensive-copy-record.txt create mode 100644 immutable/output/05-jmh-raw.txt create mode 100644 immutable/output/06-tests.txt create mode 100644 immutable/pom.xml create mode 100755 immutable/scripts/run-all.sh create mode 100755 immutable/scripts/run.sh create mode 100644 immutable/src/main/java/com/ankurm/immutable/DefensiveCopyRecordDemo.java create mode 100644 immutable/src/main/java/com/ankurm/immutable/GuavaComparisonDemo.java create mode 100644 immutable/src/main/java/com/ankurm/immutable/ImmutableOverheadBenchmark.java create mode 100644 immutable/src/main/java/com/ankurm/immutable/NullHandlingDemo.java create mode 100644 immutable/src/main/java/com/ankurm/immutable/ViewsVsCopiesDemo.java create mode 100644 immutable/src/test/java/com/ankurm/immutable/ImmutableCollectionsTest.java diff --git a/README.md b/README.md index e8de277..13ece09 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ article; each module's own README has that article's version table, quickstart, | [`nio2`](nio2/) | Java NIO.2 File API: Files, Path, WatchService, and Streaming Large Files Without OOM | | [`list-benchmarks`](list-benchmarks/) | ArrayList vs LinkedList in 2026: JMH Benchmarks and Why LinkedList Rarely Wins | | [`sequenced`](sequenced/) | Sequenced Collections in Java 21+: getFirst, getLast and reversed() | +| [`immutable`](immutable/) | Immutable Collections in Java: List.of vs unmodifiableList vs copyOf | ## License diff --git a/immutable/README.md b/immutable/README.md new file mode 100644 index 0000000..bbf83e4 --- /dev/null +++ b/immutable/README.md @@ -0,0 +1,48 @@ +# immutable + +Companion code for the ankurm.com post *"Immutable Collections in Java: List.of vs +unmodifiableList vs copyOf."* Module in `java-core-examples`, the Java-core series. + +## Versions this was built and tested against + +| Component | Version | Notes | +|---|---|---| +| JDK | 25.0.4.1+1 (Temurin, LTS) | `List.of`/`Map.of`/`Set.of` shipped in Java 9 (2017); `List.copyOf`/`Map.copyOf`/`Set.copyOf` in Java 10 (2018). Both unchanged since. | +| Guava | 33.7.2-jre | Newest GA per Maven Central at the time of writing. | +| JMH | 1.37 | | +| JUnit Jupiter | 5.11.0 | | +| Maven | 3.9.11 | | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-17-or-newer +mvn compile +java -cp "target/classes:$(find ~/.m2 -name 'guava-*.jar' ! -name '*sources*' | head -1)" \ + com.ankurm.immutable.NullHandlingDemo +``` + +`scripts/run-all.sh` regenerates every file in `output/`, including a fresh JMH run. +`scripts/run.sh ` runs one demo ad hoc. + +## What's in here + +| File | What it shows | +|---|---| +| `NullHandlingDemo.java` | `List.of`/`List.copyOf` reject `null` elements at construction; `Arrays.asList` and `Collections.unmodifiableList` do not check for `null` themselves - they only block structural writes. | +| `ViewsVsCopiesDemo.java` | `Collections.unmodifiableList` is a live view over its backing collection; `List.copyOf`/`Map.copyOf` take an independent snapshot. Also pins `List.copyOf`'s documented no-op optimisation when the source is already immutable. | +| `GuavaComparisonDemo.java` | Where Guava's `ImmutableList` still differs from `List.of`/`List.copyOf` today: mainly the builder API, since null-rejection and copy-vs-view behaviour now match. | +| `DefensiveCopyRecordDemo.java` | A record's compact constructor does **not** defensively copy a `List` field for you - `LeakyOrder` shows the leak both at construction and through the accessor; `SafeOrder` fixes both with `List.copyOf(items)`. | +| `ImmutableOverheadBenchmark.java` | JMH: construction cost of wrap-vs-copy at two sizes, and read-by-index cost across a plain `ArrayList`, an unmodifiable wrapper, and an immutable copy. | +| `ImmutableCollectionsTest.java` | Pins all of the above as assertions, 12/12 passing. | +| `output/01-06` | Captured runs of the four demos, the raw JMH report, and the test suite. | + +## Notes worth knowing before reading the post + +- **`Collections.unmodifiableList` has no null-check of its own.** It wraps whatever list you hand it and blocks *its own* mutator methods; whether `null` is already in there, or gets added later through a reference you kept to the backing list, is entirely up to that backing list. See `output/01`. +- **Construction cost and read cost tell opposite stories.** `scripts/run-all.sh`'s JMH run (`output/05`) shows wrapping is O(1) regardless of size while `copyOf` is O(n) and gets measurably slower as the source grows - but reading through any of the three (plain list, unmodifiable wrapper, immutable copy) comes out statistically indistinguishable, because the extra delegation call in the wrapper is cheap enough for the JIT to not matter at this scale. Read the ratios, not the absolute ops/ms - this is a shared, multi-tenant sandbox. +- **A record field typed as `List` is not immutable just because the record is.** `DefensiveCopyRecordDemo` is the one demo in this module that most people get wrong in real code - see `output/04`. + +## License + +MIT - see the [repo-wide LICENSE](../LICENSE). diff --git a/immutable/output/01-null-handling.txt b/immutable/output/01-null-handling.txt new file mode 100644 index 0000000..1335d5b --- /dev/null +++ b/immutable/output/01-null-handling.txt @@ -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 diff --git a/immutable/output/02-views-vs-copies.txt b/immutable/output/02-views-vs-copies.txt new file mode 100644 index 0000000..3c9693d --- /dev/null +++ b/immutable/output/02-views-vs-copies.txt @@ -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) diff --git a/immutable/output/03-guava-comparison.txt b/immutable/output/03-guava-comparison.txt new file mode 100644 index 0000000..775bb23 --- /dev/null +++ b/immutable/output/03-guava-comparison.txt @@ -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. diff --git a/immutable/output/04-defensive-copy-record.txt b/immutable/output/04-defensive-copy-record.txt new file mode 100644 index 0000000..5a81e53 --- /dev/null +++ b/immutable/output/04-defensive-copy-record.txt @@ -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(...) diff --git a/immutable/output/05-jmh-raw.txt b/immutable/output/05-jmh-raw.txt new file mode 100644 index 0000000..dc6cf15 --- /dev/null +++ b/immutable/output/05-jmh-raw.txt @@ -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 diff --git a/immutable/output/06-tests.txt b/immutable/output/06-tests.txt new file mode 100644 index 0000000..f995e92 --- /dev/null +++ b/immutable/output/06-tests.txt @@ -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 diff --git a/immutable/pom.xml b/immutable/pom.xml new file mode 100644 index 0000000..0bac841 --- /dev/null +++ b/immutable/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + immutable + immutable + List.of/Map.of vs Collections.unmodifiableList vs List.copyOf: null handling, views vs copies, JMH overhead, a brief Guava comparison, and defensive copies in records. + + + 1.37 + 33.7.2-jre + + + + + com.google.guava + guava + ${guava.version} + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + + + benchmarks + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + + + org.openjdk.jmh.Main + + + + + + + + + diff --git a/immutable/scripts/run-all.sh b/immutable/scripts/run-all.sh new file mode 100755 index 0000000..242b88c --- /dev/null +++ b/immutable/scripts/run-all.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +mvn -q -B compile +GUAVA_JAR="$(find "$HOME/.m2" -name 'guava-*.jar' ! -name '*sources*' | head -1)" +CP="target/classes:${GUAVA_JAR}" + +java -cp "$CP" com.ankurm.immutable.NullHandlingDemo 2>&1 \ + | grep -vE 'JAVA_TOOL_OPTIONS|^WARNING' > output/01-null-handling.txt +java -cp "$CP" com.ankurm.immutable.ViewsVsCopiesDemo 2>&1 \ + | grep -vE 'JAVA_TOOL_OPTIONS|^WARNING' > output/02-views-vs-copies.txt +java -cp "$CP" com.ankurm.immutable.GuavaComparisonDemo 2>&1 \ + | grep -vE 'JAVA_TOOL_OPTIONS|^WARNING' > output/03-guava-comparison.txt +java -cp "$CP" com.ankurm.immutable.DefensiveCopyRecordDemo 2>&1 \ + | grep -vE 'JAVA_TOOL_OPTIONS|^WARNING' > output/04-defensive-copy-record.txt + +mvn -q -B package -DskipTests +java -jar target/benchmarks.jar -rf text -rff output/05-jmh-raw.txt + +mvn -q -B test +cp target/surefire-reports/com.ankurm.immutable.ImmutableCollectionsTest.txt output/06-tests.txt + +echo "Regenerated output/01-06. Note: 05-jmh-raw.txt numbers are indicative - this sandbox is shared." diff --git a/immutable/scripts/run.sh b/immutable/scripts/run.sh new file mode 100755 index 0000000..9742c9a --- /dev/null +++ b/immutable/scripts/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Run one of the demo classes ad hoc, e.g.: ./scripts/run.sh NullHandlingDemo +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -B compile +GUAVA_JAR="$(find "$HOME/.m2" -name 'guava-*.jar' ! -name '*sources*' | head -1)" +java -cp "target/classes:${GUAVA_JAR}" "com.ankurm.immutable.$1" diff --git a/immutable/src/main/java/com/ankurm/immutable/DefensiveCopyRecordDemo.java b/immutable/src/main/java/com/ankurm/immutable/DefensiveCopyRecordDemo.java new file mode 100644 index 0000000..daac083 --- /dev/null +++ b/immutable/src/main/java/com/ankurm/immutable/DefensiveCopyRecordDemo.java @@ -0,0 +1,71 @@ +package com.ankurm.immutable; + +import java.util.ArrayList; +import java.util.List; + +/** + * A record's canonical constructor does NOT defensively copy a {@code List} field for you - it + * stores exactly the reference it was handed. If the caller passes a mutable {@code ArrayList}, + * the record's "immutable" field is only as immutable as whatever the caller does with their own + * reference next. This demo shows the leak on {@link LeakyOrder}, then the fix on {@link SafeOrder}, + * which calls {@code List.copyOf(...)} inside a compact constructor. + */ +public final class DefensiveCopyRecordDemo { + + record LeakyOrder(String id, List items) { + // no compact constructor - items is stored exactly as passed in + } + + record SafeOrder(String id, List items) { + SafeOrder { + items = List.copyOf(items); // snapshot + reject nulls, same guarantee List.of gives you + } + } + + private DefensiveCopyRecordDemo() {} + + public static void main(String[] args) { + System.out.println("=== LeakyOrder: a record field that LOOKS immutable but isn't ==="); + List callerList = new ArrayList<>(List.of("widget", "gadget")); + LeakyOrder leaky = new LeakyOrder("ORD-1", callerList); + System.out.println("leaky.items() right after construction = " + leaky.items()); + callerList.add("SNEAKY EXTRA ITEM"); + System.out.println("caller mutates their OWN list reference afterwards: callerList.add(...)"); + System.out.println("leaky.items() now = " + leaky.items() + + " <- the record's field changed too, because it's the SAME list object"); + + System.out.println(); + System.out.println("=== LeakyOrder.items() also returns the live mutable reference - callers can mutate it directly ==="); + leaky.items().add("MUTATED THROUGH THE ACCESSOR"); + System.out.println("leaky.items() after leaky.items().add(...) = " + leaky.items()); + + System.out.println(); + System.out.println("=== SafeOrder: List.copyOf(...) in a compact constructor closes both holes ==="); + List callerList2 = new ArrayList<>(List.of("widget", "gadget")); + SafeOrder safe = new SafeOrder("ORD-2", callerList2); + System.out.println("safe.items() right after construction = " + safe.items()); + callerList2.add("SNEAKY EXTRA ITEM"); + System.out.println("caller mutates their OWN list reference afterwards: callerList2.add(...)"); + System.out.println("safe.items() now = " + safe.items() + " <- unchanged, it's an independent copy"); + + System.out.println(); + System.out.println("=== And the accessor's result rejects mutation too, since List.copyOf returns an immutable list ==="); + try { + safe.items().add("should not be possible"); + } catch (UnsupportedOperationException e) { + System.out.println("safe.items().add(...) threw UnsupportedOperationException"); + } + + System.out.println(); + System.out.println("=== The constructor itself still rejects null elements, same as List.of ==="); + try { + List withNull = new ArrayList<>(); + withNull.add("a"); + withNull.add(null); + new SafeOrder("ORD-3", withNull); + System.out.println("new SafeOrder(..., listContainingNull) succeeded (unexpected)"); + } catch (NullPointerException e) { + System.out.println("new SafeOrder(..., listContainingNull) threw NullPointerException from inside List.copyOf(...)"); + } + } +} diff --git a/immutable/src/main/java/com/ankurm/immutable/GuavaComparisonDemo.java b/immutable/src/main/java/com/ankurm/immutable/GuavaComparisonDemo.java new file mode 100644 index 0000000..1ef037a --- /dev/null +++ b/immutable/src/main/java/com/ankurm/immutable/GuavaComparisonDemo.java @@ -0,0 +1,54 @@ +package com.ankurm.immutable; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +/** + * Guava's {@code ImmutableList} predates {@code List.of} by roughly a decade (Guava's collections + * shipped around 2010; {@code List.of} landed in Java 9, 2017) and most of its behaviour now + * matches the JDK's own immutable lists - this demo exists to pin the two places it still doesn't. + */ +public final class GuavaComparisonDemo { + + private GuavaComparisonDemo() {} + + public static void main(String[] args) { + System.out.println("=== Both reject null, same as List.of ==="); + try { + ImmutableList.of("a", null, "c"); + System.out.println("ImmutableList.of(\"a\", null, \"c\") succeeded (unexpected)"); + } catch (NullPointerException e) { + System.out.println("ImmutableList.of(\"a\", null, \"c\") threw NullPointerException - same as List.of"); + } + + System.out.println(); + System.out.println("=== Guava's builder tolerates a size that grows past what you declared; List.of has no builder ==="); + ImmutableList built = ImmutableList.builder() + .add(1).add(2).add(3).add(4).add(5) + .build(); + System.out.println("ImmutableList.builder() result = " + built); + System.out.println("-> java.util has no equivalent builder; you either know all elements up front for List.of(...)"); + System.out.println(" or you build an ArrayList and call List.copyOf(...) at the end - this demo's RecordDemo does exactly that."); + + System.out.println(); + System.out.println("=== Element limit: List.of has none in practice; the old Arrays.asList(E...) varargs ceiling is long gone ==="); + Integer[] many = new Integer[5000]; + for (int i = 0; i < many.length; i++) many[i] = i; + List bigListOf = List.of(many); + System.out.println("List.of(... 5000 elements ...).size() = " + bigListOf.size() + + " (List.of(E...) switched to a varargs array long ago - the old 1-10 overloads are just JIT-friendlier for small sizes)"); + + System.out.println(); + System.out.println("=== Guava's ImmutableList also exposes a reverse() view and asList() - conceptually close to JEP 431's reversed() ==="); + ImmutableList reversed = built.reverse(); + System.out.println("built.reverse() = " + reversed + " (Guava's reverse() predates java.util's reversed() by over a decade)"); + + System.out.println(); + System.out.println("=== Where they genuinely differ: Guava's collections are NOT in java.base, so they ship as a real dependency ==="); + System.out.println("Adding Guava to a project to get ImmutableList today mostly buys you nothing List.of/List.copyOf"); + System.out.println("doesn't already give you for the collection types themselves - the builder pattern above is the"); + System.out.println("real remaining reason, plus ImmutableList/ImmutableMap/ImmutableSet having first-class equivalents"); + System.out.println("for ImmutableMultimap, ImmutableTable and other Guava-only collection types the JDK has no analog for."); + } +} diff --git a/immutable/src/main/java/com/ankurm/immutable/ImmutableOverheadBenchmark.java b/immutable/src/main/java/com/ankurm/immutable/ImmutableOverheadBenchmark.java new file mode 100644 index 0000000..23f543d --- /dev/null +++ b/immutable/src/main/java/com/ankurm/immutable/ImmutableOverheadBenchmark.java @@ -0,0 +1,105 @@ +package com.ankurm.immutable; + +import com.google.common.collect.ImmutableList; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Two questions: (1) does wrapping vs copying cost anything measurable at construction time, and + * (2) does reading through an unmodifiable wrapper cost anything measurable per call, versus + * reading the backing list directly or reading an immutable copy. Source for every number quoted + * in the post: {@code output/05-jmh-raw.txt}. + */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 2, time = 1) +@Measurement(iterations = 3, time = 1) +@Fork(2) +public class ImmutableOverheadBenchmark { + + @State(Scope.Benchmark) + public static class ConstructionState { + @Param({"10", "1000"}) + int size; + List source; + + @Setup(Level.Invocation) + public void setup() { + source = new ArrayList<>(size); + for (int i = 0; i < size; i++) source.add(i); + } + } + + @Benchmark + public List construct_unmodifiableListWrap(ConstructionState s) { + return Collections.unmodifiableList(s.source); + } + + @Benchmark + public List construct_listCopyOf(ConstructionState s) { + return List.copyOf(s.source); + } + + @Benchmark + public ImmutableList construct_guavaImmutableListCopyOf(ConstructionState s) { + return ImmutableList.copyOf(s.source); + } + + @State(Scope.Benchmark) + public static class ReadState { + @Param({"1000"}) + int size; + List plainArrayList; + List unmodifiableWrapper; + List immutableCopy; + + @Setup(Level.Trial) + public void setup() { + plainArrayList = new ArrayList<>(size); + for (int i = 0; i < size; i++) plainArrayList.add(i); + unmodifiableWrapper = Collections.unmodifiableList(plainArrayList); + immutableCopy = List.copyOf(plainArrayList); + } + } + + @Benchmark + public int read_plainArrayList_get(ReadState s) { + int sum = 0; + for (int i = 0; i < s.size; i++) sum += s.plainArrayList.get(i); + return sum; + } + + @Benchmark + public int read_unmodifiableWrapper_get(ReadState s) { + int sum = 0; + for (int i = 0; i < s.size; i++) sum += s.unmodifiableWrapper.get(i); + return sum; + } + + @Benchmark + public int read_immutableCopy_get(ReadState s) { + int sum = 0; + for (int i = 0; i < s.size; i++) sum += s.immutableCopy.get(i); + return sum; + } + + public static void consume(Blackhole bh, Object o) { + bh.consume(o); + } +} diff --git a/immutable/src/main/java/com/ankurm/immutable/NullHandlingDemo.java b/immutable/src/main/java/com/ankurm/immutable/NullHandlingDemo.java new file mode 100644 index 0000000..c85fa6d --- /dev/null +++ b/immutable/src/main/java/com/ankurm/immutable/NullHandlingDemo.java @@ -0,0 +1,68 @@ +package com.ankurm.immutable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The three factory families people reach for to build a "read-only-looking" list disagree about + * {@code null} in three different ways. This demo runs all three against the same question - + * "can this list contain a null element at all" - and prints the real exception (or lack of one) + * for each, rather than describing it from memory. + */ +public final class NullHandlingDemo { + + private NullHandlingDemo() {} + + public static void main(String[] args) { + System.out.println("=== List.of(...) - rejects null at construction time ==="); + try { + List l = List.of("a", null, "c"); + System.out.println("List.of(\"a\", null, \"c\") succeeded (unexpected): " + l); + } catch (NullPointerException e) { + System.out.println("List.of(\"a\", null, \"c\") threw NullPointerException"); + } + + System.out.println(); + System.out.println("=== Arrays.asList(...) - allows null, it's just a view over the array ==="); + List viaAsList = Arrays.asList("a", null, "c"); + System.out.println("Arrays.asList(\"a\", null, \"c\") = " + viaAsList); + + System.out.println(); + System.out.println("=== Collections.unmodifiableList(...) - allows whatever the backing list allows ==="); + ArrayList backing = new ArrayList<>(); + backing.add("a"); + backing.add(null); + backing.add("c"); + List unmodView = Collections.unmodifiableList(backing); + System.out.println("unmodifiableList over a list already containing null = " + unmodView); + System.out.println("(it has no null-check of its own - it only blocks structural writes, see below)"); + + System.out.println(); + System.out.println("=== List.copyOf(...) - rejects null, same as List.of ==="); + try { + List copy = List.copyOf(viaAsList); // viaAsList contains a null + System.out.println("List.copyOf(aListContainingNull) succeeded (unexpected): " + copy); + } catch (NullPointerException e) { + System.out.println("List.copyOf(aListContainingNull) threw NullPointerException"); + System.out.println("-> List.copyOf() re-validates elements, it does not just wrap and trust the source"); + } + + System.out.println(); + System.out.println("=== Collections.unmodifiableList is a VIEW, not a copy: mutating the backing list shows through ==="); + System.out.println("before: unmodView = " + unmodView); + backing.set(0, "A-CHANGED"); + backing.add("d"); + System.out.println("after backing.set(0,...) and backing.add(\"d\"): unmodView = " + unmodView); + System.out.println("-> the \"unmodifiable\" promise is about the VIEW's own mutator methods, not about the data being frozen"); + + System.out.println(); + System.out.println("=== unmodifiableList's own mutators still throw, even though the backing list is mutable ==="); + try { + unmodView.add("e"); + } catch (UnsupportedOperationException e) { + System.out.println("unmodView.add(\"e\") threw UnsupportedOperationException"); + } + } +} diff --git a/immutable/src/main/java/com/ankurm/immutable/ViewsVsCopiesDemo.java b/immutable/src/main/java/com/ankurm/immutable/ViewsVsCopiesDemo.java new file mode 100644 index 0000000..7435d88 --- /dev/null +++ b/immutable/src/main/java/com/ankurm/immutable/ViewsVsCopiesDemo.java @@ -0,0 +1,62 @@ +package com.ankurm.immutable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The single most important distinction between the three "read-only-looking" factories: + * {@code Collections.unmodifiableList} wraps the collection you gave it and keeps watching it, + * while {@code List.copyOf} takes a snapshot and never looks at the source again. Both refuse + * writes through themselves; only one of them actually freezes the data. + */ +public final class ViewsVsCopiesDemo { + + private ViewsVsCopiesDemo() {} + + public static void main(String[] args) { + System.out.println("=== unmodifiableList is a VIEW: changes to the backing list show through ==="); + ArrayList backing = new ArrayList<>(List.of("a", "b", "c")); + List view = Collections.unmodifiableList(backing); + System.out.println("backing = " + backing); + System.out.println("view = " + view); + backing.add("d"); + System.out.println("after backing.add(\"d\"):"); + System.out.println("backing = " + backing); + System.out.println("view = " + view + " <- changed, with no code touching 'view' directly"); + + System.out.println(); + System.out.println("=== List.copyOf is a SNAPSHOT: changes to the source do NOT show through ==="); + ArrayList source = new ArrayList<>(List.of("x", "y", "z")); + List copy = List.copyOf(source); + System.out.println("source = " + source); + System.out.println("copy = " + copy); + source.add("w"); + System.out.println("after source.add(\"w\"):"); + System.out.println("source = " + source); + System.out.println("copy = " + copy + " <- unchanged, it was a real copy at the moment copyOf() ran"); + + System.out.println(); + System.out.println("=== List.copyOf has a documented optimization: copying an already-immutable list is a no-op ==="); + List alreadyImmutable = List.of("p", "q"); + List copyOfImmutable = List.copyOf(alreadyImmutable); + System.out.println("List.copyOf(List.of(...)) returns the SAME instance: " + + (alreadyImmutable == copyOfImmutable)); + List mutableSource = new ArrayList<>(List.of("p", "q")); + List copyOfMutable = List.copyOf(mutableSource); + System.out.println("List.copyOf(new ArrayList<>(...)) returns a DIFFERENT instance: " + + (mutableSource != copyOfMutable)); + + System.out.println(); + System.out.println("=== Map and Set follow the same pattern as List ==="); + Map mutableMap = new HashMap<>(); + mutableMap.put("one", 1); + Map unmodMapView = Collections.unmodifiableMap(mutableMap); + Map mapCopy = Map.copyOf(mutableMap); + mutableMap.put("two", 2); + System.out.println("unmodMapView after mutating backing map = " + unmodMapView + " (view: changed)"); + System.out.println("mapCopy after mutating the ORIGINAL map = " + mapCopy + " (copy: unchanged)"); + } +} diff --git a/immutable/src/test/java/com/ankurm/immutable/ImmutableCollectionsTest.java b/immutable/src/test/java/com/ankurm/immutable/ImmutableCollectionsTest.java new file mode 100644 index 0000000..d6114d0 --- /dev/null +++ b/immutable/src/test/java/com/ankurm/immutable/ImmutableCollectionsTest.java @@ -0,0 +1,108 @@ +package com.ankurm.immutable; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ImmutableCollectionsTest { + + @Test + void listOfRejectsNull() { + assertThrows(NullPointerException.class, () -> List.of("a", null, "c")); + } + + @Test + void arraysAsListAllowsNullBecauseItIsJustAnArrayView() { + List l = Arrays.asList("a", null, "c"); + assertEquals(null, l.get(1)); + } + + @Test + void unmodifiableListHasNoNullCheckOfItsOwn() { + ArrayList backing = new ArrayList<>(); + backing.add("a"); + backing.add(null); + List view = Collections.unmodifiableList(backing); + assertEquals(null, view.get(1)); + } + + @Test + void listCopyOfRevalidatesAndRejectsNull() { + List withNull = Arrays.asList("a", null, "c"); + assertThrows(NullPointerException.class, () -> List.copyOf(withNull)); + } + + @Test + void unmodifiableListIsALiveViewOverTheBackingList() { + ArrayList backing = new ArrayList<>(List.of("a", "b")); + List view = Collections.unmodifiableList(backing); + backing.add("c"); + assertEquals(List.of("a", "b", "c"), view, "the view must see the backing list's mutation"); + } + + @Test + void listCopyOfIsAnIndependentSnapshot() { + ArrayList source = new ArrayList<>(List.of("x", "y")); + List copy = List.copyOf(source); + source.add("z"); + assertEquals(List.of("x", "y"), copy, "the copy must NOT see the source's later mutation"); + } + + @Test + void listCopyOfAnAlreadyImmutableListIsANoOp() { + List imm = List.of("p", "q"); + assertSame(imm, List.copyOf(imm), "List.copyOf must return the same instance for an already-immutable source"); + } + + @Test + void listCopyOfAMutableListAllocatesANewInstance() { + List mutable = new ArrayList<>(List.of("p", "q")); + assertFalse(mutable == List.copyOf(mutable)); + } + + @Test + void bothUnmodifiableViewAndImmutableCopyRejectDirectMutation() { + List view = Collections.unmodifiableList(new ArrayList<>(List.of("a"))); + List copy = List.copyOf(List.of("a")); + assertThrows(UnsupportedOperationException.class, () -> view.add("b")); + assertThrows(UnsupportedOperationException.class, () -> copy.add("b")); + } + + @Test + void guavaImmutableListAlsoRejectsNull() { + assertThrows(NullPointerException.class, () -> ImmutableList.of("a", null)); + } + + @Test + void recordWithoutCompactConstructorLeaksTheBackingList() { + List callerList = new ArrayList<>(List.of("a", "b")); + record LeakyOrder(String id, List items) {} + LeakyOrder leaky = new LeakyOrder("ORD-1", callerList); + callerList.add("LEAKED"); + assertTrue(leaky.items().contains("LEAKED"), "without a defensive copy the record sees the caller's later mutation"); + } + + @Test + void recordWithListCopyOfInCompactConstructorIsSafe() { + List callerList = new ArrayList<>(List.of("a", "b")); + record SafeOrder(String id, List items) { + SafeOrder { + items = List.copyOf(items); + } + } + SafeOrder safe = new SafeOrder("ORD-2", callerList); + callerList.add("SHOULD NOT LEAK"); + assertFalse(safe.items().contains("SHOULD NOT LEAK"), "List.copyOf in the compact constructor must snapshot"); + assertThrows(UnsupportedOperationException.class, () -> safe.items().add("x")); + } +} diff --git a/pom.xml b/pom.xml index 0919ad3..1bef3c1 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ nio2 list-benchmarks sequenced + immutable