DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Java Stream API: 3 Things Every Developer Should Know About
  • Optimizing Java Applications: Parallel Processing and Result Aggregation Techniques
  • Functional Approach To String Manipulation in Java
  • Techniques You Should Know as a Kafka Streams Developer

Trending

  • Building a Skill-Based Agentic Reviewer with Claude Code: A Practical Guide Using Skills.MD, MCP Servers, Tools, and Tasks
  • The Middleware Gap in AI Agent Frameworks
  • Evolving Spring Boot APIs to an Event-Driven Mesh
  • A Scalable Framework for Enterprise Salesforce Optimization: Turning Outcomes Into an Operating System
  1. DZone
  2. Coding
  3. Java
  4. Gatherers in Java: What They Are and Why They Matter

Gatherers in Java: What They Are and Why They Matter

With Stream API, developers could only customize terminal operations using Collector. Stream Gatherers allow developers to define custom intermediate operations.

By 
Ammar Husain user avatar
Ammar Husain
DZone Core CORE ·
Nov. 11, 25 · Analysis
Likes (8)
Comment
Save
Tweet
Share
5.0K Views

Join the DZone community and get the full member experience.

Join For Free

Java 8, released more than a decade ago, is a major milestone. With this version, Java brought a fundamental shift from only being an object-oriented programming (OOP) to a combination of OOP and functional programming (FP) as well. To achieve this, Java 8 came in with support of lambdas, stream APIs, etc., as core language features.

Stream API is influenced and modeled after the collection pipeline. A typical stream has three stages, viz., source, intermediate operations, and terminal operations.

  • A source is something that either already has or generates the elements for consumption. It could be a prepopulated collection like Listor a Set. Alternatively, a stream can be generated viz Stream.of or Stream.generate factory methods.
  • Intermediate operations are APIs that transform or filter the elements supplied via the source. E.g., filter ,map, sort, distinct etc. It could either be stateless or stateful, depending upon the nature of operations it performs.
  • Terminal operation is the final stage of a stream pipeline, which concludes the processing by producing the final results or side effects. Once invoked, the terminal operation closes the stream and prevents reuse. E.g., reduce , collect, count, min/max, etc.

While powerful, the Stream API has long had a limitation — developers could only customize terminal operations using Collector. Intermediate operations were fixed — until now.

With Java 24, stream gatherers arrive as a game-changing addition. They allow developers to define custom intermediate operations, enabling more expressive, reusable, and efficient stream pipelines. Previously, such custom logic required imperative workarounds or post-processing — often clunky and inefficient. Gatherer bridge this gap, offering a first-class, composable way to extend the stream model without compromising readability or performance.

Gatherers

Definition

From the docs themselves, a Gatherer is defined as:

A gatherer is an intermediate operation that transforms a stream of input elements into a stream of output elements, optionally applying a final action when it reaches the end of the stream of input elements.

At its core Gatherer can do the following:

  • Transform elements in a one-to-one, one-to-many, many-to-one, or many-to-many fashion.
  • Track previously seen elements to influence the transformation of later elements.
  • Short-circuit, or stop processing input elements to transform infinite streams to finite ones.
  • Process a stream in parallel.

Stream pipeline

Stream pipeline — with customization for intermediate and terminal operations via gatherers and collectors respectively.


Simply put, a Gatherer is same as to intermediate operations what a Collector is for terminal operations.

Details

Suppose we need to slice a stream with a fixed size of 3 and limit the output to maximum of 2 slices.

Plain Text
 
Input  => 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
Output => [0, 1, 2], [3, 4, 5] 


With existing stream APIs, one way to achieve it is to slice the stream during post processing, i.e., collect all the stream elements, keep a track of previous elements and windows and generate the output. The resultant code, as seen below, is fairly complicated, unintuitive and difficult to understand or maintain.

Java
 
IntStream.range(0, 10)
         .boxed()
         .limit(3 * 2) // window size * number of windows
         .collect(Collector.of(
                        () -> new ArrayList<ArrayList<Integer>>(),
                        (groups, element) -> {
                            if (groups.isEmpty() || groups.getLast().size() == 3) {
                                var current = new ArrayList<Integer>();
                                current.add(element);
                                groups.addLast(current);
                            } else {
                                groups.getLast().add(element);
                            }
                        },
                        (left, right) -> {
                            throw new UnsupportedOperationException("Cannot be parallelized");
                        }
                ))
          .forEach(System.out::println);


With built-in Gatheres.windowFixed the same can be achieved with clean, concise, intuitive and maintainable code as seen below.

Java
 
IntStream.range(0, 10)
         .boxed()
         .gather(Gatherers.windowFixed(3))
         .limit(2)
         .forEach(System.out::println);


Built-In Gatherers

Since not all use cases can be added as stream APIs to maintain learnability and API discovery, Java comes in with below built-in Gatherers to address common use cases.

Fixed Window

As seen in previous section, this gathers elements into windows of a fixed size. If the stream is empty then no window will be produced. The last window may contain fewer elements than the supplied window size. The window maintains the encountered order of elements.

Sliding Window

This gathers elements into windows of a given size, where each subsequent window includes all elements of the previous window except for the least recent, and adds the next element in the stream. If the stream is empty then no window will be produced. If the size of the stream is smaller than the window size then only one window will be produced, containing all elements in the stream. The window maintains the encountered order of elements.

Plain Text
 
Input  => 0, 1, 2, 3, 4, 5

Gatherers.windowSliding(3)

Output => [0, 1, 2], [1, 2, 3], [2, 3, 4], [3, 4, 5]


Fold

This performs an ordered, reduction-like, transformation on top of terminal operations; with no intermediate results. Useful if intermediate reduce is required and need to continue with stream computations. 

For e.g., to concatenate stream of characters and convert them to uppercase, it can be achieved as seen below →

Java
 
List.of("a", "b", "c", "d", "e")
    .stream()
    .gather(Gatherers.fold(() -> "", String::concat))
    .map(String::toUpperCase)
    .forEach(System.out::println);

Output => ABCDE


Note:

  • Since a stream.gather returns a stream itself, it can be further operated upon until a terminal operation is invoked. Unlike a reduce operation which is terminal and closes the stream.
  • Semantically this example can be achieved via existing stream APIs as below — but is inefficient.
Java
 
List.of("a", "b", "c", "d", "e")
    .stream()
    .map(String::toUpperCase) // operates on each element
    .reduce(String::concat)
    .ifPresent(System.out::println);

Output => ABCDE


Scan

This performs a Prefix Scan (an incremental accumulation) using the provided functions. Starting with an initial value, each subsequent value is obtained by accumulating the current value and the next input element. scan differs with fold in the sense scan produces an intermediate reduced results.

For e.g., to concatenate stream of characters and convert them to uppercase can be achieved as below (with intermediate results too):

Java
 
List.of("a", "b", "c", "d", "e")
    .stream()
    .gather(Gatherers.scan(() -> "", String::concat))
    .map(String::toUpperCase)
    .forEach(System.out::println);

Output => A, AB, ABC, ABCD, ABCDE


This is typically useful, for any processing where intermediate results are required too. E.g., for a bank statement to reflect the account balance after each transactions.

Map Concurrent

Similar to map operation, but with concurrent execution at specified maximum concurrency level while maintaining the stream order. Do note in case of any exception encountered or early stream termination, the ongoing mapping tasks are cancelled on best-effort basis. Thus, the mapper supplied should be resilient towards cancellations.

Moreover, while parallelStream utilizes common fork join pool, mapConcurrent uses virtual threads for execution. Thus, it is typically useful in case the mapper involves I/O calls for which usage of virtual threads can benefit a lot.

Custom Gatherers

Just like Collector interface is available for any custom terminal operations, Gatherer interface too can be implemented for any custom intermediate operations.

However, before implementing any custom Gatherer consider below →

  • Most of the use cases are already available via existing intermediate stream operations, try attempting a solution utilizing them first — with considering various aspects viz. readability, ease of understanding and maintenance, performance etc.
  • In case above doesn’t suffice then check the built-in Gatherers APIs.
  • Still if custom Gatherer need is felt, pause and think how many times did we actually used a custom Collector ever!

In short — use a custom Gatherer as last resort only and with utmost caution. Remember — just because we can, we shouldn’t!

Fundamentals

Below are few fundamentals which a developer should be aware before customizing a Gatherer →

Flow

In a stream data flows down while control flows up. I.e., once the intermediate/terminal operation satisfies any optional conditions then it signals upstream to stop pushing more data. E.g., limit, findFirst, findAny, takeWhile, dropWhileetc. signals the upstream to stop sending more elements once the specified conditions are met. These could be stateful or stateless operations too.

Stream pipeline flow

Stream pipeline flow — data flows down while control flows up.

Stage: Each Gatherer represents a pipeline stage which should achieve following

  1. Receive elements (data) to process.
  2. Process elements.
  3. Optionally transform and push elements to downstream.
  4. Ask downstream if it requires more elements and pass this details to upstream (control).

Characteristics:

  • Gatherer.of(..) means the stream runs sequential or parallel respectively — as characterized while instantiated.
  • Gatherer.ofSequential(..) means the stream runs sequential in parallel stream — essential to enforce the processing especially when its stateful. E.g., distinct, sorted etc.
  • A Gatherer could be characterized as either of four below, depending upon the complexity of processing. The leftmost is easiest with gradual increase in difficulty as moved towards right. Thus, prefer a Gatherer with characteristics towards left as much as possible.

Stream gatherer characteristics

Stream gatherer characteristics — complexity increases as we go right.

Contract:

A Gatherer is composed of four functions →

  1. Initializer (optional) — Provides a private state object used during stream processing to remember the previous element for comparison.
  2. Integrator (required) — Processes each input element which can emit zero or more output elements via a Downstream object. It can also short-circuit the stream by returning false.
  3. Combiner (optional) — Supports parallel stream processing by merging state objects. If omitted, the gatherer runs sequentially even in a parallel stream. Useful for associative operations like summing or collecting.
  4. Finisher (optional) — Called after all elements are processed. It can emit final results or perform cleanup.

Its imperative to know how a call to Stream.gather(gatherer) works stepwise →

  1. A Downstream object is created to pass output to the next stage.
  2. The initializer creates a state object.
  3. The integrator processes each input element, possibly emitting output.
  4. If the integrator returns false, the stream terminates immediately.
  5. After all elements, the finisher (if present) is invoked.

Example

Suppose, for a list of integers, only increasing numbers should be retained. A custom Gatherer should be stateful and sequential to achieve it.
Stateful — as only integer greater than previous should be retained.
Sequential — as it should maintain the order of elements. In case of parallel processing the result will be indeterministic.

Gatherer<Integer, int[], Integer> INCREASING_ONLY =
            Gatherer.ofSequential( // enfore sequential processing for deterministic processing
                    () -> new int[1], // state - maintain last seen value
                    (state, input, downstream) -> {
                        if (input > state[0]) {
                            state[0] = input; // update current as now last seen
                            downstream.push(input); // emit the element to downstream
                        }
                        return true; // continue execution
                    }
            );

List.of(1, 3, 2, 5, 4, 6, 8, 7, 9)
    .stream()
    .gather(INCREASING_ONLY)
    .forEach(System.out::println);


Output => 1, 3, 5, 6, 8, 9


Every standard stream operation can be expressed as a gatherer, it would be a good exercise to attempt implement them as custom Gatherer. For e.g.,

  • map — stateless one-to-one gatherer transforming elements.
  • filter — stateless gatherer emitting only if condition is true
  • flatMap — stateless one-to-many gatherer transforming and emitting all elements.
  • distinct — stateful gatherer tracking seen elements.

Conclusion

Stream Gatherers in Java 24 mark a pivotal evolution in the Stream API, empowering developers with customizable intermediate operations that were previously out of reach. By bridging the gap between rigid pipelines and expressive transformations, Gatherers unlock new possibilities for clean, efficient, and reusable stream logic. While built-in Gatherers cover common patterns, custom implementations should be approached with care to preserve readability and performance. For engineers and architects, mastering Gatherers means gaining finer control over data flow and pipeline behavior — an essential skill as Java continues its journey into more functional and declarative paradigms.

References and Further Reads

  • JEP 485
  • Stream Gatherers
  • Extending Functional Pipeline with Gatherers
  • Collection Pipeline
API Java (programming language) Stream (computing)

Published at DZone with permission of Ammar Husain. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Java Stream API: 3 Things Every Developer Should Know About
  • Optimizing Java Applications: Parallel Processing and Result Aggregation Techniques
  • Functional Approach To String Manipulation in Java
  • Techniques You Should Know as a Kafka Streams Developer

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook