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:
priorityaufsteigend (1 hat höchste Priorität).totalabsteigend.createdAtaufsteigend (ä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 = 1besteht aus Binh und Dung. Beide habentotal = 300, daher entscheidet das Datumskriterium. Dung (01/10) kommt vor Binh (03/10). - Die Gruppe
priority = 2besteht 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
ifist klarer als eine gezwungenethenComparing-Kette. - Der Schlüssel ist teuer zu berechnen. Der Key Extractor wird bei jedem Vergleich aufgerufen, etwa
n log nMal. 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 BYnormalerweise schneller und hilft, unnötige Daten in den Speicher zu laden.
10. Fazit
In diesem Artikel hast du gelernt, wie du:
comparing,comparingIntundcomparingDoubleverwendest, um einen Comparator aus einem Schlüssel zu erstellen.- Mehrere Kriterien mit
thenComparingkombinierst und ein einzelnes Kriterium mitreversed()oderComparator.reverseOrder()kehrst. - Mit
nullsFirstundnullsLastauf Schlüssel- und Elementebene mitnullumgehen. - Typ-Inferenzfehler bei Lambdas, Überläufe bei Subtraktionen und Elementverlust in
TreeSetvermeidest.
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.