Diving: Streams API
Before going into this, let’s go over declarative programming. It’s a programming paradigm where you write code that describes what you want to achieve rather than explicitly detailing how to achieve it. Instead of outlining step-by-step commands or control flow using loops, you define the desired outcome and leave the execution steps to the system or compiler.
Java mostly used to rely on imperative programming, which focuses on how to achieve results. However, version by version, it has become more declarative. The Streams API, along with Lambda Expressions, was the first major declarative feature added in Java 8.
This means that before Java 8, the logic of what we were doing often got buried inside how we were doing it.
// not using streams API
List<String> result = new ArrayList<>();
Collections.sort(...);
for(Employee e : employees){
if(...){
result.add(e.getName());
if(result.size()==5)
break;
}
}// using streams api
employees.stream()
.filter(...)
.sorted(...)
.limit(5)
.map(...)
.toList();What is a stream?
A Stream is essentially an iterator. It doesn’t store anything; it acts like a conveyor belt for elements to be processed. And this conveyor belt doesn’t move backwards.
Here’s a simple example:
Stream<Integer> stream = list.stream();
s.forEach(System.out::println);
s.forEach(System.out::println);The second forEach() will throw an exception because a stream can only be consumed once.
Pipeline of a stream:
Every stream has:
- a source
- intermediate operations
- a terminal operation
Without a terminal operation, nothing happens. This is known as lazy evaluation.
How does Stream process elements?
Unlike what I initially thought, the Streams API doesn’t process one operation over the entire collection and then move to the next.
Instead, it processes one element through the entire pipeline before moving to the next element. This is known as pipeline fusion.
list.stream()
.filter(x->x%2==0)
.map(x->x*x)
.limit(2)
.toList();
//it doesn't filter the list and then map it
//it works through each element till the terminal operationStreams use internal iteration, meaning Java controls the iteration rather than us using a for loop. This has several advantages:
- Parallelism
- Optimization
- Short-circuiting
- Lazy evaluation
Intermediate Operations:
Think of these as the actual processing performed on the elements.
They are of two types:
Stateless
These only require the current element. No previous knowledge is needed.
Examples:
filtermap
Stateful
These require knowledge of other elements.
Get Taha Jamal’s stories in your inbox
Join Medium for free to get updates from this writer.
Examples:
sorteddistinct
Stream.sorted()for object streams ultimately relies on Java's object sorting implementation, which uses TimSort. It's stable, adaptive, and runs in O(n log n) time in the general case. For primitive streams likeIntStream.sorted(), the JDK uses the primitive array sort implementation (Dual-Pivot Quicksort). Also,sorted()is a stateful intermediate operation because it must buffer the entire stream before it can produce any output.
Terminal Operations:
These trigger execution. Think of them as collecting the output of the processing pipeline.
Examples:
count()collect()toList()min()anyMatch()
Primitive Streams:
Regular streams convert every int into an Integer object, which introduces boxing overhead. The same applies to long and double (other primitive types don't have specialized stream implementations because they wouldn't provide much benefit).
Instead, Java provides primitive streams like IntStream, which operate directly on primitive int values without boxing. They also provide built-in operations such as:
sum()average()max()
Usage of Optional in Streams:Optional is commonly used with stream operations because methods like findFirst() or max() may not find a result. Instead of returning null, they return an Optional, making the absence of a value explicit.
//Optional Usage with Streams API(orElse/orElseThrow()/ifPresent() > can be used)
List<String> stringList = Arrays.asList("Spring", "Hibernate", "Struts", "Zuarkus");
Optional<String> foundString = stringList.stream()
.filter(name->name.startsWith("S"))
.findFirst();
String fallback = stringList.stream().filter(name->name.startsWith("Z"))
.findFirst().orElse("Default String");
System.out.println("Result: "+fallback);Spliterator (Streams is built on top of this)
Both stream() and parallelStream() are built on top of a Spliterator.
A sequential stream primarily uses methods like tryAdvance() or forEachRemaining() to traverse elements one by one.
A parallel stream additionally uses trySplit() to divide the data into smaller chunks, which are then processed concurrently by worker threads from the ForkJoinPool.
The key difference isn’t the data structure they use, it’s whether the Spliterator is used only for traversal or also for splitting the work.
And apparently collectors have characteristics
A Collector exposes characteristics that describe its behavior to the Stream framework.
- IDENTITY_FINISH means the accumulator result is already the final result, so no finishing transformation is needed.
- UNORDERED means the collector doesn’t depend on encounter order, allowing more optimization, especially in parallel streams.
- CONCURRENT means multiple threads can safely accumulate into the same result container, avoiding the need for separate containers and merge steps during parallel execution.
Did you know? Streams can be infinite Stream.generate(Math::random)
Remember Streams are not always faster.
Simple loops are often faster because
- no lambda allocations
- less abstraction
- JVM optimization
Streams improve
- readability
- composability
- maintainability
not necessarily speed.








