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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 <ClassName>` 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<String>` 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).
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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(...)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>java-core-examples</artifactId>
|
||||
<version>1.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>immutable</artifactId>
|
||||
<name>immutable</name>
|
||||
<description>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.</description>
|
||||
|
||||
<properties>
|
||||
<jmh.version>1.37</jmh.version>
|
||||
<guava.version>33.7.2-jre</guava.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>${guava.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjdk.jmh</groupId>
|
||||
<artifactId>jmh-core</artifactId>
|
||||
<version>${jmh.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjdk.jmh</groupId>
|
||||
<artifactId>jmh-generator-annprocess</artifactId>
|
||||
<version>${jmh.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>benchmarks</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>25</release>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.openjdk.jmh</groupId>
|
||||
<artifactId>jmh-generator-annprocess</artifactId>
|
||||
<version>${jmh.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>org.openjdk.jmh.Main</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Executable
+24
@@ -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."
|
||||
Executable
+7
@@ -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"
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
private DefensiveCopyRecordDemo() {}
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("=== LeakyOrder: a record field that LOOKS immutable but isn't ===");
|
||||
List<String> 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<String> 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<String> 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(...)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Integer> built = ImmutableList.<Integer>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<Integer> 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<Integer> 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.");
|
||||
}
|
||||
}
|
||||
@@ -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<Integer> source;
|
||||
|
||||
@Setup(Level.Invocation)
|
||||
public void setup() {
|
||||
source = new ArrayList<>(size);
|
||||
for (int i = 0; i < size; i++) source.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
@Benchmark
|
||||
public List<Integer> construct_unmodifiableListWrap(ConstructionState s) {
|
||||
return Collections.unmodifiableList(s.source);
|
||||
}
|
||||
|
||||
@Benchmark
|
||||
public List<Integer> construct_listCopyOf(ConstructionState s) {
|
||||
return List.copyOf(s.source);
|
||||
}
|
||||
|
||||
@Benchmark
|
||||
public ImmutableList<Integer> construct_guavaImmutableListCopyOf(ConstructionState s) {
|
||||
return ImmutableList.copyOf(s.source);
|
||||
}
|
||||
|
||||
@State(Scope.Benchmark)
|
||||
public static class ReadState {
|
||||
@Param({"1000"})
|
||||
int size;
|
||||
List<Integer> plainArrayList;
|
||||
List<Integer> unmodifiableWrapper;
|
||||
List<Integer> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<String> backing = new ArrayList<>();
|
||||
backing.add("a");
|
||||
backing.add(null);
|
||||
backing.add("c");
|
||||
List<String> 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<String> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> backing = new ArrayList<>(List.of("a", "b", "c"));
|
||||
List<String> 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<String> source = new ArrayList<>(List.of("x", "y", "z"));
|
||||
List<String> 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<String> alreadyImmutable = List.of("p", "q");
|
||||
List<String> copyOfImmutable = List.copyOf(alreadyImmutable);
|
||||
System.out.println("List.copyOf(List.of(...)) returns the SAME instance: "
|
||||
+ (alreadyImmutable == copyOfImmutable));
|
||||
List<String> mutableSource = new ArrayList<>(List.of("p", "q"));
|
||||
List<String> 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<String, Integer> mutableMap = new HashMap<>();
|
||||
mutableMap.put("one", 1);
|
||||
Map<String, Integer> unmodMapView = Collections.unmodifiableMap(mutableMap);
|
||||
Map<String, Integer> 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)");
|
||||
}
|
||||
}
|
||||
@@ -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<String> l = Arrays.asList("a", null, "c");
|
||||
assertEquals(null, l.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmodifiableListHasNoNullCheckOfItsOwn() {
|
||||
ArrayList<String> backing = new ArrayList<>();
|
||||
backing.add("a");
|
||||
backing.add(null);
|
||||
List<String> view = Collections.unmodifiableList(backing);
|
||||
assertEquals(null, view.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listCopyOfRevalidatesAndRejectsNull() {
|
||||
List<String> withNull = Arrays.asList("a", null, "c");
|
||||
assertThrows(NullPointerException.class, () -> List.copyOf(withNull));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmodifiableListIsALiveViewOverTheBackingList() {
|
||||
ArrayList<String> backing = new ArrayList<>(List.of("a", "b"));
|
||||
List<String> 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<String> source = new ArrayList<>(List.of("x", "y"));
|
||||
List<String> 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<String> 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<String> mutable = new ArrayList<>(List.of("p", "q"));
|
||||
assertFalse(mutable == List.copyOf(mutable));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothUnmodifiableViewAndImmutableCopyRejectDirectMutation() {
|
||||
List<String> view = Collections.unmodifiableList(new ArrayList<>(List.of("a")));
|
||||
List<String> 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<String> callerList = new ArrayList<>(List.of("a", "b"));
|
||||
record LeakyOrder(String id, List<String> 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<String> callerList = new ArrayList<>(List.of("a", "b"));
|
||||
record SafeOrder(String id, List<String> 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user