
List<String> 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<String> 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<Integer, String> 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<Integer, String> safeMap = names.stream()
.collect(Collectors.toMap(
String::length,
n -> n,
(a, b) -> a // keep the first value if keys collide
));
Joining strings
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<String> 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.
Grouping data
Collectors.groupingBy() groups elements by key (here it’s string length) and returns a Map<Key, List<Value>>:
List<String> names = List.of("james", "linus", "john", "bill", "patrick");
Map<Integer, List<String>> grouped = names.stream()
.collect(Collectors.groupingBy(String::length));
The output will be: {4=[john, bill], 5=[james, linus], 7=[patrick]}.
Summarizing numbers
You can also use collectors for summarizing:
List<Integer> 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));
Functional programming with streams
Earlier, I mentioned that streams combine functional and declarative elements. Let’s look at some of the functional programming elements in streams.
Lambdas and method references
Lambdas define behavior inline, whereas method references reuse existing methods:
names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
map() vs. flatMap()
As a rule of thumb:
- Use a
map()when you have one input and want one output. - Use a
flatMap()when you have one input and want many outputs (flattened).
Here is an example using map() in a stream:
List<List<String>> 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<List<List<String>>> deep = List.of(
List.of(List.of("James", "Bill")),
List.of(List.of("Patrick"))
);
List<String> 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
Optional chaining is another useful operation you can combine with streams:
List<String> 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.
Conclusion
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.

