Core Java

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.

Download
You can download the full source code of this example here: java stream gatherers

Omozegie Aziegbe

Omos Aziegbe is a technical writer and web/application developer with a BSc in Computer Science and Software Engineering from the University of Bedfordshire. Specializing in Java enterprise applications with the Jakarta EE framework, Omos also works with HTML5, CSS, and JavaScript for web development. As a freelance web developer, Omos combines technical expertise with research and writing on topics such as software engineering, programming, web application development, computer science, and technology.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button