Comparator: Mehrere Kriterien mit comparing und thenComparing sortieren

1. Überblick

Reale Sortieraufgaben haben selten nur ein Kriterium. Beispiel: Bestellungen nach Priorität sortieren, bei gleicher Priorität die mit höherem Gesamtbetrag zuerst, bei gleich hohem Betrag die älteren Bestellungen zuerst.

Dieser Artikel zeigt dir, wie du solche Comparator mit Comparator.comparing und thenComparing konstruierst. Du wirst auch lernen, wie du mit null umgehen, reversed() korrekt verwenden und häufige Fehler mit Comparatoren vermeidest.

2. Daten vorbereiten

Wir verwenden einen einfachen record für Bestellungen:

import java.time.LocalDate;

public record Order(String customer, int priority, double total, LocalDate createdAt) {
}

Und eine Musterliste. Die ursprüngliche Reihenfolge ist An, Binh, Chi, Dung, Em:

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class SampleData {

    public static List<Order> orders() {
        return new ArrayList<>(List.of(
            new Order("An",   2, 150.0, LocalDate.of(2026, 10, 1)),
            new Order("Binh", 1, 300.0, LocalDate.of(2026, 10, 3)),
            new Order("Chi",  2, 300.0, LocalDate.of(2026, 10, 2)),
            new Order("Dung", 1, 300.0, LocalDate.of(2026, 10, 1)),
            new Order("Em",   2, 150.0, LocalDate.of(2026, 9, 30))
        ));
    }
}

Wir wickeln es in new ArrayList<>(...), weil List.of eine unveränderliche Liste zurückgibt. Der Aufruf von sort auf dieser Liste wirft UnsupportedOperationException.

3. Einteilige Comparator mit comparing

3.1. comparing mit Method-Referenz

Comparator.comparing nimmt eine Schlüssel-Extraktorfunktion an. Diese Funktion gibt einen Comparable-Wert zurück, und der Comparator vergleicht diese Schlüssel dann:

List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparing(Order::customer));

System.out.println(orders.stream().map(Order::customer).toList());
[An, Binh, Chi, Dung, Em]

Order::customer gibt einen String zurück. String implementiert bereits Comparable, daher verwendet der Comparator die natürliche Ordnung von Zeichenketten.

3.2. comparingInt, comparingLong, comparingDouble

Verwende für primitive Schlüsseltypen spezialisierte Varianten. Sie erfordern keine Autoboxing zu Integer oder Double bei jedem Vergleich:

List<Order> orders = SampleData.orders();
orders.sort(Comparator.comparingDouble(Order::total));

System.out.println(orders.stream().map(Order::customer).toList());
[An, Em, Binh, Chi, Dung]

An und Em haben beide den Gesamtbetrag 150. Sie behalten ihre ursprüngliche Reihenfolge, weil List.sort TimSort verwendet, einen stabilen Sortieralgorithmus. Elemente, die nach dem Comparator gleich sind, werden ihre Reihenfolge nicht ändern.

3.3. comparing mit Comparator für den Schlüssel

Die zweite Überladung von comparing nimmt einen zusätzlichen Comparator zur Schlüsselvergleich. Dadurch kannst du nur für ein Kriterium absteigend sortieren:

orders.sort(Comparator.comparing(Order::createdAt, Comparator.reverseOrder()));

Dieser Aufruf sortiert die neuesten Bestellungen nach oben. Diese Schreibweise ist sehr nützlich, wenn du mehrere Kriterien kombinierst, wie du im nächsten Abschnitt sehen wirst.

4. Mehrere Kriterien mit thenComparing

thenComparing wird nur verwendet, wenn der vorherige Comparator 0 zurückgibt, d. h. zwei Elemente sind bei den vorherigen Kriterien gleich. Zurück zu den ursprünglichen Anforderungen:

  1. priority aufsteigend (1 hat höchste Priorität).
  2. total absteigend.
  3. createdAt aufsteigend (ältere Bestellungen zuerst).
Comparator<Order> byBusinessRule = Comparator
    .comparingInt(Order::priority)
    .thenComparing(Comparator.comparingDouble(Order::total).reversed())
    .thenComparing(Order::createdAt);

List<Order> orders = SampleData.orders();
orders.sort(byBusinessRule);

System.out.println(orders.stream().map(Order::customer).toList());
[Dung, Binh, Chi, Em, An]

Das läuft in folgenden Schritten ab:

  • Die Gruppe priority = 1 besteht aus Binh und Dung. Beide haben total = 300, daher entscheidet das Datumskriterium. Dung (01/10) kommt vor Binh (03/10).
  • Die Gruppe priority = 2 besteht aus An, Chi, Em. Chi hat 300 und steht an der Spitze der Gruppe. An und Em haben beide 150, daher kommt Em (30/09) vor An (01/10).

Beachte die zweite Zeile: reversed() wird auf einem separaten Comparator für total aufgerufen. Dies wird nicht auf die gesamte Kette angewendet. Hier machen viele einen Fehler.

5. Die Falle mit reversed()

reversed() kehrt den gesamten Comparator um, auf dem es aufgerufen wird. Schau dir folgenden Code an:

Comparator<Order> wrong = Comparator
    .comparingInt(Order::priority)
    .thenComparingDouble(Order::total)
    .reversed();

List<Order> orders = SampleData.orders();
orders.sort(wrong);

System.out.println(orders.stream().map(Order::customer).toList());
[Chi, An, Em, Binh, Dung]

Der Autor wollte „priority aufsteigend, total absteigend”. Das Ergebnis ist stattdessen, dass beide absteigend sind: die Gruppe priority = 2 wird nach oben verschoben. Der Grund ist, dass reversed() auf den bereits aus zwei Kriterien zusammengesetzten Comparator angewendet wird.

Es gibt zwei richtige Schreibweisen, wähle die eine, die dir lesbarer erscheint:

// Weg 1: Separater Comparator für den Schlüssel, dann umgekehrt
Comparator<Order> c1 = Comparator.comparingInt(Order::priority)
    .thenComparing(Comparator.comparingDouble(Order::total).reversed());

// Weg 2: Comparator des Schlüssels an thenComparing übergeben
Comparator<Order> c2 = Comparator.comparingInt(Order::priority)
    .thenComparing(Order::total, Comparator.reverseOrder());

Weg 2 ist kürzer, erfordert aber Autoboxing von double zu Double. Mit einigen tausend Elementen ist dieser Unterschied vernachlässigbar. Für große Datenmengen und häufiges Sortieren bevorzugst du Weg 1.

Hinweis: Einen Aufruf von reversed() am Ende der Kette ist nur korrekt, wenn du wirklich alle Kriterien umkehren willst. Wenn du nur ein Kriterium umkehren möchtest, kehre es direkt an diesem Kriterium um.

6. Umgang mit null-Werten

6.1. Schlüssel kann null sein

Comparator.comparing(Customer::nickname) wirft NullPointerException, wenn es auf einen null-Wert für nickname trifft. Du packst den Comparator des Schlüssels mit nullsFirst oder nullsLast ein:

public record Customer(String nickname) {
}
List<Customer> customers = new ArrayList<>(List.of(
    new Customer("lan"),
    new Customer(null),
    new Customer("An"),
    new Customer("binh"),
    new Customer("Cuong")
));

customers.sort(Comparator.comparing(
    Customer::nickname,
    Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)
));

System.out.println(customers.stream().map(Customer::nickname).toList());
[An, binh, Cuong, lan, null]

Dieses Beispiel verwendet String.CASE_INSENSITIVE_ORDER, daher werden Groß- und Kleinbuchstaben als gleichwertig behandelt. Wenn du Comparator.naturalOrder() verwendest, ist das Ergebnis [An, Cuong, binh, lan, null]. Der Grund ist, dass die natürliche Ordnung von String nach Unicode-Code vergleicht, und Großbuchstaben kommen vor Kleinbuchstaben.

6.2. Das Element selbst ist null

Wenn das Element selbst in der Liste null sein kann, wickle den gesamten Comparator außen ein:

Comparator<Order> safe = Comparator.nullsFirst(byBusinessRule);

Diese beiden Fälle sind unterschiedlich. Das Einwickeln des Schlüssels schützt dich nicht vor null-Elementen, und das Einwickeln des Elements schützt dich auch nicht vor null-Schlüsseln.

7. Typ-Inferenzfehler mit Lambda

Der folgende Code kompiliert nicht:

// Fehler: cannot find symbol: method customer() in class Object
Comparator<Order> c = Comparator.comparing(o -> o.customer())
    .thenComparing(Order::createdAt);

Der Compiler inferiert den Typ von links nach rechts in der Methodenaufrufskette. Bei dem Aufruf comparing(...) weiß er noch nicht, dass das Endziel Comparator<Order> ist, daher wird der Parameter o als Object inferiert.

Es gibt drei Möglichkeiten, das zu beheben:

Comparator.comparing(Order::customer)                    // Method-Referenz
Comparator.comparing((Order o) -> o.customer())          // Parametertyp deklarieren
Comparator.<Order, String>comparing(o -> o.customer())   // Typ-Hinweis

Method-Referenzen sind die kürzeste Schreibweise. Verwende sie, wann immer du kannst.

8. Tests für Comparator schreiben

Comparator enthält Geschäftslogik und sollte daher separat getestet werden. Füge eine Abhängigkeit hinzu:

<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>

Du platzierst den Comparator an einem festen Ort, um ihn leicht wiederzuverwenden:

import java.util.Comparator;

public final class OrderComparators {

    public static final Comparator<Order> BY_BUSINESS_RULE = Comparator
        .comparingInt(Order::priority)
        .thenComparing(Comparator.comparingDouble(Order::total).reversed())
        .thenComparing(Order::createdAt);

    private OrderComparators() {
    }
}

Schreibe dann Tests:

import org.junit.jupiter.api.Test;

import java.util.List;

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

class OrderComparatorsTest {

    @Test
    void givenOrders_whenSortByBusinessRule_thenPriorityAscTotalDescDateAsc() {
        List<Order> orders = SampleData.orders();

        orders.sort(OrderComparators.BY_BUSINESS_RULE);

        assertThat(orders)
            .extracting(Order::customer)
            .containsExactly("Dung", "Binh", "Chi", "Em", "An");
        assertThat(orders).isSortedAccordingTo(OrderComparators.BY_BUSINESS_RULE);
    }

    @Test
    void givenReversedAtEnd_whenSort_thenAllCriteriaAreReversed() {
        List<Order> orders = SampleData.orders();

        orders.sort(java.util.Comparator.comparingInt(Order::priority)
            .thenComparingDouble(Order::total)
            .reversed());

        assertThat(orders.getFirst().priority()).isEqualTo(2);
    }
}

containsExactly prüft die genaue Reihenfolge des Ergebnisses. isSortedAccordingTo prüft, dass die Liste nach diesem Comparator richtig sortiert ist. Der zweite Test dokumentiert das Verhalten von reversed() am Ende der Kette, damit andere diesen Fehler nicht wiederholen. getFirst() ist eine neue Methode von List in Java 21.

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

9.1. Vergleich mit Subtraktion

// Mach das nicht
Comparator<Order> bad = (a, b) -> a.priority() - b.priority();

Die Subtraktion kann überflute. Zum Beispiel gibt Integer.MIN_VALUE - 1 eine große positive Zahl zurück, daher ist das Vergleichsergebnis falsch. Verwende Comparator.comparingInt(...) oder Integer.compare(a, b).

9.2. Comparator nicht konsistent mit equals in TreeSet/TreeMap

TreeSet betrachtet zwei Elemente als gleich, wenn der Comparator 0 zurückgibt. Es verwendet nicht equals zum Vergleichen:

Set<Order> set = new TreeSet<>(Comparator.comparingInt(Order::priority));
set.addAll(SampleData.orders());

System.out.println(set.size());
2

Von fünf Bestellungen bleiben nur zwei übrig, eine pro priority. Wenn du einen Comparator für TreeSet oder TreeMap verwendest, füge ein abschließendes eindeutiges Kriterium hinzu, z. B. eine Bestellnummer. Dann gibt der Comparator nur 0 für wirklich gleiche Elemente zurück.

9.3. Wann man die comparing-Kette nicht verwenden sollte

  • Kriterien hängen voneinander ab. Zum Beispiel: „VIP-Bestellungen nach Gesamtbetrag, Standardbestellungen nach Datum”. Das Schreiben eines Comparator als Lambda mit if ist klarer als eine gezwungene thenComparing-Kette.
  • Der Schlüssel ist teuer zu berechnen. Der Key Extractor wird bei jedem Vergleich aufgerufen, etwa n log n Mal. Wenn der Schlüssel einen String analysieren oder einen Service aufrufen muss, berechne den Schlüssel einmal vor und sortiere dann.
  • Sortierung kann in der Datenbank erfolgen. Wenn die Daten aus einer SQL-Abfrage stammen, ist ORDER BY normalerweise schneller und hilft, unnötige Daten in den Speicher zu laden.

10. Fazit

In diesem Artikel hast du gelernt, wie du:

  • comparing, comparingInt und comparingDouble verwendest, um einen Comparator aus einem Schlüssel zu erstellen.
  • Mehrere Kriterien mit thenComparing kombinierst und ein einzelnes Kriterium mit reversed() oder Comparator.reverseOrder() kehrst.
  • Mit nullsFirst und nullsLast auf Schlüssel- und Elementebene mit null umgehen.
  • Typ-Inferenzfehler bei Lambdas, Überläufe bei Subtraktionen und Elementverlust in TreeSet vermeidest.

Definiere Geschäfts-Comparatoren als Konstanten mit klaren Namen und begleite sie immer mit Tests, die die erwartete Reihenfolge prüfen. Wenn sich Sortierregeln ändern, teilen dir die Tests das sofort mit.

javacomparatorsortingstreamcollections