Java Unmodifiable Collections
Unveränderliche Collections in Java mit List.of, Set.of, Map.of und den Collections.unmodifiable*-Wrappern erstellen.
Eine veränderliche Collection erlaubt es jedem mit einer Referenz, ihren Inhalt zu ändern. Eine unveränderliche Collection hingegen nicht — der Aufruf von add, remove, put, clear oder set löst eine UnsupportedOperationException aus. Java bietet zwei sich ergänzende Wege, eine solche zu erstellen: die .of(...)-Factories, die in Java 9 eingeführt wurden (List.of, Set.of, Map.of, Map.ofEntries), und die älteren Collections.unmodifiable*-Wrapper. Sie sehen an der Aufrufstelle ähnlich aus, verhalten sich jedoch in zwei wichtigen Punkten unterschiedlich — das richtige Werkzeug hängt davon ab, was man tatsächlich benötigt.
Dieses Kapitel schließt den Collections-Framework-Teil ab und bietet ein klares, modernes Rezept für „gib mir eine Konstante" und „gib mir einen Schnappschuss".
Warum Unveränderlichkeit
Drei konkrete Vorteile, die das Muster rechtfertigen:
- Sicheres Teilen. Übergibt man eine unveränderliche Liste an einen Konstruktor, einen Worker-Thread oder einen Event-Consumer, muss man sich keine Sorgen machen, dass diese den eigenen Zustand verändern. Der statische Typ sagt nicht „schreibgeschützt", aber der Laufzeittyp schon.
- Sicher hashbar. Eine veränderliche
Listin einHashSetzu legen ist ein Fehler — ändert sich der Inhalt der Liste, ändert sich ihrhashCode, und das Set verliert das Element. Unveränderliche Collections umgehen dieses Problem vollständig. - Besseres API-Design. Eine unveränderliche View aus einem Getter zurückzugeben signalisiert: „Das gehört mir — lies es, ändere es nicht." Ohne das muss jeder Aufrufer selbst entscheiden, ob er eine defensive Kopie anlegt.
Die zwei Strategien
List.of, Set.of, Map.of, Map.ofEntries — echte unveränderliche Collections
In Java 9 hinzugefügt. Sie erzeugen eine neue Collection mit eigenem internen Speicher. Nichts anderes hat eine Referenz darauf:
List<String> roles = List.of("admin", "editor", "viewer");
Set<Integer> primes = Set.of(2, 3, 5, 7, 11);
Map<String, Integer> ages = Map.of("alice", 30, "bob", 25);
Map<String, Integer> many = Map.ofEntries(
Map.entry("alice", 30),
Map.entry("bob", 25),
Map.entry("carol", 28)
);Diese eignen sich für Konstanten und Literale — kleine feste Collections, die man direkt in den Code schreibt. Der JIT-Compiler kompiliert sie zu sehr kompakten, ressourcenschonenden Darstellungen (oft ein einzelnes Inline-Array). Die Kosten beschränken sich auf die Allokation des Literals selbst.
Drei Einschränkungen, die man beachten sollte:
- Keine
null-Elemente, keinenull-Schlüssel, keinenull-Werte.List.of("a", null)löst beim Erzeugen eineNullPointerExceptionaus. Soll „nicht vorhanden" repräsentiert werden, nutzt manOptionaloder lässt den Schlüssel in der Map weg. - Keine Duplikate bei
Set.ofundMap.of.Set.of("a", "a")löst eineIllegalArgumentExceptionaus. Sie sind für Literaldaten gedacht, die man selbst kontrolliert. Map.ofhat Overloads nur bis zu 10 Einträgen. Bei 11 oder mehr nutzt manMap.ofEntries(Map.entry(...), Map.entry(...), ...).
Collections.unmodifiableList(coll) usw. — Views einer bestehenden Collection
Umhüllt eine Collection in eine schreibgeschützte View. Das Original bleibt veränderlich, und Änderungen über das Original sind durch die View sichtbar:
List<String> mutable = new ArrayList<>(List.of("a", "b", "c"));
List<String> view = Collections.unmodifiableList(mutable);
view.add("d"); // throws UnsupportedOperationException
mutable.add("d"); // legal — and the view sees the change
System.out.println(view); // [a, b, c, d]Diese Strategie eignet sich, wenn man eine interne Collection exponieren möchte, ohne sie zu kopieren und ohne Aufrufern die Erlaubnis zur Mutation zu geben. Das klassische Muster ist ein Getter:
public List<String> getNames() {
return Collections.unmodifiableList(this.names);
}Der Aufrufer kann this.names über die zurückgegebene View nicht ändern. Man selbst kann es. Soll auch das verhindert werden, kopiert man:
return List.copyOf(this.names);…was die dritte Strategie ist.
List.copyOf, Set.copyOf, Map.copyOf — Schnappschuss, dann einfrieren
Eine Kurzform für „kopiere den aktuellen Inhalt in eine neue unveränderliche Collection":
List<String> snapshot = List.copyOf(mutable);Nach diesem Aufruf ist snapshot vollständig unabhängig von mutable. Spätere Änderungen an mutable sind durch snapshot nicht sichtbar. Es gibt auch eine clevere Optimierung: Ist die Quelle bereits eine unveränderliche Collection, die durch List.of / List.copyOf erzeugt wurde, gibt der Aufruf die Quelle selbst zurück — ohne Allokation.
copyOf lehnt null-Elemente ab, genau wie of. Enthält die Quelle möglicherweise null, verwendet man stattdessen Collections.unmodifiableList(new ArrayList<>(source)).
Die drei Muster im Überblick
| Muster | Unabhängig von der Quelle? | Erlaubt null? | Verwenden wenn |
|---|---|---|---|
List.of("a", "b") | Nicht anwendbar (keine Quelle) | Nein | Literal-Konstanten |
List.copyOf(source) | Ja — eigener Speicher | Nein | Schnappschuss zu einem Zeitpunkt |
Collections.unmodifiableList(source) | Nein — View | Ja | Internen Zustand schreibgeschützt exponieren |
Wenn die Aufrufstelle bedeutet „Sind das buchstäblich hart kodierte Daten?", greift man zu of. Wenn sie bedeutet „Ich will einen eingefrorenen Schnappschuss des aktuellen Zustands", greift man zu copyOf. Wenn sie bedeutet „Ich will, dass der aktuelle Inhalt beobachtbar, aber über diese Referenz nicht änderbar ist", greift man zu unmodifiableList.
Flach, nicht tief
Alle drei Strategien sind flach — sie frieren die Struktur der Collection ein, nicht die darin enthaltenen Elemente.
List<int[]> arrays = List.of(new int[]{1, 2}, new int[]{3, 4});
arrays.add(new int[]{5}); // UnsupportedOperationException
arrays.get(0)[0] = 99; // OK — and now the list contains {99, 2}Soll tiefe Unveränderlichkeit erreicht werden, müssen Elementtypen gewählt werden, die selbst unveränderlich sind. Records mit primitiven oder String-Feldern sind das. Records mit veränderlichen Feldern nicht. Dies entspricht dem gleichen Vorbehalt, der für final-Referenzen gilt: Die Bindung ist fest, das Ziel muss es nicht sein.
Set.of und Map.of haben unspezifizierte Iterationsreihenfolge
Zwei absichtliche Designentscheidungen überraschen Menschen:
Set.ofundMap.ofrandomisieren die Iterationsreihenfolge zwischen Ausführungen derselben JVM absichtlich. Schreibt man Code, der auf einer bestimmten Reihenfolge aus diesen Collections basiert, wird man instabile Tests erleben. Man verwendetList.of(das die Literalreihenfolge beibehält) oder einLinkedHashSet/LinkedHashMap, das mitCollections.unmodifiable*umhüllt ist, wenn tatsächlich Reihenfolge benötigt wird.Set.of(a, b)undSet.of(b, a)können selbst im selben Lauf unterschiedlich iterieren, wenn die Werte unterschiedliche Hashcodes haben. Nicht pertoStringvergleichen.
Dies ist so beabsichtigt — Java verhindert, dass man versehentlich von der Reihenfolge abhängig wird, damit die Implementierung sie frei ändern kann.
Was Unveränderlichkeit nicht bietet
- Sie ist nicht thread-sicher bezüglich Lesezugriffen auf veränderliche Elementfelder. Sind die Elemente veränderlich und ein anderer Thread ändert sie, ist trotzdem Synchronisation erforderlich.
- Sie macht die zugrundeliegende Collection nicht thread-sicher.
Collections.unmodifiableList(arrayList)ist eine View einer nicht-thread-sicheren Liste; fügt ein anderer Thread derarrayListein Element hinzu, kann der Lesezugriff durch die View einen inkonsistenten Zustand sehen. Für thread-sichere Unveränderlichkeit istList.copyOf(oderList.of) das richtige Werkzeug — sie haben privaten Speicher. - Sie macht
.equalsnicht reihenfolgeunabhängig. EineList, die vonList.ofzurückgegeben wird, ist immer noch positionsbasiert gleich zu anderen Listen, nicht inhaltsbasiert.
Ein ausgearbeitetes Beispiel: Literale, Schnappschüsse, Views und die Shallow-Falle
Das folgende Programm zeigt alle drei Strategien nebeneinander, demonstriert die Überraschung „View sieht Mutationen", die Garantie „Kopie ist unabhängig" und die Shallow-Falle, die jeden beim ersten Mal erwischt.
Was man dem Lauf entnehmen kann:
List.ofundList.copyOferzeugen beide eine echte unveränderliche Collection — sie lehnen alle Mutationen ab. Sie unterscheiden sich nur darin, ob die Daten direkt angegeben oder aus einer anderen Quelle kopiert wurden.- Die
Collections.unmodifiableList-View hatview.addabgelehnt, aberbacking.addüber die ursprüngliche Referenz akzeptiert. Änderungen über die Backing-Liste wurden durch die View sichtbar. Das ist das definierende Merkmal einer View und der Grund, warum diese Strategie in nicht vertrautem Code kein Ersatz fürcopyOfist. - Die Shallow-Falle ist real: Die
int[]-Elemente einer unveränderlichenList<int[]>sind selbst veränderlich, und das Bearbeiten eines Elements überschreibt die „eingefrorene" Liste. Soll tiefe Unveränderlichkeit erreicht werden, müssen die Elemente bereits unveränderlich sein. Set.ofhat das Duplikat abgelehnt, undMap.ofhat dennull-Wert abgelehnt — beide bei der Konstruktion. Diese Collections scheitern schnell und lautstark; das ist ein Feature.List.copyOfeiner bereits unveränderlichen Liste gab dieselbe Instanz zurück, ohne zu allokieren. Das ist die Optimierung des JDK — und der Grund, warum „beim Herausgeben immer kopieren" günstig ist, wenn die Quelle bereits unveränderlich ist.
Was kommt als Nächstes — und weiter zu Teil 12
Damit schließt der Teil Collections Framework. Nun kennt man jede Implementierung (ArrayList, LinkedList, HashMap, TreeMap, die Queues, die Deques und den Rest), jedes Interface (Collection, List, Set, Map, Queue, Deque), die Iterationscursor (Iterator, ListIterator), die Ordnungsinterfaces (Comparable, Comparator), den statischen Werkzeugkasten (Collections) und das Thema Unveränderlichkeit.
Der nächste Teil — Functional Programming — wechselt die Perspektive. Statt wie Daten gespeichert werden behandelt er wie Transformationen auf Daten ausgedrückt werden. Das erste Kapitel, Functional Programming in Java, stellt das mentale Modell vor: Funktionen als Werte, Unveränderlichkeit, reine Funktionen und Komposition. Von dort aus baut der Teil Lambdas, Methodenreferenzen, die eingebauten Funktionsinterfaces (Function, Predicate, Consumer, Supplier), Optional und Streams auf — die die eben erlernten Collections als Quelle und Ziel nutzen.
Die meisten Muster in diesem Teil — list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), stream().filter(...).toList() — sind bereits funktional geprägt. Teil 12 macht diesen Charakter explizit und zeigt, wie man ihn für alles andere einsetzen kann.