With Java 22 introducing Stream Gatherers (JEP 461), developers gained a powerful extension point to transform stream elements in custom ways. However, if you read the official JavaDoc for java.util.stream.Gatherer, you might encounter this requirement:

"Each invocation of initializer(), integrator(), combiner(), and finisher() must return a semantically identical result."

If you're wondering whether this means you need to cache and return the exact same object reference on every call, or why the stream framework invokes methods like integrator() multiple times, this guide breaks it down clearly.

What Does "Semantically Identical" Mean?

In Java, we often think of equality in terms of referential identity (==) or value identity (equals()). However, in functional programming and specification design, "semantically identical" refers to behavioral equivalence.

It means that every time the Stream framework calls initializer(), integrator(), combiner(), or finisher(), the returned function or object must exhibit the exact same logic and behavior. It does not mean you must return the exact same object instance in memory.

Referential vs. Behavioral Identity Example

Returning a new lambda expression on every method call produces different object references in memory, but they are semantically identical because they do the exact same thing:

// Correct: Returns a new lambda each time, but behavior is identical.
@Override
public Gatherer.Integrator<State, Element, Output> integrator() {
    return (state, element, downstream) -> {
        // Perform integration logic...
        return true;
    };
}

Conversely, returning a function that relies on external mutable variables or produces different output logic depending on when it was requested breaks semantic identity:

// INCORRECT: Non-semantically identical behavior (stateful across calls)
private int invocationCount = 0;

@Override
public Gatherer.Integrator<State, Element, Output> integrator() {
    invocationCount++;
    return (state, element, downstream) -> {
        if (invocationCount > 1) {
            // Behaves differently based on when integrator() was called!
        }
        return true;
    };
}

Why Does the Stream Framework Call integrator() Multiple Times?

You might observe during debugging that the stream implementation calls integrator() twice or more, even for a simple sequential stream. This happens for several internal reasons:

  • Capability Inspection: The Stream execution engine may query the integrator to check its characteristics (e.g., whether it is a greedy integrator or short-circuiting) before setting up the execution pipeline.
  • Pipeline Construction vs. Execution: Gathering components are queried at different stages of stream execution pipeline setup.
  • Parallel Stream Execution: For parallel streams, multiple threads require independent instances of internal state and functions to execute concurrently without data races.

How to Correctly Implement Custom Gatherers

To ensure your custom gatherers adhere to the specification and work predictably in both sequential and parallel streams, follow these best practices:

1. Keep Component Functions Pure

Do not maintain mutable state inside your Gatherer class implementation or inside the factory methods. All state required during the stream transformation should live entirely inside the state object instantiated by the initializer().

2. Prefer Factory Methods

In most cases, you don't need to manually implement the Gatherer interface directly using class declarations. Instead, use the built-in static factory methods on Gatherer:

Gatherer<Integer, ?, String> toStringGatherer = Gatherer.of(
    () -> new StringBuilder(), // initializer
    Gatherer.Integrator.ofGreedy((state, element, downstream) -> {
        state.append(element);
        return downstream.push(state.toString());
    }) // integrator
);

Summary

The requirement that methods return a semantically identical result is a behavioral constraint, not a memory constraint. You do not need to cache your lambdas or instances manually. As long as every invocation returns functions that behave consistently without unexpected side effects or dynamic state changes, your custom Java Stream Gatherer will function smoothly.