
Example: Running total with streams gatherers
The following example demonstrates the benefits of stream gatherers:
Stream.of(2, 4, 6, 8)
.gather(Gatherers.scan(() -> 0, Integer::sum))
.forEach(System.out::println);
// 2, 6, 12, 20Each emitted value includes the cumulative sum so far. The stream remains lazy and free of side-effects.
Like any technology, stream gatherers have their place. Use stream gatherers when the following conditions are true:
- The application involves sliding or cumulative analytics.
- The application produces metrics or transformations that depend on previous elements.
- The operation includes sequence analysis or pattern recognition.
- The code requires manual state with clean, declarative logic.
Gatherers restore the full expressive power of Java streams for stateful operations while keeping pipelines readable, efficient, and parallel-friendly.
Combining and zipping streams
Sometimes you need to combine data from multiple streams; an example is merging two sequences element by element. While the Stream API doesn’t yet include a built-in zip() method, you can easily implement one:
import java.util.*;
import java.util.function.BiFunction;
import java.util.stream.*;
public class StreamZipDemo {
public static Stream zip(
Stream a, Stream b, BiFunction combiner) {
Iterator itA = a.iterator();
Iterator itB = b.iterator();
Iterable iterable = () -> new Iterator<>() {
public boolean hasNext() {
return itA.hasNext() && itB.hasNext();
}
public C next() {
return combiner.apply(itA.next(), itB.next());
}
};
return StreamSupport.stream(iterable.spliterator(), false);
}
// Usage:
public static void main(String[] args) {
zip(Stream.of(1, 2, 3),
Stream.of("Duke", "Juggy", "Moby"),
(n, s) -> n + " → " + s)
.forEach(System.out::println);
}
}
The output will be:
1 → Duke
2 → Juggy
3 → MobyZipping pairs elements from two streams until one runs out, which is perfect for combining related data sequences.
Pitfalls and best practices with Java streams
We’ll conclude with an overview of pitfalls to avoid when working with streams, and some best practices to enhance streams performance and efficiency.
Pitfalls to avoid when using Java streams
- Overusing streams: Not every loop should be a stream.
- Side-effects in
map/filter: Retain pure functions. - Forgetting terminal operations: Remember that streams are lazy.
- Parallel misuse: Helps CPU-bound work but hurts I/O-bound work.
- Reusing consumed streams: One traversal only.
- Collector misuse: Avoid shared mutable state.
- Manual state hacks: Use gatherers instead.
Best practices when using Java streams
To maximize the benefits of Java streams, apply the following best practices:
- Keep pipelines small and readable.
- Prefer primitive streams for numbers.
- Use
peek()only for debugging. - Filter early, before expensive ops.
- Favor built-in gatherers for stateful logic.
- Avoid parallel streams for I/O; use virtual threads instead.
- Use the Java Microbenchmark Harness or profilers to measure performance before optimizing your code.
Conclusion
The advanced Java Stream API techniques in this tutorial will help you unlock expressive, high-performance data processing in modern Java. Short-circuiting saves computation; parallel streams use multiple cores; virtual threads handle massive I/O; and gatherers bring stateful transformations without breaking the declarative style in your Java code.
Combine these techniques wisely by testing, measuring, and reasoning about your workload, and your streams will remain concise, scalable, and as smooth as Duke surfing the digital wave!
Now it’s your turn: Take one of the examples in the Java Challengers GitHub repository, tweak it, and run your own benchmarks or experiments. Practice is the real challenge—and that’s how you’ll master modern Java streams.

