skip to content
everything.collections.immutableCollections

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.

Main.java next to run
  1. 1void main() {
  2. 2 List<String> src = new ArrayList<>(List.of("a"));
  3. 3 List<String> view = Collections.unmodifiableList(src);
  4. 4 List<String> copy = List.copyOf(src);
  5. 5 src.add("b");
  6. 6 IO.println(view + " " + copy);
  7. 7 view.add("c");
  8. 8}
stdout
·
stack
main
no locals yet
heap
nothing allocated yet.

three lists are about to be made. only one of them actually owns its elements.

step 1/7
step through it. the view is one tiny object pointing at 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 byadd(x)set(0, x)remove(0)sort(…)null insidecontains(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
click any cell
var list = List.of("a", "b"); list.add("c") // → UnsupportedOperationException immutable. truly immutable, and allergic to null: it won't store one, and contains(null) throws instead of returning false.
click a cell to see the code and what happens. ok means no exception. UOE is UnsupportedOperationException, NPE is NullPointerException.

the useful rules of thumb:

  • List.of, Set.of, Map.of java 9+: compact, truly immutable, no nulls anywhere. Set.of and Map.of also 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. if c is already an immutable list, it returns c itself, no copy.
  • stream.toList() java 16+: unmodifiable, but allows nulls. prefer it to collect(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 a List is expected, not for building a list.
predict the output
java
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.

Main.java
void main() {
    var sb = new StringBuilder("hi");
    List<StringBuilder> frozen = List.of(sb);
    sb.append("!");
    frozen.get(0).append("?");
    IO.println(frozen);
}
output
[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.

keylines
  • 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.asList is fixed-size, not read-only: set writes into the array.
  • List.of and friends reject null, even in contains.
  • immutable collections are shallow. mutable elements can still change.