What you'll learn

  • groupingBy with downstream collectors
  • partitioningBy, toMap and joining
  • flatMap for nested data
  • reduce, summaryStatistics and teeing
  • Parallel streams — and when not to use them

In the basics you filtered and mapped. Real reporting needs more: totals per customer, counts per status, all the lines across all orders. That’s the job of collectors and flatMap — and once you know them, many a fifty-line loop becomes five lines.

The examples use orders that each contain lines:

record Line(String sku, int qty, double price) { double total() { return qty * price; } }
record Order(String id, String customer, String status, List<Line> lines) {
    double total() { return lines.stream().mapToDouble(Line::total).sum(); }
}

groupingBy: the SQL GROUP BY of Java

Map<String, List<Order>> byCustomer = orders.stream()
        .collect(Collectors.groupingBy(Order::customer));

The real power is the optional downstream collector — what to do with each group:

// customer -> total spend (sorted by customer thanks to TreeMap::new)
Map<String, Double> spend = orders.stream()
        .collect(Collectors.groupingBy(Order::customer, TreeMap::new,
                 Collectors.summingDouble(Order::total)));

// status -> number of orders
Map<String, Long> perStatus = orders.stream()
        .collect(Collectors.groupingBy(Order::status, Collectors.counting()));

// customer -> list of order IDs
Map<String, List<String>> idsByCustomer = orders.stream()
        .collect(Collectors.groupingBy(Order::customer,
                 Collectors.mapping(Order::id, Collectors.toList())));

Useful downstream collectors: counting(), summingInt/Double, averagingDouble, mapping(f, toList()), maxBy(comparator), toSet(), joining(", ").

partitioningBy: exactly two groups

Map<Boolean, List<String>> bigOrSmall = orders.stream()
        .collect(Collectors.partitioningBy(o -> o.total() > 200,
                 Collectors.mapping(Order::id, Collectors.toList())));
// {false=[PO-2], true=[PO-1, PO-3]}

toMap: build lookups

Map<String, Order> byId = orders.stream()
        .collect(Collectors.toMap(Order::id, o -> o));

Watch out: toMap throws IllegalStateException on duplicate keys. Pass a merge function to decide what happens: toMap(Line::sku, Line::qty, Integer::sum) adds quantities for the same SKU.

flatMap: from nested to flat

Each order has a list of lines. To work with all lines at once, flatten them:

Map<String, Integer> qtyPerSku = orders.stream()
        .flatMap(o -> o.lines().stream())                       // Stream<Order> -> Stream<Line>
        .collect(Collectors.toMap(Line::sku, Line::qty, Integer::sum, TreeMap::new));
// {AX-200=39, BX-310=2, CX-420=1}

map turns each element into one thing; flatMap turns each element into a stream of things and joins them all together. It’s the stream equivalent of a loop inside a loop.

joining, reduce and statistics

String ids = orders.stream().map(Order::id).collect(Collectors.joining(", ", "[", "]"));

double grandTotal = orders.stream().map(Order::total).reduce(0.0, Double::sum);

DoubleSummaryStatistics stats = orders.stream().mapToDouble(Order::total).summaryStatistics();
stats.getMin(); stats.getMax(); stats.getAverage(); stats.getCount();

reduce combines all elements into one value with an identity (0.0) and a combining function. For sums, mapToDouble(...).sum() is clearer; reach for reduce when combining things that aren’t simple numbers.

teeing (Java 12+): two results in one pass

String report = orders.stream().collect(Collectors.teeing(
        Collectors.counting(),
        Collectors.averagingDouble(Order::total),
        (count, average) -> count + " orders, average " + String.format("%.2f", average)));

Putting it together: the top-selling SKU

orders.stream()
      .flatMap(o -> o.lines().stream())
      .collect(Collectors.groupingBy(Line::sku, Collectors.summingInt(Line::qty)))
      .entrySet().stream()
      .max(Map.Entry.comparingByValue())
      .ifPresent(e -> System.out.println("Top SKU: " + e.getKey()));

Parallel streams — handle with care

orders.parallelStream() splits work across CPU cores. It can help for large, CPU-heavy, independent work. It usually hurts for small lists, I/O (API calls, database queries), or code with shared mutable state. Measure before using it — and for concurrent I/O, prefer CompletableFuture.

The full example

// AdvancedStreams.java — collectors, groupingBy, partitioningBy, flatMap, reduce.
import java.util.*;
import java.util.stream.*;

public class AdvancedStreams {

    record Line(String sku, int qty, double price) {
        double total() { return qty * price; }
    }

    record Order(String id, String customer, String status, List<Line> lines) {
        double total() { return lines.stream().mapToDouble(Line::total).sum(); }
    }

    public static void main(String[] args) {
        List<Order> orders = List.of(
                new Order("PO-1", "Acme", "SHIPPED", List.of(new Line("AX-200", 24, 8.5), new Line("BX-310", 2, 40))),
                new Order("PO-2", "Globex", "NEW", List.of(new Line("AX-200", 10, 8.5))),
                new Order("PO-3", "Acme", "NEW", List.of(new Line("CX-420", 1, 999), new Line("AX-200", 5, 8.5))));

        // groupingBy: customer -> list of orders
        Map<String, List<Order>> byCustomer = orders.stream()
                .collect(Collectors.groupingBy(Order::customer));
        System.out.println(byCustomer.keySet());

        // groupingBy with a downstream collector: customer -> total spend
        Map<String, Double> spend = orders.stream()
                .collect(Collectors.groupingBy(Order::customer, TreeMap::new,
                        Collectors.summingDouble(Order::total)));
        System.out.println(spend);                                  // {Acme=1325.5, Globex=85.0}

        // counting per status
        Map<String, Long> perStatus = orders.stream()
                .collect(Collectors.groupingBy(Order::status, TreeMap::new, Collectors.counting()));
        System.out.println(perStatus);                              // {NEW=2, SHIPPED=1}

        // partitioningBy: two groups, true and false
        Map<Boolean, List<String>> bigOrSmall = orders.stream()
                .collect(Collectors.partitioningBy(o -> o.total() > 200,
                        Collectors.mapping(Order::id, Collectors.toList())));
        System.out.println(bigOrSmall);                             // {false=[PO-2], true=[PO-1, PO-3]}

        // flatMap: from orders to all their lines
        Map<String, Integer> qtyPerSku = orders.stream()
                .flatMap(o -> o.lines().stream())
                .collect(Collectors.toMap(Line::sku, Line::qty, Integer::sum, TreeMap::new));
        System.out.println(qtyPerSku);                              // {AX-200=39, BX-310=2, CX-420=1}

        // joining
        String ids = orders.stream().map(Order::id).collect(Collectors.joining(", ", "[", "]"));
        System.out.println(ids);                                    // [PO-1, PO-2, PO-3]

        // reduce: combine everything into one value
        double grandTotal = orders.stream().map(Order::total).reduce(0.0, Double::sum);
        System.out.println("Grand total: " + grandTotal);

        // summary statistics in one pass
        DoubleSummaryStatistics stats = orders.stream().mapToDouble(Order::total).summaryStatistics();
        System.out.printf("min %.1f max %.1f avg %.1f%n", stats.getMin(), stats.getMax(), stats.getAverage());

        // teeing (Java 12+): two collectors at once
        String report = orders.stream().collect(Collectors.teeing(
                Collectors.counting(),
                Collectors.averagingDouble(Order::total),
                (count, average) -> count + " orders, average " + String.format("%.2f", average)));
        System.out.println(report);

        // Most ordered SKU
        orders.stream()
                .flatMap(o -> o.lines().stream())
                .collect(Collectors.groupingBy(Line::sku, Collectors.summingInt(Line::qty)))
                .entrySet().stream()
                .max(Map.Entry.comparingByValue())
                .ifPresent(e -> System.out.println("Top SKU: " + e.getKey() + " (" + e.getValue() + ")"));
    }
}

Try it yourself

Using the same orders, build a Map<String, Set<String>> from customer to the set of SKUs they ordered.

Show a solution
Map<String, Set<String>> skusByCustomer = orders.stream()
        .collect(Collectors.groupingBy(Order::customer, TreeMap::new,
                 Collectors.flatMapping(o -> o.lines().stream().map(Line::sku),
                                        Collectors.toCollection(TreeSet::new))));
System.out.println(skusByCustomer);   // {Acme=[AX-200, BX-310, CX-420], Globex=[AX-200]}

Collectors.flatMapping (Java 9+) is flatMap as a downstream collector.

Key takeaways

  • groupingBy + a downstream collector answers “per X, how many / how much / which”.
  • partitioningBy splits into true/false; toMap builds lookups (give it a merge function).
  • flatMap flattens nested collections.
  • joining, reduce, summaryStatistics and teeing summarise; parallel streams need measurement.

Example files