Java Streams Basics
Java Streams Basics
A Stream is a sequence of elements that runs through a chain of operations — filter this out, transform that, collect the result — in a single pass, read top to bottom in the same order it actually executes. Java 8 introduced it alongside lambdas and functional interfaces specifically to replace the general-purpose external loop, where filtering, transforming, and accumulating results all lived inside one block of mutable, imperative code. A Stream is not a data structure and does not store anything; it processes elements that already live somewhere else — a List, an array, a generator — and every method covered in this series so far, Predicate, Function, Consumer, is what actually powers the operations chained onto it.
What Is a Stream?
java.util.stream.Stream<T> represents a sequence of elements that supports sequential or parallel aggregate operations, built from a source such as a Collection, an array, or a generator function. It carries a few properties that consistently surprise anyone coming from plain loops: a stream does not store elements and does not modify its source, it is lazy — intermediate operations do nothing until a terminal operation actually triggers them — and it can be consumed exactly once, throwing an exception the moment a second terminal operation is attempted on it.
Every stream pipeline has the same three-part shape: a source that produces elements, zero or more intermediate operations that each return a new stream, and exactly one terminal operation that finally runs the whole pipeline and produces a result.
Why Streams Were Introduced
Filtering, transforming, and accumulating a result from a collection used to mean writing a loop that did all three at once, with a mutable variable tracking progress along the way.
1// File: BeforeStreams.java
2import java.util.*;
3
4public class BeforeStreams {
5 record Order(String id, double amount, String status) {}
6
7 public static void main(String[] args) {
8 List<Order> orders = List.of(
9 new Order("ORD-1", 450.0, "DELIVERED"),
10 new Order("ORD-2", 899.0, "CANCELLED"),
11 new Order("ORD-3", 1200.0, "DELIVERED")
12 );
13
14 List<Double> deliveredAmounts = new ArrayList<>();
15 for (Order order : orders) {
16 if (order.status().equals("DELIVERED")) {
17 deliveredAmounts.add(order.amount());
18 }
19 }
20
21 double total = 0;
22 for (double amount : deliveredAmounts) {
23 total += amount;
24 }
25
26 System.out.println("Delivered revenue: " + total);
27 }
28}Output:
Delivered revenue: 1650.0
The same logic as a stream pipeline reads as a description of the result, not a set of instructions for building it — filter the delivered orders, read out their amounts, sum them.
1// File: AfterStreams.java
2import java.util.*;
3
4public class AfterStreams {
5 record Order(String id, double amount, String status) {}
6
7 public static void main(String[] args) {
8 List<Order> orders = List.of(
9 new Order("ORD-1", 450.0, "DELIVERED"),
10 new Order("ORD-2", 899.0, "CANCELLED"),
11 new Order("ORD-3", 1200.0, "DELIVERED")
12 );
13
14 double total = orders.stream()
15 .filter(order -> order.status().equals("DELIVERED"))
16 .mapToDouble(Order::amount)
17 .sum();
18
19 System.out.println("Delivered revenue: " + total);
20 }
21}Output:
Delivered revenue: 1650.0
Both versions compute the exact same number. The stream version has no intermediate List, no manually tracked running total, and no loop variable to get wrong.
Syntax
Every pipeline is a source, followed by any number of intermediate operations, followed by exactly one terminal operation.
1// File: StreamPipelineStructure.java
2import java.util.*;
3import java.util.stream.*;
4
5public class StreamPipelineStructure {
6 public static void main(String[] args) {
7 List<String> cities = List.of("Pune", "Bengaluru", "Delhi", "Hyderabad", "Goa");
8
9 // Source -> intermediate operations -> terminal operation
10 List<String> result = cities.stream() // source
11 .filter(city -> city.length() > 4) // intermediate
12 .map(String::toUpperCase) // intermediate
13 .sorted() // intermediate
14 .collect(Collectors.toList()); // terminal
15
16 System.out.println(result);
17 }
18}Output:
[BENGALURU, DELHI, HYDERABAD]
Intermediate operations do nothing on their own — building a pipeline does not run it, and nothing executes until a terminal operation is called.
1// File: StreamLazinessDemo.java
2import java.util.*;
3import java.util.stream.*;
4
5public class StreamLazinessDemo {
6 public static void main(String[] args) {
7 List<String> names = List.of("Ananya", "Rohit", "Priya");
8
9 Stream<String> pipeline = names.stream()
10 .filter(name -> {
11 System.out.println("Filtering " + name);
12 return name.length() > 5;
13 });
14
15 System.out.println("Pipeline built - nothing has run yet");
16
17 long count = pipeline.count();
18 System.out.println("Matching count: " + count);
19 }
20}Output:
Pipeline built - nothing has run yet
Filtering Ananya
Filtering Rohit
Filtering Priya
Matching count: 1
Not one filtering line prints until count() — the terminal operation — actually runs.
Common Use Cases
Building a Stream From Different Sources
A stream can start from a collection, a fixed set of values, or a numeric range, and each source produces the exact same kind of pipeline downstream.
1// File: StreamCreationExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class StreamCreationExample {
6 public static void main(String[] args) {
7 Stream<String> fromCollection = List.of("A", "B").stream();
8 Stream<Integer> fromValues = Stream.of(10, 20, 30);
9 IntStream fromRange = IntStream.range(1, 5);
10
11 System.out.println("From collection: " + fromCollection.collect(Collectors.toList()));
12 System.out.println("From values: " + fromValues.collect(Collectors.toList()));
13 System.out.println("From range: " + fromRange.boxed().collect(Collectors.toList()));
14 }
15}Output:
From collection: [A, B]
From values: [10, 20, 30]
From range: [1, 2, 3, 4]
A Basic Filter, Transform, Collect Pipeline
This is the shape most everyday stream code takes — narrow down the elements that matter, change what each one looks like, and gather the result.
1// File: BasicPipelineExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class BasicPipelineExample {
6 public static void main(String[] args) {
7 List<String> products = List.of("Mouse", "Keyboard", "Webcam", "Mic", "Monitor");
8
9 List<String> shortNamesUpper = products.stream()
10 .filter(name -> name.length() <= 6)
11 .map(String::toUpperCase)
12 .collect(Collectors.toList());
13
14 System.out.println(shortNamesUpper);
15 }
16}Output:
[MOUSE, WEBCAM, MIC]
Terminal Operations Beyond Collect
collect is only one of several terminal operations. count, anyMatch, and max all end a pipeline too, each producing a different kind of result.
1// File: TerminalOperationsOverviewExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class TerminalOperationsOverviewExample {
6 public static void main(String[] args) {
7 List<Integer> scores = List.of(72, 88, 95, 64, 91);
8
9 long passingCount = scores.stream().filter(score -> score >= 70).count();
10 boolean anyFailed = scores.stream().anyMatch(score -> score < 70);
11 Optional<Integer> topScore = scores.stream().max(Integer::compareTo);
12
13 System.out.println("Passing count: " + passingCount);
14 System.out.println("Any failed: " + anyFailed);
15 System.out.println("Top score: " + topScore.orElse(0));
16 }
17}Output:
Passing count: 4
Any failed: true
Top score: 95
Bringing Predicate, Function, and Consumer Together
A stream pipeline is really just Predicate, Function, and Consumer chained one after another, which becomes obvious the moment each one is declared explicitly instead of written inline.
1// File: CombiningFunctionalInterfacesExample.java
2import java.util.*;
3import java.util.function.*;
4import java.util.stream.*;
5
6public class CombiningFunctionalInterfacesExample {
7 public static void main(String[] args) {
8 List<Double> orderAmounts = List.of(450.0, 899.0, 120.0, 1200.0);
9
10 Predicate<Double> isHighValue = amount -> amount >= 500;
11 Function<Double, String> toLabel = amount -> "Rs." + amount;
12 Consumer<String> printLabel = label -> System.out.println("High value order: " + label);
13
14 orderAmounts.stream()
15 .filter(isHighValue)
16 .map(toLabel)
17 .forEach(printLabel);
18 }
19}Output:
High value order: Rs.899.0
High value order: Rs.1200.0
Real-World Example
An order analytics service for an online marketplace typically needs several separate numbers from the same list of orders — total delivered revenue, average order value among delivered orders, and which delivered orders crossed a high-value threshold. Writing one method that computes all three at once with several running totals tracked by hand becomes hard to verify; three small, focused stream pipelines, each doing exactly one calculation, are far easier to read and to trust.
1// File: Order.java
2
3public record Order(String id, double amount, String status) {}1// File: OrderAnalyticsService.java
2import java.util.*;
3import java.util.stream.*;
4
5public class OrderAnalyticsService {
6
7 public double totalDeliveredRevenue(List<Order> orders) {
8 return orders.stream()
9 .filter(order -> order.status().equals("DELIVERED"))
10 .mapToDouble(Order::amount)
11 .sum();
12 }
13
14 public double averageDeliveredOrderValue(List<Order> orders) {
15 return orders.stream()
16 .filter(order -> order.status().equals("DELIVERED"))
17 .mapToDouble(Order::amount)
18 .average()
19 .orElse(0.0);
20 }
21
22 public List<String> highValueOrderIds(List<Order> orders, double threshold) {
23 return orders.stream()
24 .filter(order -> order.status().equals("DELIVERED"))
25 .filter(order -> order.amount() >= threshold)
26 .map(Order::id)
27 .collect(Collectors.toList());
28 }
29
30 public void printSummary(List<Order> orders) {
31 System.out.println("Total delivered revenue: " + totalDeliveredRevenue(orders));
32 System.out.printf("Average delivered order value: %.2f%n", averageDeliveredOrderValue(orders));
33 System.out.println("High value order ids: " + highValueOrderIds(orders, 500.0));
34 }
35}1// File: OrderAnalyticsDemo.java
2import java.util.*;
3
4public class OrderAnalyticsDemo {
5 public static void main(String[] args) {
6 List<Order> orders = List.of(
7 new Order("ORD-1", 450.0, "DELIVERED"),
8 new Order("ORD-2", 899.0, "CANCELLED"),
9 new Order("ORD-3", 1200.0, "DELIVERED"),
10 new Order("ORD-4", 620.0, "DELIVERED"),
11 new Order("ORD-5", 80.0, "DELIVERED")
12 );
13
14 OrderAnalyticsService analytics = new OrderAnalyticsService();
15 analytics.printSummary(orders);
16 }
17}Output:
Total delivered revenue: 2350.0
Average delivered order value: 587.50
High value order ids: [ORD-3, ORD-4]
During code reviews, seniors commonly flag a single method trying to compute every metric in one pass with several manually tracked accumulator variables and nested conditionals. Three separate, single-purpose pipelines — each filtering, transforming, and terminating independently, exactly as OrderAnalyticsService does — are easier to test, easier to read, and easier to change one at a time without breaking the other two.
Combining Streams With Other Features
Every operation in a stream pipeline is powered by a functional interface already covered in this series — filter takes a Predicate, map takes a Function, forEach takes a Consumer. Terminal operations that might not find anything, like max, min, and findFirst, return an Optional, since a stream can always turn out to be empty. Streams can also run in parallel through parallelStream(), which introduces thread-safety considerations well beyond what belongs in an introduction — that trade-off gets its own dedicated coverage separately.
Best Practices
Treat a stream as disposable. Build it, run it through exactly one terminal operation, and let it go — needing to store a Stream reference for later reuse is a sign the code should be working with a List or holding onto the original source instead.
Keep intermediate operations free of side effects. A filter or map that mutates something outside the pipeline makes the pipeline's behavior depend on execution order in ways that stop being predictable, especially once parallel streams are involved.
Order operations so the most data gets filtered out earliest. Chaining a cheap filter before an expensive map means the expensive step only runs on the elements that actually survived the filter.
Reach for a stream when the logic is fundamentally "take a collection, transform or summarize it." Logic that is really about controlling execution flow — early returns, deeply nested branching — usually reads worse forced into a stream than it would as a plain loop.
Common Mistakes
Building a pipeline and forgetting the terminal operation entirely means nothing runs at all, and nothing in the code visibly signals the mistake.
1// File: MissingTerminalOperationMistake.java
2import java.util.*;
3import java.util.stream.*;
4
5public class MissingTerminalOperationMistake {
6 public static void main(String[] args) {
7 List<String> names = List.of("Ananya", "Rohit", "Priya");
8
9 // This builds a pipeline but never triggers it - nothing runs,
10 // and no compiler error appears for the missing terminal operation
11 names.stream().filter(name -> name.length() > 5);
12
13 System.out.println("Nothing was printed above - the pipeline was never triggered");
14
15 // Adding a terminal operation actually runs the pipeline
16 names.stream().filter(name -> name.length() > 5).forEach(System.out::println);
17 }
18}Output:
Nothing was printed above - the pipeline was never triggered
Ananya
Reusing a Stream reference after a terminal operation has already run throws IllegalStateException, because a stream is designed to be consumed exactly once.
1// File: StreamReuseMistake.java
2import java.util.*;
3import java.util.stream.*;
4
5public class StreamReuseMistake {
6 public static void main(String[] args) {
7 Stream<String> singleUseStream = List.of("A", "B", "C").stream();
8
9 long firstCount = singleUseStream.count();
10 System.out.println("First count: " + firstCount);
11
12 try {
13 long secondCount = singleUseStream.count();
14 System.out.println("Never printed: " + secondCount);
15 } catch (IllegalStateException e) {
16 System.out.println("IllegalStateException - a stream can only be consumed once");
17 }
18 }
19}Output:
First count: 3
IllegalStateException - a stream can only be consumed once
Reaching for peek() as a general-purpose way to insert side effects into the middle of a pipeline is a subtler mistake that does not always show up in local testing. peek is documented as a debugging aid, and the JDK explicitly reserves the right to skip calling it when the JIT can determine the values it would see are not actually needed for the rest of the pipeline to produce its result — code that depends on peek always running is depending on behavior the API was never designed to guarantee.
Interview Questions
Q1. What is a Stream in Java, and how is it different from a Collection?
A Stream is a sequence of elements that supports chained aggregate operations like filter, map, and collect, while a Collection like List or Set is a data structure that actually stores elements in memory. A stream does not store anything itself — it processes elements pulled from a source, typically a collection — and it can only be consumed once, unlike a collection, which can be iterated repeatedly. Interviewers frequently open with this question to check whether a candidate conflates the two, since confusing them leads directly into the "why can't I reuse this stream" mistake.
Q2. What is the difference between intermediate and terminal operations?
Intermediate operations, like filter and map, each return a new Stream and are lazy — they do not execute until something forces the pipeline to run. Terminal operations, like collect, forEach, and count, actually trigger the pipeline and produce a final result rather than another stream. A pipeline with only intermediate operations and no terminal operation never runs at all, which is one of the more common bugs freshers hit early on.
Q3. Why can a Stream only be consumed once?
Because a stream does not store its elements — it represents a computation over a source, and once a terminal operation has pulled elements through that computation, there is nothing left for a second terminal operation to run against. Calling a second terminal operation on the same stream reference throws IllegalStateException, and the fix is always to call .stream() again on the original source rather than trying to reuse the stream object itself.
Q4. What does it mean for a Stream to be lazy, and why does that matter?
Laziness means intermediate operations do not run when they are declared — they only run once a terminal operation pulls elements through the entire pipeline. This matters because it lets the JVM optimize the whole pipeline as one unit rather than materializing an intermediate collection after every single step, and because it means side effects inside intermediate operations, like a filter that prints something, only happen if and when the pipeline is actually executed at all.
Q5. What happens if a stream pipeline has no terminal operation?
Nothing happens. The pipeline is built but never executes, and no exception or compiler warning points this out directly — the code simply produces no result and, if any intermediate operation had a visible side effect, that side effect silently never occurs either. This is one of the more common real bugs in stream code, usually caused by forgetting to append .collect(...) or .forEach(...) at the end of a chain.
Q6. How does Stream.filter interact with Stream.count in terms of laziness and optimization?
When a pipeline contains only size-preserving operations before count(), such as map, the JVM may skip running the pipeline entirely and compute the count directly from the source's known size. Once a filter is present, that optimization is no longer possible, because the number of elements surviving the filter cannot be known without actually evaluating the filter's predicate against every element — so count() after a filter always runs the predicate for each source element. This distinction is a favorite among product-based interviewers checking whether a candidate understands laziness as a real optimization mechanism, not just a vague description.
FAQs
Is a Stream a data structure?
No. A Stream has no internal storage of its own — it represents a sequence of computations applied to elements pulled from an underlying source, like a List or an array, which is where the actual data lives.
Does calling .stream() on a list modify the original list?
No, and this is one of the design guarantees of the Stream API. A stream reads from its source but never modifies it, unless an operation inside the pipeline explicitly mutates the source object itself through a side effect, which is exactly the kind of side effect the Best Practices section above recommends avoiding.
What is the difference between Stream and IntStream?
Stream<T> works with reference types and boxes primitive values, while IntStream (along with LongStream and DoubleStream) works directly with primitive int values, avoiding the cost of autoboxing. mapToInt, mapToDouble, and similar methods convert a Stream<T> into its primitive-specialized counterpart when needed.
Can a Stream be infinite?
Yes. Stream.generate and Stream.iterate can both produce an unbounded stream, which only becomes usable in practice when combined with a bounding operation like limit — consuming an infinite stream directly without a limit never terminates.
Are stream operations guaranteed to run in the order they are written?
For a sequential stream, yes — each element generally flows through the entire pipeline, one operation at a time, before the next element starts, which is why side effects in a sequential stream tend to appear predictable. Parallel streams do not offer that same guarantee, since elements may be processed on different threads in an order that has nothing to do with how the pipeline was written.
Is stream code always faster than a for-loop?
No, and this is a common overstatement. For small collections or simple operations, a plain loop is often just as fast or faster, since a stream pipeline carries its own setup overhead. Streams earn their value primarily in readability and in enabling straightforward parallelism for larger workloads, not as an automatic performance upgrade over every loop.
What is the difference between Stream.of and Arrays.stream?
Stream.of(values...) builds a stream from an explicit, comma-separated list of values or a single array of reference types. Arrays.stream(array) builds a stream directly from an existing array, and it has primitive-friendly overloads that return IntStream, LongStream, or DoubleStream when given a primitive array, which Stream.of does not handle the same way.
Summary
A stream pipeline is a source, a chain of intermediate operations that describe what should happen without running anything yet, and exactly one terminal operation that finally makes it all execute. Every piece of that chain leans on ideas already covered in this series — filter needs a Predicate, map needs a Function, forEach needs a Consumer — which is exactly why streams tend to click quickly once those four interfaces are already familiar.
The habits worth keeping are the ones this article kept returning to: a stream runs once and then it is done, nothing happens without a terminal operation at the end, and a pipeline reads clearest when each step does exactly one thing, the way the order analytics example splits one messy calculation into three small, trustworthy ones. The dedicated articles on filter, map, reduce, collect, and the rest of the Stream API build directly on this foundation, one operation at a time.