Unveränderliche Klassen in Java
Unveränderliche Klassen in Java mit final-Feldern, defensiven Kopien und ohne Setter entwerfen.
Eine unveränderliche Klasse ist eine, deren Instanzen nach der Konstruktion nicht mehr verändert werden können. String, Integer, LocalDate, BigDecimal, UUID — Javas Standardbibliothek ist voll davon, und das nicht zufällig. Unveränderliche Objekte sind sicher thread-übergreifend zu teilen, sicher als HashMap-Schlüssel zu verwenden, sicher zu cachen und leicht zu verstehen: Sobald man eines gesehen hat, kennt man seinen Zustand für den Rest seines Lebens.
Eine Klasse unveränderlich zu machen ist nicht eine Frage eines einzelnen Schlüsselworts — es geht darum, eine Handvoll Regeln gemeinsam zu befolgen. Wird eine davon vergessen, erhält man eine Klasse, die unveränderlich aussieht, es aber nicht ist.
Die fünf Regeln
Um eine Klasse wirklich unveränderlich zu machen:
- Die Klasse als
finaldeklarieren (oder nur private Konstruktoren verwenden). Andernfalls kann eine Unterklasse den Vertrag brechen. - Jedes Feld
private finalmachen.finalverhindert eine erneute Zuweisung nach der Konstruktion;privateverhindert, dass Aufrufer direkt darauf zugreifen. - Keine Setter bereitstellen. Jede Mutationsmethode (
add,set,clear,reset) ist ausgeschlossen. - Veränderliche Eingaben im Konstruktor defensiv kopieren. Wenn der Aufrufer ein
Dateoder eineListübergibt, muss eine Kopie erstellt werden — andernfalls kann er sie von außen verändern und das "unveränderliche" Objekt ändert sich unbemerkt. - Veränderliche Rückgaben in Gettern defensiv kopieren — aus dem gleichen Grund in umgekehrter Richtung.
Eine Klasse, die alle fünf Regeln erfüllt, ist tief unveränderlich. Wird auch nur eine davon vernachlässigt, wird die Garantie aufgebrochen.
final allein ist keine Unveränderlichkeit. Ein final-Feld kann nicht neu zugewiesen werden, aber wenn es auf ein veränderliches Objekt zeigt — eine List, ein Array, ein Date — kann dieses Objekt sich dennoch ändern. final List<String> tags bedeutet, dass die Liste nicht gegen eine andere ausgetauscht werden kann, nicht dass der Inhalt der Liste eingefroren ist. Die Regeln 4 und 5 existieren genau, um diese Lücke zu schließen. Siehe Java final keyword für das, was final verspricht und was nicht.
Das minimale Beispiel
Für eine Klasse, deren Felder alle primitiv oder bereits unveränderlich sind, reduzieren sich die Regeln auf nahezu nichts:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
}int ist ein primitiver Typ, daher gibt es nichts defensiv zu kopieren. Die Klasse ist final, die Felder sind private final, es gibt keine Setter. Fertig.
Veränderliche Felder benötigen defensive Kopien
Das Problem beginnt, wenn ein Feld selbst veränderlich ist — ein Array, ein Date, eine ArrayList. Wenn die Referenz des Aufrufers direkt gespeichert wird, behält er einen Zugriff darauf und kann die internen Daten verändern:
// Broken: the array is shared
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) { this.points = points; }
public double[] points() { return points; }
}
double[] arr = {1.0, 2.0, 3.0};
Trajectory t = new Trajectory(arr);
arr[0] = 999; // mutates the "immutable" object!
System.out.println(t.points()[0]); // 999Die Lösung besteht darin, sowohl beim Eingang als auch beim Ausgang zu kopieren:
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) {
this.points = points.clone(); // copy in
}
public double[] points() {
return points.clone(); // copy out
}
}Bei Collections ist das Äquivalent List.copyOf(other) (gibt eine unveränderliche Liste zurück, die durch eine Kopie gesichert ist):
public final class Recipe {
private final String name;
private final List<String> steps;
public Recipe(String name, List<String> steps) {
this.name = name;
this.steps = List.copyOf(steps); // copy + unmodifiable view
}
public List<String> steps() { return steps; } // already unmodifiable
}Zu beachten ist die Asymmetrie zum Array-Beispiel: clone() eines Arrays erzeugt eine veränderliche Kopie, daher muss beim Ausgang erneut kopiert werden. List.copyOf erzeugt eine unveränderliche Liste, sodass der Getter sie direkt zurückgeben kann — jeder Aufrufer, der versucht, sie zu mutieren, erhält eine UnsupportedOperationException. Unveränderliche Collection-Typen sollten bevorzugt werden, wann immer möglich; sie eliminieren eine ganze Klasse von Kopierfehlern beim Ausgang.
„Änderungen" geben neue Instanzen zurück
Eine unveränderliche Klasse kann dennoch Änderungen unterstützen — indem sie eine neue Instanz zurückgibt:
public final class Money {
private final long cents;
public Money plus(Money other) { return new Money(cents + other.cents); }
public Money times(int factor) { return new Money(cents * factor); }
// constructor + accessors omitted
}Konventionell heißt die Methode with..., wenn sie eine Kopie mit einem geänderten Feld erzeugt: point.withX(5), user.withEmail("..."). Die Java-Datums-/Uhrzeit-API verwendet dieses Muster konsequent — LocalDate.plusDays(7), LocalDate.withYear(2026).
Warum das wichtig ist
Unveränderliche Objekte bieten:
- Thread-Sicherheit ohne Aufwand. Keine Sperren, kein
volatile, keine Sichtbarkeitsüberraschungen — es gibt nichts zu synchronisieren, weil sich der Zustand nicht ändern kann. - Sicheres Teilen und Cachen. Zwei Aufrufer, die dasselbe
Money(2000, "USD")halten, können sich nicht gegenseitig beeinflussen. - Zuverlässige Hash-Schlüssel. Da sich die in
hashCodeverwendeten Felder nicht ändern können, wird der Bucket des Objekts nie veraltet. Ein veränderlicher Schlüssel, dessen Hash sich nach der Speicherung in einerHashMapändert, ist praktisch verloren — siehe Java equals and hashCode. - Einfacheres Verstehen. Sobald man ein unveränderliches Objekt gesehen hat, weiß man, was es für den Rest seines Lebens tun wird. Keine „Wo wurde das mutiert?"-Archäologie.
Der Preis ist die Allokation neuer Instanzen für jede „Änderung". Bei kleinen, häufig verwendeten Objekten (String, Integer) ist das selten ein Problem; die JVM ist sehr gut bei kurzlebigen Allokationen. Für wirklich aufwändige Fälle gibt es spezifische Techniken (String-Builder, persistente Datenstrukturen) — aber diese sollten nur dann eingesetzt werden, wenn das Profiling ein echtes Problem aufzeigt.
Records erledigen den größten Teil der Arbeit
Ein record ist implizit final, hat private final-Felder, generiert Accessors ohne Setter und bietet equals/hashCode/toString kostenlos:
public record Point(int x, int y) {}Das ist tief unveränderlich, solange die Komponenten selbst unveränderlich sind. Bei Records, die eine veränderliche Komponente (eine List, ein Array) enthalten, wird dennoch ein kompakter Konstruktor benötigt, der defensiv kopiert:
public record Recipe(String name, List<String> steps) {
public Recipe {
steps = List.copyOf(steps);
}
}Wenn Records passen, sind sie der kürzeste Weg zu einer korrekten unveränderlichen Klasse.
Ein ausgearbeitetes Beispiel
Wie es weitergeht
Unveränderliche Klassen handeln von der Kontrolle über Änderungen. Das letzte Kapitel von Teil 6 handelt von der Kontrolle über die Anzahl — eine Klasse, die so gestaltet ist, dass immer nur eine Instanz existiert. Weiter zu Java singleton pattern.