RU | EN | DE

Was ist ein „unveränderliches Objekt“ und wofür wird es benötigt?

Unveränderliches Objekt ist ein Objekt, dessen Zustand nach der Konstruktion nicht verändert werden kann. Vorteile:

  • Standardmäßig thread-sicher (ohne Synchronisierung).
  • Einfache Invarianz: Wenn ein Objekt im Konstruktor gültig ist, ist es immer gültig.
  • Sichere gemeinsame Nutzung/Caching (kann frei zwischen Threads übertragen und in Caches gelegt werden).
  • Praktisch als Schlüssel in Map/Elemente in Set (konsistenter hashCode).
  • Einfacher zu testen und zu verstehen.

Regeln für die Erstellung eines unveränderlichen Klassens

  1. Mache die Klasse final (oder alle Felder private final + Vererbung logisch verbieten).
  2. Alle Felder sind private final.
  3. Initialisierung nur im Konstruktor (keine Setter danach).
  4. Vermeide this-Referenzen aus dem Konstruktor (keine Übergabe eines Links auf sich selbst, bevor die Initialisierung abgeschlossen ist).
  5. Defensive Kopie für:
  • Eingangswerte: veränderliche Argumente (Arrays, Collections, Date, alle mutable Typen);
  • Rückgabewerte: Getter sollten keine “lebenden” internen Referenzen zurückgeben.
  1. Tiefe Unveränderlichkeit: Wenn ein Feld eine Collection/Array/Objekt ist, sorge für eine tiefe Schutzmaßnahmen (siehe unten).

Beispiel (korrekt)

import java.util.List;
import java.util.Objects;
public final class Money {
  private final String currency;  // unveränderlicher Typ (String ist unveränderlich)
  private final long minorUnits;  // Pfennige/Cent
  private final List<Integer> tags;  // BEISPIEL: Collection ist von Natur aus veränderlich
 
  public Money(String currency, long minorUnits, List<Integer> tags) {
  // defensive copy + unveränderliche Hülle
    this.currency  = Objects.requireNonNull(currency);
    this.minorUnits = minorUnits;
    this.tags = List.copyOf(tags);  // Java 10+: im Grunde ein unmodifizierbarer Snapshot
  }
  public String currency()  { return currency; }
  public long  minorUnits() { return minorUnits; }
 
  public List<Integer> tags() {
  // kann tags direkt zurückgeben, da copyOf einen unveränderlichen Snapshot erstellt
    return tags;
  }
  // funktionaler Stil "mit Änderung": erstelle ein NEUES Objekt mit einem anderen Wert
  public Money withMinorUnits(long newMinorUnits) {
  return new Money(this.currency, newMinorUnits, this.tags);
  }
  @Override public boolean equals(Object o) { /* standardmäßig anhand der zwei Felder + tags */ }
  @Override public int hashCode()  { /* konsistent mit equals */ }
  @Override public String toString()  { /* nützlich für Logs */ }
}

Erläuterungen:

  • List.copyOf(...) gibt einen unveränderlichen Snapshot des aktuellen Inhalts zurück (und wirft NullPointerException bei null-Elementen).
  • Wenn eine tiefe Unveränderlichkeit benötigt wird (Elemente der Collection sind selbst veränderlich), kopiere die Elemente (siehe „Tiefe Unveränderlichkeit“).
  • Für oberflächliche Unveränderlichkeit: List.copyOf, Set.copyOf, Map.copyOf (Java 10+) oder Collections.unmodifiableXxx(...).
  • unmodifiableList verhindert nur die Mutation über den Wrapper, aber wenn du eine Referenz auf die ursprüngliche veränderliche Collection hältst, sind Änderungen sichtbar. Daher:
    • Entweder erstelle eine neue Collection und hülle sie ein,
    • oder verwende copyOf (erstellt einen Snapshot).

Umgang mit Arrays und Collections

Arrays

Arrays sind veränderlich. Das bedeutet:

  • Im Konstruktor: this.arr = Arrays.copyOf(arr, arr.length);
  • Im Getter: return Arrays.copyOf(arr, arr.length);

Collections

  • Für oberflächliche Unveränderlichkeit: List.copyOf, Set.copyOf, Map.copyOf (Java 10+) oder Collections.unmodifiableXxx(...).
  • unmodifiableList verhindert nur die Mutation über den Wrapper, aber wenn du eine Referenz auf die ursprüngliche veränderliche Collection hältst, sind Änderungen sichtbar. Daher:
    • Entweder erstelle eine neue Collection und hülle sie ein,
    • oder verwende copyOf (erstellt einen Snapshot).
  • Für tiefe Unveränderlichkeit: Kopiere die Elemente (wenn sie veränderlich sind).

Häufige Fehler (die Interviews lieben)

  1. Rückgabe einer lebenden Referenz auf eine interne Collection/Array: Ein „unveränderlicher“ Klasse, der getList() zurückgibt und diese dann verändert werden kann, ist nicht unveränderlich.
  2. Keine defensive Kopie im Konstruktor: Du hast eine Liste übergeben, die Referenz gespeichert und nicht kopiert → die Klasse ist „kaputt“, wenn die Liste nach dem Konstruktor verändert wird.
  3. Veränderliche Typen innerhalb einer unveränderlichen Hülle: Speichere java.util.Date (veränderlich) ohne Kopie → Änderungen über die Referenz sind sichtbar. Verwende java.time (unveränderlich).
  4. Vererbung von einer unveränderlichen Klasse: Ein Subclass kann einen veränderlichen Zustand/Methoden hinzufügen → bricht die Garantie. Ergebnis: Mache die Basisklasse final (oder verwende private Konstruktor + Factorys).
  5. Verstoß gegen equals/hashCode bei „Mutation“: Wenn ein Objekt als Schlüssel in HashMap verwendet wird, muss sein hashCode stabil bleiben. Daher: Unveränderlichkeit.

Unveränderlichkeit und record (Java 16+)

record ist ein syntaktischer Zucker für „Daten“. Beispiel:

public record Point(int x, int y) {}

Besonderheiten:

  • Alle Komponenten sind final, Getter werden generiert, equals/hashCode/toString sind bereits vorhanden.
  • Aber: Unveränderlichkeit ist oberflächlich. Wenn ein Feld eine Collection/Array/jeder veränderliche Typ ist, benötigst du eine defensive Kopie im kompakten Konstruktor:
public record Team(String name, List<String> members) {
  public Team {
  members = List.copyOf(members); // erstellen eines Snapshots
  }
}

Unveränderlichkeit und Lombok

  • @Value → macht die Klasse final, Felder private final, Getter, equals/hashCode.
  • Sorge trotzdem selbst für eine defensive Kopie für Arrays/Collections/Date.
@lombok.Value
public class UserDto {
  String name;
  List<String> roles; // im Konstruktor selbst List.copyOf(...) erstellen
}

„Funktionale Updates“: with*-Methoden

In der unveränderlichen Modell werden Änderungen durch die Erstellung eines neuen Objekts durchgeführt:

public Money withCurrency(String newCurrency) {
  if (this.currency.equals(newCurrency)) return this; // Mikrooptimierung
  return new Money(newCurrency, this.minorUnits, this.tags);
}

Das ist bequem und sicher für Multithreading.

Serialisierung unveränderlicher Klassen

  • Werden normal serialisiert/deserialisiert (Java/JSON/Protobuf).
  • Wenn ein Singleton benötigt wird, füge readResolve() hinzu.
  • Bei JSON-Deserialisierung (Jackson) verwende:
    • vollständige Konstruktor oder
    • @JsonCreator + @JsonProperty.

Warum String unveränderlich in Java ist

  1. Sicherheit: Strings werden oft als Identifikatoren, Klassennamen, Ressourcennamen, Parameter von ClassLoader und SQL-Strings verwendet. Wenn man eine String „am Ort“ ändern könnte, würde dies eine Vielzahl von Sicherheitslücken öffnen (Pfadmanipulation, Klassennamenmanipulation, SQL-Injection auf Objektniveau usw.).
  2. String Pool (Internierung) Literale Strings werden im Pool gespeichert: identische Literale verweisen auf ein Objekt zur Speichereinsparung und zur Beschleunigung des ==-Vergleichs zwischen Literalen. Der Pool ist nur möglich, wenn Strings unveränderlich sind (andernfalls würde eine Änderung eines Strings alle Referenzen darauf ändern).
  3. Caching von hashCode: String.hashCode() wird einmal berechnet und in einem Feld zwischengespeichert. Wenn ein String verändert werden könnte, würde der Cache ungültig werden → Verletzung der Verträge von Map/Set.
  4. Thread-Sicherheit und Sharing: Ein identischer String kann frei zwischen Threads geteilt werden, ohne Synchronisierung.
  5. JVM/JIT-Optimierungen: Unveränderliche Objekte lassen sich leichter optimieren (Escape-Analysis, konstante Inline, konstante Zusammenfassung).

Fazit: Unveränderlichkeit ist das Fundament von String für Sicherheit, Leistung und Vorhersagbarkeit.

Mini-Beispiele: richtig/falsch

❌ Falsch (Leck auf internem Array)

public final class Bad {
  private final int[] data;
  public Bad(int[] data) { this.data = data; }  // keine Kopie!
  public int[] getData() { return data; }  // gibt eine lebende Referenz zurück!
}

✅ Richtig

public final class Good {
  private final int[] data;
  public Good(int[] data) { this.data = data.clone(); }
  public int[] data()  { return data.clone(); }
}

✅ Richtig für Date: verwende java.time

public final class Booking {
  private final java.time.Instant from;
  private final java.time.Instant to;
  public Booking(Instant from, Instant to) {
  this.from = from; this.to = to; // Instant ist unveränderlich
  }
}

Tiefe Unveränderlichkeit

Wenn ein Feld eine Liste von Address-Objekten ist und Address veränderlich ist, dann:

  • Mache Address unveränderlich (bevorzugt), oder
  • Erstelle eine tiefe Kopie der Elemente und hülle die Liste in List.copyOf ein.
public record Address(String city, String street) {}
 
public final class Person {
  private final List<Address> addresses;
  public Person(List<Address> addresses) {
  this.addresses = List.copyOf(addresses); // Elemente sind bereits unveränderlich
  }
  public List<Address> addresses() { return addresses; }
}

Best Practices (kurze Checkliste)

  • Mache die Klasse final + alle Felder private final.
  • Keine Setter. „Änderungen“ erfolgen über with*-Methoden, die ein neues Objekt erstellen.
  • Verwende eine defensive Kopie für Arrays/Collections/veränderliche Typen sowohl beim Eingang als auch beim Ausgang.
  • Verwende java.time anstelle von Date/Calendar.
  • Für Collections: List.copyOf/Map.copyOf (oder Collections.unmodifiableXxx + vorherige Kopie).
  • Vermeide this-Referenzen im Konstruktor (keine Callbacks/Listener dort).
  • Speichere Invarianz an einem Ort – im Konstruktor/Factory, überprüfe requireNonNull, Bereiche usw.
  • Für die Leistung kannst du abgeleitete Werte zwischenspeichern (z.B. hashCode) – dies ist sicher, wenn das Objekt unveränderlich ist.