The introduction of the Stream API in Java marked a crucial step in the development of functional programming paradigms within the language. With Java 24, stream processing has been further consolidated and is now a central tool for declarative data processing Stream not about an alternative form of Collection, but rather an abstract concept that describes a potentially infinite sequence of data that is transformed and consumed through a pipeline of operations. While a Collection is a data structure that stores data, Stream is a carrier of a computing model: It does not store any data but instead allows the description of data flows.
- Getting started with streams in Java
- The power of composition
- Effects on the Classic Design Patterns in Java
- An example from classic Java to the use of streams
- Changes to the Streams API from Java 8 to 21 - an overview
- A concrete example of flatMap and mapMulti
- What’s new with Java 24 - Gatherer
- Practical examples with the Gatherer
Each stream processing can be divided into three logically separate phases. The starting point is always a source - one Collection, an array, an I/O channel or a created structure like Stream.generate(…) be. The source is followed by a number of Intermediate Operations , which transform, filter or sort the Stream. These operations are lazy , d. h. they are not executed immediately but are merely registered. Only through a final Terminal Operation , such as forEach, collect or reduce, execution is triggered and the entire pipeline is concretized. The Laziness principle allows the JVM to optimize, merge, or even eliminate operations without impacting the final result.
A fundamental feature of stream architecture is its uniqueness. A stream can be consumed exactly once. Once a terminal operation has been performed, the Stream is exhausted. This has profound implications for the design of stream-based algorithms, as one must carefully decide when and where to evaluate a pipeline. Compared to reusable data containers like lists, this requires a shift towards fluent, targeted processing.
The structure of a stream pipeline follows a well-defined structure. It begins with the definition of the data source, is completed by a sequence of intermediate operations and ends with a final consumption action. Under the hood, the JVM detects and analyzes this structure and performs a variety of optimizations. For example, the JVM can do operations like map and filter combine (Operation Fusion), activate parallel execution on suitable sources or avoid redundant calculations. These optimization options are only possible due to the declarative nature of streams and underline the paradigmatic difference to imperative code with classic loops.
In Java, the Stream API remains a key element for modern, expressive, and concurrent computing. However, understanding them requires a deep awareness of the differences from the conventional collection hierarchy - particularly in terms of laziness, single use, and JVM-powered optimizations that make streams a powerful tool in the Java developer’s repertoire.
Getting started with streams in Java#
Getting started with the Stream API in Java requires a basic understanding of functional programming concepts and the way Java integrates these concepts within an object-oriented language. Streams make it possible to describe data flows declaratively, with the focus not on the How but on the Where the data processing lies. This shift from imperative to declarative thinking forms the foundation for a modern, concise and, at the same time, expressive programming model.
A typical entry point into stream processing is the conversion of existing data structures, such as List- or Setinstances, into a stream. This is done using the method stream(), by most Collection implementations is provided. Alternatively, methods such as: Stream.of(…), Arrays.stream(…) or IntStream.range(…) can be used to create stream instances. Regardless of the source, this always creates a pipeline that is potentially evaluated with a delay.
After a stream is initialized, intermediate operations such as filter, map, or sorted are applied to transform or refine the data flow. These operations are purely descriptive and do not modify the original data source or trigger any calculation. Instead, you build a kind of recipe that is later executed through a terminal operation.
As mentioned before, the pipeline is completed by a terminal operation that triggers execution and delivers the result material. Only at this point is the pipeline evaluated, with all previously defined steps being applied to the data in one pass. This is a key difference from traditional processing using loops or iterators, where each processing step is executed immediately.
Especially at the beginning, it is helpful to experiment with simple examples, such as filtering character strings or transforming integers. This allows an intuitive understanding of the data flow, the chaining of operations and the underlying execution logic. The advantage of streams quickly becomes apparent: they promote a concise, readable and at the same time efficient expression for data-driven operations, without unnecessarily complicating the structure of the underlying code.
The power of composition#
The Java Stream API’s strength lies in its ability to process data declaratively and, above all, in its ability to form complex processing steps into a compositional unit. Streams allow transformation and filter logic to be defined modularly and combined elegantly, making even complex data flows comprehensible and reusable. The underlying idea is to understand arithmetic operations as functional units that can be fluidly combined into a processing chain.
From a software architecture perspective, stream composition opens an elegant way to modularize processing steps. For example, methods can be defined as those that encapsulate a specific stream transformation and can be embedded as a building block in a more extensive pipeline. By using higher-order functions, for example, by returning a Function<T, R>-Object, a repertoire of reusable processing modules is created that can be put together dynamically. This not only promotes readability, but also testability and maintainability of the code.
Another advantage is the clear separation of structure and behaviour. While imperative code typically mixes loop logic with specialized logic, stream composition allows a decoupled view: the type of data flow (e.g. sequential or parallel) is orthogonal to the question of what happens to the data in terms of content. This separation makes it easier to reuse or specifically expand existing pipelines in different contexts without fundamentally changing their structure.
Last but not least, the combination of different stream types expresses this compositional ability. So primitive streams (IntStream, LongStream, DoubleStream) with object streams via conversion methods like boxed() or mapToInt() be connected. The same can be said about flatMap, which realizes the processing of nested data structures, whereby a stream of containers is created into a flat data stream that can be processed further seamlessly.
Therefore, the ability to compose streams is more than just a syntactic convenience. It represents a fundamental paradigm shift in the way data flows are designed, structured, and executed in Java. Anyone who masters this approach can convert even complex application logic into clearly structured, functional modules—a gain in terms of both maintainability and expressiveness.
Effects on the Classic Design Patterns in Java#
The integration of the Stream API into Java has profound implications for the use and necessity of classic design patterns. Many patterns that emerged in object-oriented development emerged in response to the lack of functional means of expression. However, with the emergence of streams and their close integration with functional concepts such as lambdas and higher-order functions, the role of these patterns is fundamentally changing. This is particularly noticeable with patterns that were initially used to control the iteration, transformation or filtering of data structures.
A prominent example of this is the Iterator Pattern , which has been primarily made obsolete by streams. While the Iterator Pattern in classic Java was considered an idiomatic approach to processing collections sequentially, the Stream API completely encapsulates this responsibility. Access to the elements is implicit and declarative; the stream configuration controls the order and traversal behaviour. Manual control over iteration, as provided by the iterator pattern, is thus replaced by a more expressive and, at the same time, less error-prone abstraction.
That too Strategy Pattern undergoes a transformation through streams. Strategies that were previously coded as separate classes or interfaces with concrete implementations can now be elegantly implemented via lambdas and method-based composition within stream pipelines. Filtering or mapping strategies that were previously modelled through object-oriented inheritance hierarchies can now be defined inline and dynamically combined - a change that not only simplifies the source code but also increases its flexibility.
The Decorator Pattern , traditionally used to extend functionality through nested object structures dynamically, finds a modern equivalent in the stream world in the chaining of intermediate operations. Each operation transforms the Stream and adds another processing layer - not through inheritance or object composition, but through functional pipeline elements. The resulting structure is more compact and allows for a much more dynamic configuration at runtime.
Furthermore, stream architecture sheds new light on the Template Method Pattern. While this was initially used to define the structure of an algorithm and implement varying steps in subclasses, the Stream API allows such “algorithms” to be defined via methodically combined functions. The sequence of stream operations specifies the fixed processing steps, while passed functions specify concrete steps. This makes the behaviour more modular and decoupled from static inheritance.
Finally, streams also change the understanding of patterns like Chain of Responsibility or Pipeline. These patterns are designed to model flexible processing sequences in which each element acts optionally and can pass responsibility. Stream pipelines implement this principle at a functional level, with each intermediate operation corresponding to a “processing unit”. The big difference is that there is no need to chain objects together manually; instead, the processing chain results from the fluent syntax of the API itself.
Overall, streams in Java lead to a functional re-contextualization of classic design patterns. This does not make many patterns superfluous but instead transforms them in their implementation. They now appear less rigid class structures and more as dynamic, composable units that seamlessly fit into fluent APIs. For the informed developer, this not only opens up new possibilities for expression but also a reflective understanding of when a pattern in the classic sense is still necessary. But I will report on this in a separate post.
An example from classic Java to the use of streams#
A classic data processing algorithm in Java extracts, transforms, and aggregates information from a list of complex objects. Let’s take a list of as an example Person-Objects, each containing name, age and place of residence. The goal is to extract all the names of adults, sort them alphabetically and return them as a string separated by commas. The classic imperative-object-oriented implementation of this requirement in Java typically takes place via explicit loops, temporary lists and manual control structures.
You usually start by creating a new results list in such an implementation. The original data is then iterated over, in one if-Condition it is checked whether the age of the person in question is over 17. If so, the person’s name is added to the results list. Once the iteration is complete, the list is sorted alphabetically by explicitly calling the sort method. Finally, the list is created using a loop or by using a StringBuilder converted into a comma-separated string. This approach works correctly but requires a lot of intermediate steps and quickly leads to redundant or error-prone logic - for example, when sorting or formatting the result.
With the introduction of the Stream API, the same algorithm can be expressed in a much more concise and declarative manner. The entire data flow – from filtering to transformation to aggregation – can be modeled in a single expression unit. The Stream is derived from the list, then filters filter-Operation to remove adults. A subsequent one map-Transformation extracts the names. The sorting is done by sorted realized, and the final aggregation takes place via Collectors.joining(","). The entire algorithm can, therefore, be expressed in a fluent, clearly structured pipeline, the sub-steps of which are semantically self-explanatory through their method names.
The differences between the two approaches are both syntactic and conceptual. While the classic solution relies heavily on imperative thinking and controls each processing element manually, the stream approach allows declarative modeling of the “what” and leaves the “how” to be executed by the runtime environment. This level of abstraction promotes readability and maintainability of the code, as the developer can concentrate on the technical logic without being distracted by control flow constructs.
At the same time, it can be seen that streams also enable semantic compression. An algorithm that previously required several dozen lines is often reduced to a few clearly structured function calls. However, this compactness does not come at the expense of transparency - on the contrary, the descriptive method names and the fluent structure make the data flow explicitly understandable. The stream variant, therefore, not only appears more modern but also conceptually closer to the problem.
It can be said that the stream-based implementation integrates functional principles into the Java world without sacrificing type safety or object orientation. It makes it possible to formulate classic algorithms with a new level of abstraction that not only simplifies the code, but also expresses its intent more clearly.
But let’s look at it in the source code: Let’s assume that we have the following structures available for both implementations.
record Person(String name, int age, String city) {}
private static final List<Person> PEOPLE = List.of(
new Person("Alice", 23, "Berlin"),
new Person("Bob", 16, "Hamburg"),
new Person("Clara", 19, "München"),
new Person("David", 17, "Köln")
);Example in classic Java without streams#
List<String> adultNames = new ArrayList<>();
for (Person p : PEOPLE) {
if (p.age >= 18) {
adultNames.add(p.name);
}
}
Collections.sort(adultNames);
StringBuilder result = new StringBuilder();
for (int i = 0; i < adultNames.size(); i++) {
result.append(adultNames.get(i));
if (i < adultNames.size() - 1) {
result.append(", ");
}
}
System.out.println("Ergebnis: " + result);
}In the classic variant, the data flow is fragmented and distributed over several steps: filtering, collecting, sorting and formatting take place in separate sections, sometimes using temporary structures. This leads to increased complexity and potential sources of errors - for example when formatting the output string correctly.
Example in Java using Streams API#
public static void main(String[] args) {
String result = PEOPLE.stream()
.filter(p -> p.age() >= 18)
.map(Person::name)
.sorted()
.collect(Collectors.joining(", "));
System.out.println("Ergebnis: " + result);
}The stream variant, on the other hand, expresses the entire data processing process in a single pipeline. Each processing step is clearly named, the transformation takes place fluidly, and the result is immediately visible. Particularly noteworthy is the better one readability , the Reduction of side effects and the Extensibility – additional processing steps can be added by simply inserting additional operations into the pipeline without fundamentally changing the structure.
Changes to the Streams API from Java 8 to 21 - an overview#
Since its introduction in Java 8, the Stream API has fundamentally changed the way data is processed in Java. With the aim of enabling declarative, functionally inspired data flows, she established a new programming paradigm within the object-oriented language. The initial version was already remarkably expressive: it offered a clear separation between data source, transformation and terminal operation, supported lazy evaluation and could be executed both sequentially and in parallel. In particular the integration with lambdas, method references and the java.util.functionlibrary enabled a fluid, type-safe style of data processing that was previously only possible via external libraries or explicit iterators.
With the subsequent versions of the JDK, the Stream API was not fundamentally redesigned, but was continually expanded, refined and stabilized. Java 9 brought with it the first significant enhancements, such as the methods causeWhile, dropWhile and iterate with predicates that made it possible to control stream pipelines even more precisely and terminate them early. These extensions closed a semantic gap in the original API and, in particular, improved the ability to model more complex data flows. Also the introduction of Optional. Stream () was a notable move as it provided an elegant bridge between the types Optional and Stream and thus further increased the ability to compose in a functional style.
In Java 10 to 14, priority was given to accompanying language features such as our introduced, which did not directly change the Stream API itself, but improved its readability and applicability in many contexts. It was only with Java 16 and the consistent spread of records as compact data containers that stream processing became more expressive again, as it could now be combined with structured but immutable data models - a clear advantage for parallel or deterministic processing scenarios.
Java 17, as a long-term support version, consolidated the API through additional optimizations in the backend and further improved integration with pattern matching and modern sealed-Class hierarchy. These structural improvements did not introduce new methods in the Stream class itself, but enabled more precise handling of heterogeneous data flows and the formulation of domain-specific pipelines at a high level of abstraction.
It was only Java 21 that brought tangible expansions in the area of functional data processing, especially in interaction Scoped Values, structured concurrency and the further developed ForkJoinPool implementation. While the Stream API received hardly any new methods formally, its applicability in the context of concurrent and memory-optimized architectures was significantly strengthened. The increased performance with parallel execution and the more efficient management of intermediate results also reflect a maturation of the API at the implementation level. With Java 21, streams can not only be written more elegantly but also executed more securely and predictably - especially in asynchronous or reactive processing environments.
In summary, the Stream API has undergone a clear maturation process from Java 8 to Java 21. While its conceptual core remained essentially constant, its semantic scope was clarified, its integration with modern linguistic means was deepened, and its efficiency was optimized on several levels.
A concrete example of flatMap and mapMulti#
Conceptual difference#
flatMap was already introduced with Java 8 and is based on the idea of applying a function that creates a new one for each element of the original stream Stream produced. These nested streams are then “flat” merged so that the resulting Stream has a flat structure. This pattern is compelling and allows elegant transformations of nested data structures - for example Stream<List
mapMulti(), introduced in Java 16, takes a different approach. Instead of creating nested streams, one is created here Consumer based Interface used: The method receives one for each element of the Stream BiConsumer, through which the developer can directly deliver zero, one or more output values to the downstream - without creating temporary data structures such as lists or streams. This avoids heap allocations, which plays a particularly relevant role in performance-critical scenarios.
In the following example, let’s assume that we have the following data structure.
private static List<List<String>> DATA = List.of(
List.of("a", "b"),
List.of("c"),
List.of(),
List.of("d", "e")
);Example with flatMap#
List<String> result = DATA
.stream()
.flatMap(List::stream)
.collect(Collectors.toList());Here each inner list is converted into a stream and then “flattened”.
List<String> result = DATA
.stream()
.<String>mapMulti(Iterable::forEach)
.toList();In this case, no new stream instance is created. Instead, it is about the forEach-Loop the content passed directly to the consumer.
flatMap stands more for declarative clarity and conceptual simplicity. The method represents a fundamental principle of functional programming and is therefore particularly suitable if the transformation is based on existing Stream
mapMulti(), however, aims to optimize away. It allows transformations to be expressed more efficiently by granting direct control over the output. This reduces heap allocations, allows better memory locality and is therefore preferable from a JVM optimization perspective if performance is a priority. However, the semantic expression is more complex: the user must explicitly say so Consumer-Logic work, which can limit readability and comprehensibility for functionally less experienced developers.
You can say that flatMap, the idiomatic, functional way, remains, while mapMulti() represents a targeted tool for high-performance data processing. Their coexistence within the Stream API demonstrates Java’s ambition to combine both expressiveness and efficiency within the same paradigm - a balancing act between declarative elegance and system-level control.
What’s new with Java 24 - Gatherer#
With Java 24, the Stream API has been expanded to include a new concept called Gatherer company. This addition addresses a long-standing gap in stream processing: the ability to perform custom aggregations across multiple elements in a controlled, stateful manner - but not at the end of the pipeline, as is the case with Collector-Instances is the case, but while of the stream process itself, as an integral part of the transformation. Gatherers represent a new class of intermediate operations that were specifically designed for advanced data flow logic.
The fundamental motivation for introducing gatherers is the limitation of the previous API in dealing with compound, multi-step, or stateful operations, particularly in the case of transformations that do not just produce a single element per input, but require sequencing, delaying, or grouping across multiple input elements. Previous tools like map, flatMap or peek all operate either statelessly or with limited context. For more complex cases, specialized iterator implementations or external libraries had to be used - which contradicts the goal of declarative data flow modelling in streams.
Gatherers solve this problem through a new processing model based on a controlled state. Similar to CollectorInstances define a set of functions that allow elements to be buffered, transformed and emitted - not at the end of the Stream, but as part of the ongoing data flow. So they combine the conceptual strengths of Collector and mapMulti, but extend these to include explicit support for temporary states and flexible emission strategies. There will be one Sink used to deliver any results downstream - even several per input element or even delayed.
The purpose of this expansion lies not only in expressivity but also in performance. Many use cases, such as sliding windows, temporal aggregations, sequence analysis or transformation logic with history, can be modelled precisely and efficiently with gatherers without sacrificing the declarative structure of the pipeline. This provides the developer with a tool that explicitly allows intermediate states and context dependency within stream processing but is type-safe, controlled and idiomatically embedded in the existing API.
This opens up new perspectives in development. Gatherers enable complex data flow modelling with a declarative character without falling back into imperative control logic. They significantly expand the semantic space of streams and thus represent a logical next step in the evolution of stream architecture - comparable to the introduction of Collector or mapMulti. Its introduction shows that Java integrates functional principles and develops them further in state models, process control and efficiency - without the compromises that often accompany system-level optimization.
Practical examples with the Gatherer#
The introduction of gatherers not only created a new extension of stream processing but also provided a collection of predefined gatherers that address typical use cases that were previously difficult to model. These predefined gatherers combine declarative expressiveness with stateful transformation, enabling new forms of data stream processing - especially for sequential, grouped or sequentially correlated data flows. The central representatives of this new genre include windowFixed, windowSliding, fold and scan. Each of these gatherers brings specific behaviour that previously had to be implemented with considerable manual effort or even outside of the stream API.
windowFixed#
The Gatherer windowFixed allows a stream to be divided into equally sized, non-overlapping windows. This is particularly relevant if data is to be further processed or aggregated in blocks. Suppose we want to split a list of integers into groups of five and calculate the sum of each group. With windowFixed This is very elegant:
List<Integer> input = IntStream.rangeClosed(1, 15).boxed().toList();
List<Integer> resultGatherer = input.stream()
.gather(Gatherers.windowFixed(5))
.map(window -> window.stream().reduce(0, Integer::sum))
.toList(); // [15, 40, 65]
System.out.println("resultGatherer = " + resultGatherer);The alternative without a gatherer could look like this.
List<Integer> result = new ArrayList<>();
List<Integer> window = new ArrayList<>();
for (int i : input) {
window.add(i);
if (window.size() == 5) {
result.add(window.stream().reduce(0, Integer::sum));
window.clear();
}
}
System.out.println("result = " + result);The difference is clear: The Gatherer variant is not only more compact, but also clearly separates data flow and logic, while the imperative solution works with side effects and state management.
windowSliding#
A related but semantically more sophisticated case is given by windowSliding covered. These are sliding windows - each new element moves the window by one.
List<Integer> input = List.of(1, 2, 3, 4, 5);
List<Double> result = input.stream()
.gather(Gatherers.windowSliding(3))
.map(window -> window.stream().mapToInt(Integer::intValue).average().orElse(0))
.toList(); // [2.0, 3.0, 4.0]The given Java source code demonstrates an application des Gatherers windowSliding(n). This is used in this example to create sliding windows - so-called - over a given list of integers Sliding Windows – to generate. The database in this case is an immutable list of integers:
List
The subsequent stream processing aims to calculate the arithmetic mean (average) of the elements contained over each sliding window of size three. The method windowSliding(3) causes the data stream to be internally split into overlapping windows, with each window containing three consecutive elements. For the list [1, 2, 3, 4, 5] This results in the following windows:
- [1, 2, 3]
- [2, 3, 4]
- [3, 4, 5]
Within the mapoperator will turn each of these windows into one Stream
The resulting List
[2.0, 3.0, 4.0]
The code’s declarative style promotes readability and understanding, while the Gatherers-API provides an elegant way to perform sliding aggregations directly in the stream context without resorting to manual window logic or imperative loops.
possible optimizations#
Is Several optimization goals can be identified that can improve both the robustness against runtime errors and the performance and semantic readability of the code. Each of these goals brings different requirements for the design of the code.
Regarding performanceoptimization , it can be determined that the current source code creates unnecessary objects by repeatedly creating temporary ones, such as stream objects within the mapping. This can lead to a noticeable increase in the garbage collection load, especially with large amounts of data. To increase performance, you should ensure repeated use of Stream () within the window and instead directly with the existing one List
List<Double> result = input.stream()
.gather(Gatherers.windowSliding(3))
.map(window -> {
int sum = 0;
for (int i : window) sum += i;
return sum / 3.0;
})
.toList();This version completely eliminates the overhead of internal stream processing per window. Instead, the summation is done directly via a simple loop. This not only reduces the allocation of temporary objects, but allows the JVM to perform aggressive inlining and loop unrolling optimizations. In addition, the division by the window size is constant, since windowSliding(3) a fixed window size guaranteed. However, if the window size varies dynamically, you could alternatively window.size() determine at runtime without endangering semantic correctness.
Regarding the readability , It should be noted that functional elegance and imperatively expressed clarity are not necessarily in contradiction. The original version with mapToInt(…).average().orElse(0) Although it is syntactically compact, it is less immediately understandable for many developers, especially with regard to the treatment of OptionalDouble. The imperative variant with direct summation and division, on the other hand, clearly reveals the underlying intention - the calculation of an average over three elements - without a deep understanding of the Stream-API is required. For teams with heterogeneous levels of experience or in code bases with a strong focus on maintainability, the following version is often preferable:
List<Double> result = input.stream()
.gather(Gatherers.windowSliding(3))
.map(window -> {
int sum = 0;
for (Integer value : window) {
sum += value;
}
return (double) sum / window.size();
})
.toList();This variant avoids potentially confusing method chains and expresses the calculation step by step. This not only makes the code easier to understand, but also easier to test, as each calculation step can be validated individually. At the same time, it remains compact and uses modern Java constructs such as Stream.gather() in an idiomatic way.
Overall, it can be seen that by taking robustness, performance and readability into account, both the functional and structural quality of the code can be increased. The choice of specific optimization always depends on the application context and the requirements for runtime behavior, error handling and maintainability.
That should be it at this point for now. In the following post I will go into more detail about the practical use of streams, the use of gatherers and similar things.
Happy Coding
Sven





