Java Stream Gatherers Example
Java’s Stream API, introduced in Java 8, transformed the way we process collections. With expressive, functional-style operations like map
, filter
, and reduce
, Java Streams made code more concise and readable. But over time, we ran into limitations with what those fixed operations could do.
Java 24 addresses these gaps with a new feature: Stream Gatherers. This article explores the concept and typical use cases of Java Stream Gatherers.
1. What Is a Stream in Java?
A Stream in Java is a sequence of elements that can be processed in a functional style. It allows you to perform operations such as filtering, mapping, or reducing while keeping the original data source unchanged.
import java.util.List; import java.util.stream.Collectors; public class StreamExample { public static void main(String[] args) { List<String> names = List.of("Daniel", "Anna", "Zoe", "Mike", "Alex", "Diana"); List<String> result = names.stream() .filter(name -> name.startsWith("A")) .sorted() .collect(Collectors.toList()); System.out.println("Names starting with 'A':"); result.forEach(System.out::println); } }
We start with a list of names, then use filter()
to keep only those that begin with the letter “A”. The sorted()
method arranges these filtered names in alphabetical order, and collect(Collectors.toList())
gathers the final results into a new list.
2. What Are Stream Gatherers?
Stream Gatherers are a new feature in Java 24 that lets us define custom intermediate operations in a stream pipeline. Unlike Collector
, which is terminal, Gatherer operates mid-stream, allowing custom grouping, accumulation, transformation, and control over how elements flow downstream.
With Gatherers, we can change how stream elements are handled. For example, we can process items one at a time, combine many into one, or split one into several. We can also remember earlier items to help decide what to do with later ones and even stop the stream early if needed. This makes it easy to do things like group items into fixed-size chunks, remove repeated values, keep a running total or summary, or rearrange the order of items as they pass through the stream.
3. Stream Gatherers Built-in Methods
Java 24 includes several ready-to-use gatherers in the java.util.stream.Gatherers
class. Let’s explore some of the basic gatherer patterns that are built into JDK 24.
To demonstrate how Gatherers work, we will use a simple Order
record. We will define the record as:
import java.time.LocalDateTime; public record Order( Long id, String customerName, String product, String category, double amount, LocalDateTime orderDate ) {}
This Order
record will be used in all the examples below to showcase how gatherers work.
The windowFixed method
The windowFixed
gatherer batches elements into fixed-size groups.
public class FixedWindowExample { public static void main(String[] args) { List<Order> orders = List.of( new Order(1L, "Alice", "Phone", "Electronics", 699.99, LocalDateTime.now()), new Order(2L, "Bob", "Laptop", "Electronics", 1299.00, LocalDateTime.now()), new Order(3L, "Charlie", "Shoes", "Fashion", 89.99, LocalDateTime.now()), new Order(4L, "Diana", "T-shirt", "Fashion", 25.00, LocalDateTime.now()), new Order(5L, "Eve", "Blender", "Home", 120.50, LocalDateTime.now()), new Order(6L, "Thomas", "Telescope", "Optics and Instrumentation", 220.50, LocalDateTime.now()) ); System.out.println(" Fixed-size packages of 2 orders:"); orders.stream() .gather(Gatherers.windowFixed(2)) .forEach(group -> { System.out.println("\n Package:"); group.forEach(order -> System.out.println(" - " + order.customerName() + " ordered " + order.product())); }); } }
This code processes a list of Order
records in fixed batches of 2. The stream emits a new list after every two elements.
Sample Output:
Fixed-size packages of 2 orders: Package: - Alice ordered Phone - Bob ordered Laptop Package: - Charlie ordered Shoes - Diana ordered T-shirt Package: - Eve ordered Blender - Thomas ordered Telescope
The windowSliding method
The windowSliding
gatherer emits overlapping windows of data. Each window includes a fixed number of recent elements.
public class SlidingWindowExample { public static void main(String[] args) { List<Order> orders = List.of( new Order(1L, "Alice", "Phone", "Electronics", 699.99, LocalDateTime.now()), new Order(2L, "Bob", "Laptop", "Electronics", 1299.00, LocalDateTime.now()), new Order(3L, "Charlie", "Shoes", "Fashion", 89.99, LocalDateTime.now()), new Order(4L, "Diana", "T-shirt", "Fashion", 25.00, LocalDateTime.now()), new Order(5L, "Eve", "Blender", "Home", 120.50, LocalDateTime.now()), new Order(6L, "Thomas", "Telescope", "Optics and Instrumentation", 220.50, LocalDateTime.now()) ); System.out.println("\n Orders in sliding window of 3 (step = 1):"); orders.stream() .gather(Gatherers.windowSliding(3)) .forEach(window -> { System.out.println("\n"); window.forEach(order -> System.out.println(" - " + order.customerName() + " bought " + order.product())); }); } }
Sample Output:
Orders in sliding window of 3 (step = 1): - Alice bought Phone - Bob bought Laptop - Charlie bought Shoes - Bob bought Laptop - Charlie bought Shoes - Diana bought T-shirt - Charlie bought Shoes - Diana bought T-shirt - Eve bought Blender - Diana bought T-shirt - Eve bought Blender - Thomas bought Telescope
Every new element enters a window of 3, sliding forward one step. We get overlapping groups of 3.
The Gatherers.fold method
The fold
method in Gatherers
is used to reduce a stream into a single value by maintaining an accumulator. It works similarly to reduce()
in traditional streams but as a gatherer, it can be more flexible in how it is applied as part of a stream pipeline.
public class FoldExample { public static void main(String[] args) { List<Order> orders = List.of( new Order(1L, "Alice", "Phone", "Electronics", 699.99, LocalDateTime.now()), new Order(2L, "Bob", "Laptop", "Electronics", 1299.00, LocalDateTime.now()), new Order(3L, "Charlie", "Shoes", "Fashion", 89.99, LocalDateTime.now()), new Order(4L, "Diana", "T-shirt", "Fashion", 25.00, LocalDateTime.now()), new Order(5L, "Eve", "Blender", "Home", 120.50, LocalDateTime.now()), new Order(6L, "Thomas", "Telescope", "Optics and Instrumentation", 220.50, LocalDateTime.now()) ); // Build a summary string of customer names and their products orders.stream() .limit(3) .gather(Gatherers.fold( () -> "Order Summary:\n", (result, order) -> result + "- " + order.customerName() + " ordered " + order.product() + "\n" )) .findFirst() .ifPresent(System.out::println); } }
In this example, we use Gatherers.fold()
to build a single string that lists the names of customers and the product they ordered from the first three orders.
Sample output:
Order Summary: - Alice ordered Phone - Bob ordered Laptop - Charlie ordered Shoes
The Gatherers.scan method
The scan
method in Gatherers allows you to create a running accumulation by producing intermediate results at each step of the stream. It’s like a rolling fold()
that outputs values after each element is processed.
public class ScanExample { public static void main(String[] args) { List<Order> orders = List.of( new Order(1L, "Alice", "Phone", "Electronics", 699.99, LocalDateTime.now()), new Order(2L, "Bob", "Laptop", "Electronics", 1299.00, LocalDateTime.now()), new Order(3L, "Charlie", "Shoes", "Fashion", 89.99, LocalDateTime.now()), new Order(4L, "Diana", "T-shirt", "Fashion", 25.00, LocalDateTime.now()), new Order(5L, "Eve", "Blender", "Home", 120.50, LocalDateTime.now()), new Order(6L, "Thomas", "Telescope", "Optics and Instrumentation", 220.50, LocalDateTime.now()) ); // Build and print a progressive summary of customer and product System.out.println("Running order summary:"); orders.stream() .limit(3) // .gather(Gatherers.scan( () -> "Summary so far: ", (summary, order) -> summary + order.customerName() + " ordered " + order.product() + ", " )) .forEach(System.out::println); } }
In this example, we use Gatherers.scan()
to build a summary string that grows with each Order
, printing each intermediate result to show how the data accumulates as the stream progresses.
Sample output:
Running order summary: Summary so far: Alice ordered Phone, Summary so far: Alice ordered Phone, Bob ordered Laptop, Summary so far: Alice ordered Phone, Bob ordered Laptop, Charlie ordered Shoes,
The Gatherers.mapConcurrent method
The mapConcurrent()
method concurrently applies a transformation function to each element in a stream. We specify the level of parallelism and the mapping function.
public class MapConcurrentExample { public static void main(String[] args) { List<Order> orders = List.of( new Order(1L, "Alice", "Phone", "Electronics", 699.99, LocalDateTime.now()), new Order(2L, "Bob", "Laptop", "Electronics", 1299.00, LocalDateTime.now()), new Order(3L, "Charlie", "Shoes", "Fashion", 89.99, LocalDateTime.now()), new Order(4L, "Diana", "T-shirt", "Fashion", 25.00, LocalDateTime.now()), new Order(5L, "Eve", "Blender", "Home", 120.50, LocalDateTime.now()), new Order(6L, "Thomas", "Telescope", "Optics and Instrumentation", 220.50, LocalDateTime.now()) ); System.out.println("Generating shipping labels concurrently:"); orders.stream() .gather(Gatherers.mapConcurrent(2, order -> "Shipping Label - Order #" + order.id() + ": " + order.customerName() + " - Product: " + order.product())) .toList() .forEach(System.out::println); } }
In this example, Gatherers.mapConcurrent(2, ...)
enables up to two orders to be processed in parallel, which would help improve performance when working with large datasets.
4. Implementing Custom Gatherers
While built-in gatherers like windowFixed
, scan
, and mapConcurrent
are powerful, there are situations where custom behavior is needed, like skipping elements, tracking states, or combining logic. Java 24’s Gatherer
interface allows us to define exactly how input elements should be gathered into outputs.
A Gatherer defines four core functions that determine how elements are collected, processed, and transformed through the stream pipeline.
- Initializer: Creates the initial state (e.g., a
HashMap
or List) - Integrator: Accepts each element, processes it, and updates the state
- Combiner: Merges multiple states
- Finisher: Emits the final results based on the state
In the example below, we define a custom Gatherer
to group a list of Order
records by their category and return only the top N orders (in this case, 3) per category, sorted by a comparator (e.g., highest amount first).
public class GroupOrdersByCategory { public static void main(String[] args) { List<Order> orders = List.of( new Order(1L, "Alice", "Phone", "Electronics", 699.99, LocalDateTime.now()), new Order(2L, "Bob", "Laptop", "Electronics", 1299.00, LocalDateTime.now()), new Order(3L, "Charlie", "Shoes", "Fashion", 89.99, LocalDateTime.now()), new Order(4L, "David", "TV", "Electronics", 1099.49, LocalDateTime.now()), new Order(5L, "Eve", "Handbag", "Fashion", 249.99, LocalDateTime.now()), new Order(6L, "Frank", "Blender", "Home", 59.99, LocalDateTime.now()), new Order(7L, "Grace", "Oven", "Home", 499.99, LocalDateTime.now()), new Order(8L, "Heidi", "Watch", "Fashion", 199.99, LocalDateTime.now()), new Order(9L, "Ivan", "Tablet", "Electronics", 399.00, LocalDateTime.now()), new Order(10L, "Judy", "Microwave", "Home", 120.00, LocalDateTime.now()), new Order(11L, "Karl", "Dress", "Fashion", 149.99, LocalDateTime.now()), new Order(12L, "Laura", "Camera", "Electronics", 850.00, LocalDateTime.now()) ); Map<String, List<Order>> topOrdersByCategory = orders.stream() .gather(groupByWithLimit( Order::category, // Group by category 3, // Limit to top 3 Comparator.comparing(Order::amount).reversed() // Highest price first )) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); printTopOrders(topOrdersByCategory); } public static <K> Gatherer<Order, Map<K, List<Order>>, Map.Entry<K, List<Order>>> groupByWithLimit( Function<? super Order, ? extends K> keyExtractor, int limit, Comparator<? super Order> comparator) { return Gatherer.of( HashMap<K, List<Order>>::new, (map, order, downstream) -> { K key = keyExtractor.apply(order); map.computeIfAbsent(key, k -> new ArrayList<>()).add(order); return true; }, (map1, map2) -> { map2.forEach((key, orders) -> map1.merge(key, orders, (list1, list2) -> { list1.addAll(list2); return list1; })); return map1; }, (map, downstream) -> { map.forEach((key, orders) -> { List<Order> limited = orders.stream() .sorted(comparator) .limit(limit) .toList(); downstream.push(Map.entry(key, limited)); }); } ); } private static void printTopOrders(Map<String, List<Order>> groupedOrders) { groupedOrders.forEach((category, orders) -> { System.out.println("Top orders for category: " + category); orders.forEach(order -> System.out.printf(" - %s bought %s for $%.2f%n", order.customerName(), order.product(), order.amount())); }); } }
The initializer sets up the initial mutable state used to accumulate results, in this case an empty HashMap<K, List<Order>>
. Each key represents a category such as “Electronics” or “Fashion” and its value is a list of related orders. The integrator handles each stream element by using a key extractor to determine the category and then adds the order to the appropriate list using computeIfAbsent
.
The combiner merges two partial results, which is useful for parallel stream processing. It combines entries from the second map into the first, merging lists when keys match or adding new entries otherwise. Finally, the finisher processes the completed map. It sorts each category’s orders using a comparator, limits each group to the top N results, and emits them as Map.Entry
pairs. These are then collected downstream into the final result.
Sample Output:
Top orders for category: Fashion - Eve bought Handbag for $249.99 - Heidi bought Watch for $199.99 - Karl bought Dress for $149.99 Top orders for category: Electronics - Bob bought Laptop for $1299.00 - David bought TV for $1099.49 - Laura bought Camera for $850.00 Top orders for category: Home - Grace bought Oven for $499.99 - Judy bought Microwave for $120.00 - Frank bought Blender for $59.99
5. Conclusion
In this article, we explored the new Gatherer
interface introduced in Java 24 as part of the Stream API enhancements. Through some examples, we demonstrated the use of built-in Gatherers like windowFixed
, windowSliding
, fold
, scan
, and mapConcurrent
. We also implemented custom Gatherers, showing how to group and limit results. With these capabilities, Gatherers bring a new level of expressiveness and control to Java stream pipelines, enabling developers to handle complex data transformations more cleanly and efficiently.
6. Download the Source Code
This article explored Java Stream Gatherers introduced in Java 24.
You can download the full source code of this example here: java stream gatherers