Collectors.groupingBy und partitioningBy durch Beispiele
1. Überblick
Wenn Sie mit Stream arbeiten, müssen Sie Elemente häufig nach einem Kriterium sammeln. Beispiele: Bestellungen nach Stadt, Gesamtbetrag nach Kunde oder Trennung großer und kleiner Bestellungen. Collectors.groupingBy und Collectors.partitioningBy wurden genau dafür konzipiert.
Der Artikel geht durch häufig verwendete Formen mit konkreten Ausgaben. Sie werden lernen, wie Sie Downstream-Collector kombinieren, den zurückgegebenen Map-Typ wählen und häufige Fehler vermeiden.
2. Beispieldaten
Alle Beispiele in diesem Artikel verwenden einen gemeinsamen Satz von Bestellungen:
import java.util.List;
enum OrderStatus { PENDING, PAID, CANCELLED }
record Order(String id, String customer, String city, OrderStatus status, long amount) {}
class OrderFixtures {
static List<Order> sample() {
return List.of(
new Order("O1", "An", "Hà Nội", OrderStatus.PAID, 500_000),
new Order("O2", "Bình", "Đà Nẵng", OrderStatus.PAID, 1_200_000),
new Order("O3", "An", "Hà Nội", OrderStatus.CANCELLED, 300_000),
new Order("O4", "Chi", "Hồ Chí Minh", OrderStatus.PAID, 2_000_000),
new Order("O5", "Bình", "Đà Nẵng", OrderStatus.PENDING, 150_000),
new Order("O6", "Chi", "Hồ Chí Minh", OrderStatus.PAID, 800_000)
);
}
}
amount ist in VND und verwendet den long-Typ zur Vereinfachung. In einem echten System sollten Sie BigDecimal für Währungen verwenden.
Die Beispiele weiter unten setzen import static java.util.stream.Collectors.*; und List<Order> orders = OrderFixtures.sample(); voraus.
3. Grundlegendes groupingBy
Die einfachste Form akzeptiert nur eine Klassifizierungsfunktion (Classifier):
Map<String, List<Order>> byCity = orders.stream()
.collect(groupingBy(Order::city));
byCity.forEach((city, list) ->
System.out.println(city + " -> " + list.stream().map(Order::id).toList()));
Hà Nội -> [O1, O3]
Đà Nẵng -> [O2, O5]
Hồ Chí Minh -> [O4, O6]
Order::city entscheidet über den Schlüssel. Jeder Schlüssel wird auf eine List abgebildet, die alle Bestellungen mit demselben Wert enthält. Intern entspricht diese Version groupingBy(Order::city, HashMap::new, toList()).
Hinweis: Die zurückgegebene Map ist eine
HashMap, daher ist die Reihenfolge der Schlüssel nicht garantiert. Die Ausgabe auf Ihrer Maschine kann unterschiedlich sein. Wenn Sie eine Reihenfolge benötigen, siehe Abschnitt 5.
4. Downstream-Collector
Der zweite Parameter von groupingBy ist ein anderer Collector, der als Downstream bezeichnet wird. Er bestimmt, wie die Elemente innerhalb derselben Gruppe zusammengefasst werden. Dies ist das, was groupingBy wirklich mächtig macht.
4.1. Zählen und Summieren
Map<OrderStatus, Long> countByStatus = orders.stream()
.collect(groupingBy(Order::status, counting()));
Map<String, Long> totalByCustomer = orders.stream()
.collect(groupingBy(Order::customer, summingLong(Order::amount)));
System.out.println(countByStatus);
System.out.println(totalByCustomer);
{PAID=4, PENDING=1, CANCELLED=1}
{An=800000, Bình=1350000, Chi=2800000}
counting() gibt immer Long zurück, nicht Integer. Sie müssen dies beachten, wenn Sie den Variablentyp deklarieren. Neben summingLong gibt es auch summingInt, summingDouble, averagingLong und summarizingLong. Der letzte Collector gibt LongSummaryStatistics zurück, das gleichzeitig count, sum, min, max und average enthält.
4.2. Elemente mit Mapping transformieren
Wenn Sie nur ein Attribut anstelle des gesamten Objekts benötigen, verwenden Sie mapping:
Map<String, Set<String>> customersByCity = orders.stream()
.collect(groupingBy(Order::city, mapping(Order::customer, toSet())));
System.out.println(customersByCity);
{Hà Nội=[An], Đà Nẵng=[Bình], Hồ Chí Minh=[Chi]}
mapping(Order::customer, toSet()) extrahiert den Kundennamen aus jeder Bestellung und fasst sie in einem Set zusammen, um Duplikate zu entfernen. An hat zwei Bestellungen in Hà Nội, erscheint aber nur einmal.
4.3. Größtes Element in einer Gruppe abrufen
maxBy gibt Optional<Order> zurück, da eine Gruppe theoretisch leer sein könnte. In groupingBy existiert eine Gruppe nur, wenn sie mindestens ein Element enthält. Sie können das Optional daher mit collectingAndThen auspacken:
Map<String, Order> biggestByCustomer = orders.stream()
.collect(groupingBy(
Order::customer,
collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), Optional::get)
));
biggestByCustomer.forEach((customer, order) ->
System.out.println(customer + " -> " + order.id() + " (" + order.amount() + ")"));
An -> O1 (500000)
Bình -> O2 (1200000)
Chi -> O4 (2000000)
collectingAndThen führt erst den inneren Collector aus und wendet dann die Finalisierungsfunktion (Optional::get) auf das Ergebnis an.
Es gibt eine andere häufig verwendete Methode: toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...)). Diese Methode liefert ein ähnliches Ergebnis und erfordert nicht das Durchlaufen von Optional.
4.4. Filtern innerhalb von Gruppen mit filtering
Collectors.filtering ist seit Java 9 verfügbar. Es unterscheidet sich davon, .filter() vor der Gruppierung aufzurufen:
Map<String, Long> cancelledPerCustomer = orders.stream()
.collect(groupingBy(Order::customer,
filtering(o -> o.status() == OrderStatus.CANCELLED, counting())));
Map<String, Long> filteredFirst = orders.stream()
.filter(o -> o.status() == OrderStatus.CANCELLED)
.collect(groupingBy(Order::customer, counting()));
System.out.println(cancelledPerCustomer);
System.out.println(filteredFirst);
{An=1, Bình=0, Chi=0}
{An=1}
Mit filtering sind alle Kunden immer noch im Ergebnis vorhanden, auch wenn die Anzahl 0 ist. Wenn Sie .filter() vorher aufrufen, verschwinden Bình und Chi vollständig. Wählen Sie je nach Bedarf: Wenn der Bericht eine vollständige Liste benötigt, verwenden Sie filtering.
In ähnlicher Weise hilft flatMapping (Java 9), eine Collection innerhalb jeder Gruppe zu glätten.
5. Wahl des Map-Typs für die Rückgabe
Die dreiparametrige Variante ermöglicht das Übergeben eines Supplier, der die Map erzeugt:
Map<String, Long> sortedTotals = orders.stream()
.collect(groupingBy(Order::customer, TreeMap::new, summingLong(Order::amount)));
Map<OrderStatus, Long> statusCounts = orders.stream()
.collect(groupingBy(Order::status,
() -> new EnumMap<>(OrderStatus.class), counting()));
System.out.println(sortedTotals);
System.out.println(statusCounts);
{An=800000, Bình=1350000, Chi=2800000}
{PENDING=1, PAID=4, CANCELLED=1}
TreeMap sortiert die Schlüssel nach natürlicher Reihenfolge. EnumMap bewahrt die Schlüssel in der Reihenfolge ihrer Deklaration in der enum und ist schneller als HashMap, wenn die Schlüssel vom Typ enum sind. Wenn Sie die Reihenfolge des ersten Auftretens im Stream beibehalten möchten, verwenden Sie LinkedHashMap::new.
Hinweis:
TreeMapmitStringsortiert nach Unicode-Code, nicht nach den Regeln der vietnamesischen Sprache. Zum Beispiel steht “Đà Nẵng” nach “Hồ Chí Minh”. Wenn Sie korrekt nach vietnamesischer Sprache sortieren müssen, übergeben Sie() -> new TreeMap<>(Collator.getInstance(Locale.of("vi"))).
6. Verschachtelte Gruppierung
Da der Downstream ein beliebiger Collector sein kann, können Sie ein anderes groupingBy darin platzieren:
Map<String, Map<OrderStatus, Long>> cityThenStatus = orders.stream()
.collect(groupingBy(Order::city, TreeMap::new,
groupingBy(Order::status, counting())));
System.out.println(cityThenStatus);
{Hà Nội={PAID=1, CANCELLED=1}, Hồ Chí Minh={PAID=2}, Đà Nẵng={PAID=1, PENDING=1}}
Das Ergebnis ist eine Map mit zwei Ebenen. Dieser Ansatz ist sehr praktisch für Pivot-ähnliche Berichte. Ab mehr als zwei Ebenen wird der Datentyp jedoch schwer zu lesen. Erwägen Sie in diesem Fall die Verwendung eines zusammengesetzten Schlüssels, z. B. record CityStatus(String city, OrderStatus status), gefolgt von groupingBy(o -> new CityStatus(o.city(), o.status())).
7. partitioningBy
partitioningBy ist ein Spezialfall der Gruppierung. Der Classifier ist ein Predicate, und der Schlüssel ist immer Boolean:
Map<Boolean, List<String>> bigOrders = orders.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000,
mapping(Order::id, toList())));
Map<Boolean, Long> bigOrderCount = orders.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000, counting()));
System.out.println(bigOrders);
System.out.println(bigOrderCount);
System.out.println("Große Bestellungen: " + bigOrders.get(true));
{false=[O1, O3, O5, O6], true=[O2, O4]}
{false=4, true=2}
Große Bestellungen: [O2, O4]
partitioningBy akzeptiert auch Downstream-Collector genauso wie groupingBy. Es hat jedoch keine Variante, die einen Supplier für die Map akzeptiert, da die zurückgegebene Map eine interne Implementierung mit genau zwei Schlüsseln ist.
7.1. Wichtiger Unterschied: Schlüssel existieren immer
Dies ist der Punkt, der viele Menschen veranlasst, falsch zwischen den beiden Collectoren zu wählen:
List<Order> smallOnly = orders.stream().filter(o -> o.amount() < 1_000_000).toList();
Map<Boolean, List<Order>> partitioned = smallOnly.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000));
Map<Boolean, List<Order>> grouped = smallOnly.stream()
.collect(groupingBy(o -> o.amount() >= 1_000_000));
System.out.println(partitioned.get(true));
System.out.println(grouped.get(true));
[]
null
partitioningBy hat immer sowohl true als auch false, selbst wenn eine Seite leer ist. groupingBy erstellt den Schlüssel nur, wenn mindestens ein Element vorhanden ist. Der Aufruf von grouped.get(true).size() würde hier eine NullPointerException werfen.
Verwenden Sie daher partitioningBy, wenn das Kriterium wahr/falsch ist. Die Absicht des Codes ist klarer, und Sie müssen sich nicht um fehlende Schlüssel sorgen.
8. Tests für Gruppierungslogik schreiben
Fügen Sie JUnit 5 und AssertJ zu pom.xml hinzu. Wenn Ihr Projekt Spring Boot 3.x verwendet, enthält spring-boot-starter-test bereits beide:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Platzieren Sie die Berichtslogik in einem separaten Service für einfacheres Testen:
class OrderReportService {
Map<String, Long> paidTotalByCustomer(List<Order> orders) {
return orders.stream()
.collect(groupingBy(Order::customer, TreeMap::new,
filtering(o -> o.status() == OrderStatus.PAID,
summingLong(Order::amount))));
}
Map<Boolean, Long> countBigOrders(List<Order> orders, long threshold) {
return orders.stream()
.collect(partitioningBy(o -> o.amount() >= threshold, counting()));
}
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class OrderReportServiceTest {
private final OrderReportService service = new OrderReportService();
@Test
void shouldSumOnlyPaidOrdersPerCustomer() {
var result = service.paidTotalByCustomer(OrderFixtures.sample());
assertThat(result)
.containsExactly(
entry("An", 500_000L),
entry("Bình", 1_200_000L),
entry("Chi", 2_800_000L));
}
@Test
void shouldAlwaysContainBothPartitions() {
var result = service.countBigOrders(OrderFixtures.sample(), 10_000_000);
assertThat(result)
.containsEntry(true, 0L)
.containsEntry(false, 6L);
}
}
Der erste Test kann containsExactly verwenden, da das Ergebnis eine TreeMap ist und somit eine feste Reihenfolge hat. An wird nur 500.000 addiert, da Bestellung O3 storniert wurde. Bình wird nur 1.200.000 addiert, da Bestellung O5 noch auf Zahlung wartet.
Der zweite Test bestätigt, dass selbst wenn keine Bestellung die Schwelle überschreitet, der Schlüssel true immer noch mit dem Wert 0 vorhanden ist.
9. Häufige Fehler
Key null. groupingBy akzeptiert keine Classifier, die null zurückgeben:
var orders = List.of(new Order("O9", "Dung", null, OrderStatus.PAID, 100_000));
orders.stream().collect(groupingBy(Order::city));
java.lang.NullPointerException: element cannot be mapped to a null key
Sie haben zwei Möglichkeiten, damit umzugehen. Die erste Möglichkeit besteht darin, null durch einen Standardwert zu ersetzen: groupingBy(o -> Objects.requireNonNullElse(o.city(), "Unbekannt")). Die zweite Möglichkeit besteht darin, Bestellungen ohne Stadt vor der Gruppierung zu filtern.
Falscher Rückgabetyp. counting() gibt Long zurück. averagingLong gibt Double zurück. maxBy gibt Optional zurück. Wenn Sie den Typ falsch deklarieren, erhalten Sie einen schwer lesbaren Compilerfehler. Wenn Sie unsicher sind, verwenden Sie var oder lassen Sie die IDE den Typ ableiten.
groupingBy verwenden, wenn jeder Schlüssel nur einen Wert hat. Wenn die ID eindeutig ist, erstellt groupingBy(Order::id) eine Map<String, List<Order>> mit Listen mit genau einem Element. Verwenden Sie in diesem Fall toMap(Order::id, Function.identity()).
Map-Ergebnis ändern. Die Javadoc garantiert nicht den Typ, die Mutabilität oder die Thread-Sicherheit der Map, die von der Standardversion zurückgegeben wird. Wenn Sie die Map später ändern möchten, übergeben Sie einen expliziten Supplier, z. B. HashMap::new.
Hinweis: Bei parallelen Streams muss
groupingBymehrere Sub-Maps zusammenführen, was langsam sein kann.groupingByConcurrentschreibt direkt in eineConcurrentHashMapund ist schneller. Im Gegenzug behält es die Reihenfolge der Elemente innerhalb jeder Gruppe nicht bei. Verwenden Sie bei sequenziellen Streams einfachgroupingBy.
10. Fazit
groupingBy gruppiert Elemente nach einem beliebigen Schlüssel. partitioningBy teilt in genau zwei Seiten nach einem Predicate auf und gibt immer sowohl true als auch false zurück. Die wahre Stärke liegt in den Downstream-Collectoren: counting, summingLong, mapping, filtering, collectingAndThen oder einem verschachtelten groupingBy im Innern.
Sie sollten sich drei Punkte merken, wenn Sie diese beiden Collector verwenden:
- Wählen Sie den geeigneten Map-Typ (
TreeMap,EnumMap,LinkedHashMap), wenn Sie eine Reihenfolge benötigen. - Behandeln Sie null-Schlüssel vor der Gruppierung.
- Bevorzugen Sie
partitioningByfür wahr/falsch-Kriterien.
Mit diesen Punkten werden die meisten Ihrer Berichtslogiken nur aus wenigen, leicht zu lesenden und zu testenden Stream-Zeilen bestehen.