Immutable Collections in Java: List.of, copyOf und unmodifiable

1. Übersicht

Java hat viele Wege, eine „unveränderbare” Collection zu erstellen: List.of, List.copyOf, Collections.unmodifiableList, Stream.toList()… Auf den ersten Blick sehen sie ähnlich aus, aber es gibt drei Unterschiede: ob die Daten kopiert werden, ob null akzeptiert wird, und ob Änderungen der ursprünglichen Collection reflektiert werden.

Dieser Artikel geht auf jeden Weg mit konkreten Beispielen ein. Danach weißt du, wie du die richtige API wählst, unerwartete NullPointerException vermeidest und eine korrekte Defensive Copy schreibst.

2. Warum man immutable Collections braucht

Schau dir die Klasse Order an:

import java.util.List;

public class Order {
    private final List<String> items;

    public Order(List<String> items) {
        this.items = items;
    }

    public List<String> getItems() {
        return items;
    }
}

Das Schlüsselwort final sperrt nur die Referenz, nicht den Inhalt der Liste. Von außen kann man sie trotzdem verändern:

List<String> items = new ArrayList<>(List.of("Laptop"));
Order order = new Order(items);

items.add("Mouse");              // die ursprüngliche Liste verändern
order.getItems().add("Keyboard"); // über den Getter verändern

System.out.println(order.getItems());
[Laptop, Mouse, Keyboard]

Order kann seinen eigenen Zustand nicht mehr kontrollieren. Wenn das Objekt zwischen mehreren Threads geteilt wird, sind solche Fehler noch schwerer zu finden. Immutable Collections lösen dieses Problem: Niemand kann sie verändern, also kannst du sie bedenkenlos teilen, ohne sie synchronisieren zu müssen.

3. List.of, Set.of und Map.of

Seit Java 9 erstellen die Factory-Methoden of unveränderbare Collections in einer Zeile:

List<String> statuses = List.of("NEW", "PAID", "SHIPPED");
Set<String> currencies = Set.of("VND", "USD", "EUR");
Map<String, Integer> stock = Map.of("Laptop", 5, "Mouse", 20);

statuses.add("CANCELLED");
Exception in thread "main" java.lang.UnsupportedOperationException

Jede Methode, die Daten verändert – wie add, remove, set, clear, sort oder put – wirft UnsupportedOperationException. Die Exception wird immer geworfen, auch wenn die Operation ohnehin nichts ändern würde, etwa wenn man ein nicht vorhandenes Element entfernt.

Map.of hat nur Überladungen für bis zu 10 Schlüssel-Wert-Paare. Für mehr verwendest du Map.ofEntries:

Map<String, Integer> prices = Map.ofEntries(
        Map.entry("Laptop", 25_000_000),
        Map.entry("Mouse", 300_000)
);

3.1. Keine null akzeptiert

Collections, die mit of erstellt werden, lehnen null sofort bei der Initialisierung ab:

List<String> names = List.of("An", null);
Exception in thread "main" java.lang.NullPointerException

Es gibt einen kniffligeren Fall: Selbst das Abfragen von null wirft einen Fehler.

List<String> names = List.of("An", "Bình");
System.out.println(names.contains(null));
Exception in thread "main" java.lang.NullPointerException

Mit ArrayList würde dieser Aufruf false zurückgeben. Wenn dein altes Code list.contains(input) aufruft und input könnte null sein, wirst du einen Fehler bekommen, wenn du zu List.of wechselst.

3.2. Set und Map: Duplikate und Reihenfolge

Set.of und Map.of ignorieren duplikate Elemente nicht stillschweigend, sondern werfen einen Fehler:

Set<String> tags = Set.of("java", "spring", "java");
Exception in thread "main" java.lang.IllegalArgumentException: duplicate element: java

Die Iterationsreihenfolge von Set.of und Map.of ist nicht garantiert. Bei OpenJDK kann sich die Reihenfolge sogar zwischen JVM-Läufen ändern. Tests, die den toString() eines Set.of vergleichen, können daher manchmal passen und manchmal fehlschlagen. Wenn du die Reihenfolge brauchst, verwendest du LinkedHashSet oder LinkedHashMap und wickelst sie dann ein, wie in Abschnitt 5.

4. List.copyOf: Unveränderbare Kopie

List.of ist praktisch, wenn du die Elemente vorher kennst. Wenn du aber bereits eine Collection hast, zum Beispiel als Parameter in einen Constructor, verwendest du List.copyOf (seit Java 10):

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> copy = List.copyOf(source);

source.add("Keyboard");

System.out.println(source);
System.out.println(copy);
[Laptop, Mouse, Keyboard]
[Laptop, Mouse]

copy ist eine unabhängige Kopie. Änderungen an source beeinflussen sie nicht. Wie List.of wirft copyOf auch NullPointerException, wenn die Quellcollection null enthält.

Ein interessantes Detail: Wenn die Eingabe bereits eine unveränderbare List ist, die von List.of oder List.copyOf erstellt wurde, gibt OpenJDK einfach jene Instanz zurück, ohne erneut zu kopieren:

List<String> a = List.of("x", "y");
System.out.println(List.copyOf(a) == a);
true

Daher kostet es fast nichts, copyOf „zur Sicherheit” aufzurufen. Dies ist aber ein Implementierungsdetail, also schreib keine Logik, die auf == Vergleichen basiert.

Es gibt auch Set.copyOf und Map.copyOf. Set.copyOf wirft nicht bei duplizierten Elementen einen Fehler, sondern behält nur eine Kopie, weil die Quelle eine List sein könnte.

5. Collections.unmodifiableList: nur eine View

Collections.unmodifiableList existiert seit Java 1.2 und funktioniert völlig anders. Es kopiert nicht, sondern wrappet die ursprüngliche Collection in eine Klasse, die Schreibvorgänge sperrt:

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> view = Collections.unmodifiableList(source);

source.add("Keyboard");

System.out.println(view);
view.add("Monitor");
[Laptop, Mouse, Keyboard]
Exception in thread "main" java.lang.UnsupportedOperationException

Du kannst nicht über view verändern, aber wer die Referenz source hält, kann immer noch verändern, und die Änderungen erscheinen sofort in der view. Also bedeutet unmodifiable hier „nicht veränderbar über diese Referenz”, nicht „unveränderlich”.

Dieses View-Verhalten ist aber praktisch, wenn du möchtest, dass die Aufrufer die neuesten Daten sehen, aber nicht verändern können:

public class ShoppingCart {
    private final List<String> items = new ArrayList<>();

    public void addItem(String item) {
        items.add(item);
    }

    public List<String> getItems() {
        return Collections.unmodifiableList(items);
    }
}

Der Getter gibt eine View zurück, ohne zu kopieren, also ist der Aufwand O(1). Aufrufer können nur lesen, aber ShoppingCart kann immer noch alles innen ändern.

Der letzte Unterschied: Diese View akzeptiert null. Ein Aufruf zu contains(null) gibt false zurück, anstatt einen Fehler zu werfen.

6. Andere Optionen und Vergleichstabelle

Es gibt noch ein paar andere APIs, die oft verwechselt werden:

// Java 16+: Ergebnis nicht veränderbar, AKZEPTIERT null
List<String> a = Stream.of("A", null).toList();

// Java 10+: Ergebnis nicht veränderbar, LEHNT null ab
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());

// Feste Größe, aber man kann immer noch set() aufrufen und schreibt direkt in das ursprüngliche Array
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z

Vergleichstabelle:

API Kopiert? null Änderungen der Quelle sichtbar? Veränderbar?
List.of(...) Keine Quelle Wirft NPE Nicht zutreffend Nein
List.copyOf(c) Ja Wirft NPE Nein Nein
Collections.unmodifiableList(l) Nein (View) Erlaubt Ja Nein (über View)
stream.toList() Ja Erlaubt Nein Nein
Collectors.toUnmodifiableList() Ja Wirft NPE Nein Nein
Arrays.asList(arr) Nein (Array-View) Erlaubt Ja Nur set

7. Oberflächliche Unveränderbarkeit (Shallow Immutability)

Alle APIs oben sperren nur die Struktur der Collection, nicht die Elemente darin:

public class OrderLine {
    private int quantity;

    public OrderLine(int quantity) { this.quantity = quantity; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

List<OrderLine> lines = List.of(new OrderLine(1));
lines.get(0).setQuantity(99);
System.out.println(lines.get(0).getQuantity());
99

Du kannst keine Elemente hinzufügen oder entfernen, aber du kannst den Inhalt jedes Elements noch verändern. Für echte Unveränderbarkeit müssen die Elemente selbst auch unveränderlich sein. Der eleganteste Weg ist, ein record zu verwenden:

public record OrderLine(String product, int quantity) {}

Zusammengefasst: Das ist das Standard-Muster für eine defensive Copy in einem unveränderlichen Aggregat:

import java.util.List;

public record Order(String customer, List<OrderLine> lines) {
    public Order {
        lines = List.copyOf(lines);
    }
}

Der Compact Constructor ersetzt den Parameter lines mit einer unveränderlichen Kopie, bevor er dem Feld zugewiesen wird. Der Accessor lines() gibt genau diese Kopie zurück, also musst du im Getter nicht noch mal kopieren. Wenn lines null ist oder null enthält, schlägt das Objekt sofort bei der Erstellung fehl, anstatt später einen Fehler zu verursachen.

8. Verhalten testen

Füge JUnit 5 und AssertJ zu pom.xml hinzu. Wenn du Spring Boot 3.x verwendest, sind beide bereits in spring-boot-starter-test enthalten:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

Die Tests unten überprüfen die drei Hauptverhaltensweisen von Order und der View:

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class OrderTest {

    @Test
    void shouldNotReflectChangesToSourceList() {
        List<OrderLine> source = new ArrayList<>();
        source.add(new OrderLine("Laptop", 1));

        Order order = new Order("An", source);
        source.add(new OrderLine("Mouse", 2));

        assertThat(order.lines()).hasSize(1);
    }

    @Test
    void shouldRejectModificationThroughAccessor() {
        Order order = new Order("An", List.of(new OrderLine("Laptop", 1)));

        assertThatThrownBy(() -> order.lines().add(new OrderLine("Mouse", 2)))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    @Test
    void unmodifiableViewShouldReflectSourceChanges() {
        List<String> source = new ArrayList<>(List.of("A"));
        List<String> view = Collections.unmodifiableList(source);

        source.add("B");

        assertThat(view).containsExactly("A", "B");
    }
}

Der erste Test bestätigt, dass List.copyOf im Compact Constructor Order von der ursprünglichen Liste trennt. Der zweite Test stellt sicher, dass der Accessor keinen Weg öffnet, Daten zu verändern. Der letzte Test dokumentiert das View-Verhalten, damit niemand unmodifiableList mit einer Kopie verwechselt.

9. Häufige Fehler und wann man sie nicht verwenden sollte

Achtung: List.of(...).contains(null) und indexOf(null) werfen NullPointerException. Prüfe null zuerst, oder verwende Objects.equals beim Durchsuchen der List.

Ein paar weitere häufige Fehler:

  • Zu denken, unmodifiableList ist unveränderlich. Es ist nur eine View. Wenn die Quelle irgendwo noch referenziert wird, können sich die Daten immer noch ändern. Wenn du einen Snapshot möchtest, verwende List.copyOf.
  • Mechanisch von collect(Collectors.toList()) zu toList() wechseln. Collectors.toList() gibt eine veränderbare ArrayList zurück. Wenn es später Code gibt, der add oder sort auf dem Ergebnis aufruft, wird dieser Code zur Laufzeit UnsupportedOperationException werfen.
  • In-Place sort aufrufen. List.of(...).sort(...) wirft immer einen Fehler. Du musst zuerst zu ArrayList kopieren oder stream().sorted().toList() verwenden.
  • Sich auf die Reihenfolge von Set.of oder Map.of in Logs, JSON oder Tests verlassen.
  • Daten aus externen Quellen, die null enthalten könnten (JSON, Datenbankresultate), direkt in List.copyOf übergeben. Filtere zuerst, oder verwende stream().toList(), wenn null ein gültiger Wert ist.

Es gibt ein paar Fälle, in denen du immutable Collections nicht verwenden solltest:

  • Collection braucht sich ständig während eines kurzen, lokalen Lebenszyklus zu ändern, etwa ein Puffer für Ergebnisse in einer Methode. ArrayList ist einfacher und schneller.
  • Manche Bibliotheken brauchen veränderbare Collections, etwa JPA-Entitäten mit Hibernate, wo ein @OneToMany-Feld veränderbar sein muss, damit Hibernate es verwalten kann. In diesem Fall hältst du ArrayList intern und gibst nur eine View im Getter zurück.
  • Sehr große Collections, die häufig aktualisiert werden. Jede „Änderung” erfordert eine vollständige Kopie, was O(n) kostet.

10. Fazit

Die drei Haupt-APIs dienen drei verschiedenen Zwecken:

  • List.of erstellt schnell eine feste Liste, die kein null enthält.
  • List.copyOf erstellt einen unabhängigen Snapshot. Das ist die Standardwahl für defensive Copy in einem Constructor oder record.
  • Collections.unmodifiableList ist eine schreibgeschützte View auf Daten, die sich noch verändern. Es ist günstig, aber nicht unveränderlich.

Denke auch daran, dass jede immutable Collection in der JDK nur oberflächlich unveränderbar ist. Kombiniere sie mit unveränderlichen Elementen wie record, um Objekte zu haben, die wirklich sicher sind, wenn sie zwischen Threads geteilt werden.

javacollectionsimmutablejava-21clean-code