You can think of a Java stream as a pipeline through which data flows. Instead of manually writing loops and conditionals to process a list, you tell Java what should happen to each element, and the Java Stream API takes care of how it happens internally.
A Java stream doesn’t hold data. Instead, it operates on an existing data source such as a List, Set, Map, or array. The stream applies a series of operations to the data source.
This article introduces you to Java streams. You’ll learn how to create streams from Java collections, get your first look at a stream pipeline, and see how lambdas, method references, and other functional programming elements work with Java streams. You’ll also learn how to combine collectors and optional chaining with Java streams, and when to use or not use streams in your programs.
Many developers get tripped up by the difference between Java streams and Java collections:
ArrayList or HashSet) are used for storage. They keep data in memory for you to access.As an analogy, consider that a collection is the cupboard holding ingredients, whereas a stream is the recipe for making them into a meal.
Streams give Java a functional and declarative feel by describing what to do instead of how to do it.
Java developers appreciate and use streams for a variety of reasons:
We can begin to see these differences by comparing loops and streams.
Streams often replace traditional loops in Java, and once you’ve started using them, it’s hard to go back. Here’s an example of a classic for loop:
List names = List.of("patrick", "mike", "james", "bill");
List result = new ArrayList();
for (String name : names) {
if (name.length() > 4) {
result.add(name.toUpperCase());
}
}
Collections.sort(result);
System.out.println(result);
And here is the Java streams version:
List names = List.of("patrick", "mike", "james", "bill");
List result = names.stream()
.filter(name -> name.length() > 4)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(result);
Unlike a loop, the Stream reads almost like English: “Take the names, filter by length, convert to uppercase, sort them, then collect to a list.”
After completing, the output will be: [JAMES, PATRICK].
Streams can start from many sources. Think of all the examples below as ways to “turn on the tap.”
Here’s how to create a Stream from a collection—in this case, a List of names:
List names = List.of("James", "Bill", "Patrick");
Stream nameStream = names.stream();
Here’s how to create a Stream from a Map:
Map idToName = Map.of(1, "James", 2, "Bill");
Stream
And here is one created from an array:
String[] names = {"James", "Bill", "Patrick"};
Stream nameStream = Arrays.stream(names);
You can also create a Stream using Stream.of():
Stream numberStream = Stream.of(1, 2, 3, 4, 5);
Using Stream.of(), you can pass in any kind of value or object to create a Stream. It’s a simple way to quickly create a stream when you don’t already have a collection or array. Perfect for small, fixed sets of data or quick tests.
The Stream.generate() method creates an infinite stream; it keeps producing values while the pipeline requests them:
Stream.generate(() -> "hello")
.forEach(System.out::println);
This Stream never stops printing. Use limit() to control it:
Stream.generate(Math::random)
.limit(5)
.forEach(System.out::println);
Both Stream.generate() and Stream.iterate() can produce infinite sequences. Always limit or short-circuit them to avoid endless execution.
If you need to safely return an empty stream rather than null, use Stream.empty():
Stream emptyStream = Stream.empty();
This avoids null checks and makes methods returning streams safer and cleaner.
Streams have intermediate (lazy) and terminal (executing) operations. Together, these two types of operations form your data pipeline.
Intermediate streams operations don’t trigger execution right away. They just add steps to the recipe:
map(): Transforms each element.filter(): Keeps only elements that match a condition.sorted(): Arranges elements in order.distinct(): Removes duplicates.limit()/skip(): Trims the stream.flatMap(): Flattens nested structures (e.g., lists of lists) into one stream.peek(): Lets you look at elements as they pass through (great for debugging/logging, but not for side effects).takeWhile(predicate): Keeps pulling elements until the predicate fails (like a conditional limit).dropWhile(predicate): Skips elements while the predicate is true, then keeps the rest.Streams prepare all their steps first (filtering, mapping, sorting), but nothing happens until a terminal operation triggers processing. This lazy evaluation makes them efficient by processing only what’s needed.
Take this stream pipeline, for example:
List names = List.of("james", "bill", "patrick", "guy");
names.stream()
.filter(n -> n.length() > 3) // keep names longer than 3 characters
.map(String::toUpperCase) // convert to uppercase
.sorted(); // sort alphabetically
System.out.println("List result: " + names);
The result will be: [james, bill, patrick, guy].
At first glance, it looks like this pipeline should:
"al" and "bob" (since their length isn’t greater than 3),But in reality, the pipeline does none of that.
The reason is that streams in Java are lazy.
filter, map, sorted) are intermediate operations..toList(), forEach(), or count().Since there’s no terminal operation in the above code, the pipeline is discarded and the original list prints unchanged.
Now we can look at the second kind of stream operation. Terminal operations trigger the stream to run and produce a result:
forEach(): Do something with each element.collect(): Gather elements into a collection.toList(): Collect all elements into an immutable List (Java 16+).reduce(): Fold elements into a single result (sum, product, etc.).count(): How many items?findFirst(): Returns the first element that matches the filtering conditions (useful when order matters).findAny(): Returns any matching element (especially useful in parallel streams where order is not guaranteed).toArray(): Collect results into an array.min(Comparator) / max(Comparator): Find the smallest or largest element based on a comparator.anyMatch(predicate): Does any element match?allMatch(predicate): Do all elements match?noneMatch(predicate): Do no elements match?Here’s an example of a stream with terminal operations:
List names = List.of("james", "bill", "patrick", "guy");
List result = names.stream()
.filter(n -> n.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList(); // Terminal operation method triggers action here
System.out.println(result);
In this case, the output will be: [BILL, JAMES, PATRICK].
Once a stream has been processed, it’s consumed and can’t be reused. A terminal operation closes the stream:
List names = List.of("James", "Bill", "Patrick");
Stream s = names.stream();
s.forEach(System.out::println); // OK
s.count(); // IllegalStateException — already processed
In this code, the first call pulls all data through the pipeline, and after that it’s closed. Create a new one if needed:
long count = names.stream().count(); // OK: new stream instance
To conclude this section, here is a stream pipeline with both intermediate and terminal streams operations:
List result = names.stream() // Source
.filter(n -> n.length() > 3) // Intermediate operation
.map(String::toUpperCase) // Intermediate operation
.sorted() // Intermediate operation
.toList(); // Terminal operation
In addition to streams, Java 8 introduced collectors, which you can use to describe how to gather (collect) processed data.
Collecting to a list creates a new unmodifiable list of names longer than three characters. Immutable results make stream code safer and more functional:
List list = names.stream()
.filter(n -> n.length() > 3)
.toList(); // Java 16+
Here, we collect results into a set, automatically removing duplicates. Use a set when uniqueness matters more than order:
Set set = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
Here, we collect to a Map, where each key is the String’s length and each value is the name itself:
Map map = names.stream()
.collect(Collectors.toMap(
String::length,
n -> n
));
If multiple names share the same length, a collision occurs. Handle it with a merge function:
Map safeMap = names.stream()
.collect(Collectors.toMap(
String::length,
n -> n,
(a, b) -> a // keep the first value if keys collide
));
Collectors.joining() merges all stream elements into one String using any delimiter you choose. You can use “ |”, “ ; ”, or even “n” to separate values however you like:
List names = List.of("Bill", "James", "Patrick");
String result = names.stream()
.map(String::toUpperCase)
.collect(Collectors.joining(", "));
System.out.println(result);
The output here will be: BILL, JAMES, PATRICK.
Collectors.groupingBy() groups elements by key (here it’s string length) and returns a Map>:
List names = List.of("james", "linus", "john", "bill", "patrick");
Map> grouped = names.stream()
.collect(Collectors.groupingBy(String::length));
The output will be: {4=[john, bill], 5=[james, linus], 7=[patrick]}.
You can also use collectors for summarizing:
List numbers = List.of(3, 5, 7, 2, 10);
IntSummaryStatistics stats = numbers.stream()
.collect(Collectors.summarizingInt(n -> n));
System.out.println(stats);
The output in this case will be: IntSummaryStatistics{count=5, sum=27, min=2, average=5.4, max=10}.
Or, if you want just the average, you could do:
double avg = numbers.stream()
.collect(Collectors.averagingDouble(n -> n));
Earlier, I mentioned that streams combine functional and declarative elements. Let’s look at some of the functional programming elements in streams.
Lambdas define behavior inline, whereas method references reuse existing methods:
names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
As a rule of thumb:
map() when you have one input and want one output.flatMap() when you have one input and want many outputs (flattened).Here is an example using map() in a stream:
List> nested = List.of(
List.of("james", "bill"),
List.of("patrick")
);
nested.stream()
.map(list -> list.stream())
.forEach(System.out::println);
The output here will be:
java.util.stream.ReferencePipeline$Head@5ca881b5
java.util.stream.ReferencePipeline$Head@24d46ca6
There are two lines because there are two inner lists, so you need two Stream objects. Also note that hash values will vary.
Here is the same stream with flatMap():
nested.stream()
.flatMap(List::stream)
.forEach(System.out::println);
In this case, the output will be:
james
bill
patrick
For deeper nesting, use:
List>> deep = List.of(
List.of(List.of("James", "Bill")),
List.of(List.of("Patrick"))
);
List flattened = deep.stream()
.flatMap(List::stream)
.flatMap(List::stream)
.toList();
System.out.println(flattened);
The output in this case will be: [James, Bill, Patrick].
Optional chaining is another useful operation you can combine with streams:
List names = List.of("James", "Bill", "Patrick");
String found = names.stream()
.filter(n -> n.length() > 6)
.findFirst()
.map(String::toUpperCase)
.orElse("NOT FOUND");
System.out.println(found);
The output will be: NOT FOUND.
findFirst() returns an optional, which safely represents a value that might not exist. If nothing matches, .orElse() provides a fallback value. Methods like findAny(), min(), and max() also return optionals for the same reason.
The Java Stream API transforms how you handle data. You can declare what should happen—such as filtering, mapping, or sorting—while Java efficiently handles how it happens. Combining streams, collectors, and optionals makes modern Java concise, expressive, and robust. Use streams for transforming or analyzing data collections, not for indexed or heavily mutable tasks. Once you get into the flow, it’s hard to go back to traditional loops.
As you get more comfortable with the basics in this article, you can explore advanced topics like parallel streams, primitive streams, and custom collectors. And don’t forget to practice. Once you understand the code examples here, try running them and changing the code. Experimentation will help you acquire real understanding and skills.