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:
@@ -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).
|
||||
Reference in New Issue
Block a user