immutable collections
List.of, copyOf, and unmodifiable views that aren't actually immutable.
- chapter
- chapter 06.09
- reading time
- 6 min read
java has three different things people call "immutable": truly immutable lists (List.of, List.copyOf), read-only views (Collections.unmodifiableList) that still change when the original does, and fixed-size lists (Arrays.asList) you can't grow but can overwrite.
they all have the same type, List. the difference only shows up at runtime, as an UnsupportedOperationException.
the picture
you know how you hand someone a list "read-only" and then it changes anyway? because you gave them a window, not a photo.
an unmodifiable view is a window into your list: they can look but not touch, and they see everything you do on your side. an immutable copy is a photo: frozen at the moment you took it. both block their holder's writes. only one of them stays still.
- 1void main() {
- 2 List<String> src = new ArrayList<>(List.of("a"));
- 3 List<String> view = Collections.unmodifiableList(src);
- 4 List<String> copy = List.copyOf(src);
- 5 src.add("b");
- 6 IO.println(view + " " + copy);
- 7 view.add("c");
- 8}
·three lists are about to be made. only one of them actually owns its elements.
src. the copy has its own array of references (to the same strings). after src.add("b"), the view shows [a, b] and the copy still says [a]. the last step is the exception.so Collections.unmodifiableList(list) is the right tool when you keep the only reference to list and want to hand out a read-only window (a getter on a class, for instance). it's the wrong tool when you want a snapshot: then you want List.copyOf.
the zoo
seven ways to get a List, seven different rule sets. every cell here was run on jdk 25:
| list made by | add(x) | set(0, x) | remove(0) | sort(…) | null inside | contains(null) | sees src changes |
|---|---|---|---|---|---|---|---|
| new ArrayList<>(…) | · | ||||||
| List.of(…) | · | ||||||
| List.copyOf(src) | no | ||||||
| Collections.unmodifiableList(src) | yes | ||||||
| Arrays.asList(…) | yes | ||||||
| stream.toList() | no | ||||||
| collect(Collectors.toList()) | no |
UnsupportedOperationException, NPE is NullPointerException.the useful rules of thumb:
List.of,Set.of,Map.ofjava 9+: compact, truly immutable, nonulls anywhere.Set.ofandMap.ofalso reject duplicates at creation (IllegalArgumentException), and their iteration order is deliberately shuffled per JVM run (so nobody accidentally depends on it).List.copyOf(c)java 10+: an immutable snapshot of anything. ifcis already an immutable list, it returnscitself, no copy.stream.toList()java 16+: unmodifiable, but allowsnulls. prefer it tocollect(Collectors.toList()), which is mutable today without promising to be.Collections.unmodifiableList(l): a read-only view of a list you keep to yourself.Arrays.asList(arr): a fixed-size list backed by the array. good for passing an array where aListis expected, not for building a list.
String[] arr = {"a", "b", "c"};
List<String> list = Arrays.asList(arr);
list.set(0, "z");
IO.println(arr[0]);immutable isn't deep
an immutable list stops you from changing which objects it holds. it does nothing about the objects themselves.
void main() {
var sb = new StringBuilder("hi");
List<StringBuilder> frozen = List.of(sb);
sb.append("!");
frozen.get(0).append("?");
IO.println(frozen);
}[hi!?]
frozen still holds exactly one reference, to the same StringBuilder, and that object changed twice. for a truly immutable structure, the elements must be immutable too: String, Integer, records of immutable fields. more on that in immutability.
why not just a read-only interface?
the obvious question: why doesn't java have an ImmutableList type, so the compiler can stop add calls? the collections framework chose the opposite in 1998: one List interface, and "optional operations" that an implementation may refuse at runtime. the authors' reasoning (in the collections design FAQ) was that splitting every interface into modifiable / unmodifiable / fixed-size / append-only variants would explode the number of types. the cost is that List<String> l tells you nothing about whether l.add will work. libraries like Guava add ImmutableList as a class type, which at least documents intent in signatures.
- a view (
unmodifiableList) blocks writes through itself. the original can still change it. - a copy (
List.copyOf,List.of) is frozen. later changes to the source don't show up. Arrays.asListis fixed-size, not read-only:setwrites into the array.List.ofand friends rejectnull, even incontains.- immutable collections are shallow. mutable elements can still change.