Streams API
java streams API filter map flatMap collect reduce parallelStream lazy evaluation terminal intermediate operations
Introduction
In the previous lessons, you learned about the Java Collections Framework, including List, Set, Map, and other collection types. Collections allow us to store and manage groups of objects efficiently.
However, consider the following common tasks:
- Find all employees with a salary greater than ₹100,000.
- Sort products by price.
- Count active users.
- Convert names to uppercase.
- Find duplicate records.
- Group employees by department.
- Calculate the average salary.
- Process millions of records efficiently.
Before Java 8, these operations required multiple loops, temporary collections, and a significant amount of boilerplate code.
Java 8 introduced the Streams API, which provides a declarative and functional approach to processing collections.
Instead of describing how to process data, Streams focus on what result you want.
The Streams API is extensively used in:
- Spring Boot Applications
- Microservices
- REST APIs
- Hibernate/JPA
- Big Data Processing
- Data Analytics
- Financial Systems
- Enterprise Java Applications
Mastering Streams is essential for modern Java development and is one of the most frequently asked interview topics.
In this lesson, you'll learn:
- What is Streams API?
- Why Streams?
- Stream Pipeline
- Creating Streams
- Intermediate Operations
- Terminal Operations
- Lazy Evaluation
- Functional Programming
- Parallel Streams
- Performance Considerations
- Best Practices
- Common Mistakes
- Hands-on Exercises
- Professional Interview Questions
What is a Stream?
A Stream is a sequence of elements that supports functional-style operations to process data.
Unlike collections:
- Collections store data.
- Streams process data.
Example:
List<String> names = List.of("Rahul","Amit","John");names.stream().forEach(System.out::println);
Output:
RahulAmitJohn
Why Streams?
Before Java 8:
List<String> result =new ArrayList<>();for(String name : names){if(name.startsWith("R")){result.add(name);}}
Using Streams:
names.stream().filter(name -> name.startsWith("R")).forEach(System.out::println);
Benefits:
- Less code
- Better readability
- Functional programming style
- Easier parallel processing
- Reduced bugs
Stream Pipeline
A Stream consists of three stages.
Source↓Intermediate Operations↓Terminal Operation
Example:
numbers.stream().filter(n -> n > 10).map(n -> n * 2).collect(Collectors.toList());
Creating Streams
From Collection
List<String> list =List.of("A","B","C");Stream<String> stream =list.stream();
From Array
String[] names = {"Java","Spring","Kafka"};Arrays.stream(names).forEach(System.out::println);
Using Stream.of()
Stream.of(10,20,30,40).forEach(System.out::println);
Infinite Streams
Stream.generate(Math::random).limit(5).forEach(System.out::println);
Intermediate Operations
Intermediate operations return another Stream.
They are lazy, meaning they do not execute until a terminal operation is invoked.
Common intermediate operations:
- filter()
- map()
- flatMap()
- sorted()
- distinct()
- limit()
- skip()
- peek()
filter()
Filters elements based on a condition.
List<Integer> numbers =List.of(10,20,30,40);numbers.stream().filter(n -> n > 20).forEach(System.out::println);
Output:
3040
map()
Transforms each element.
List<String> names =List.of("java","spring");names.stream().map(String::toUpperCase).forEach(System.out::println);
Output:
JAVASPRING
flatMap()
Converts nested collections into a single stream.
List<List<String>> data =List.of(List.of("A","B"),List.of("C","D"));data.stream().flatMap(List::stream).forEach(System.out::println);
Output:
ABCD
sorted()
Sorts elements.
List<Integer> numbers =List.of(5,2,9,1);numbers.stream().sorted().forEach(System.out::println);
Output:
1259
distinct()
Removes duplicates.
List<Integer> numbers =List.of(1,2,2,3,3,4);numbers.stream().distinct().forEach(System.out::println);
Output:
1234
limit() and skip()
Stream.of(1,2,3,4,5).limit(3).forEach(System.out::println);
Output:
123
Stream.of(1,2,3,4,5).skip(2).forEach(System.out::println);
Output:
345
peek()
Used mainly for debugging.
numbers.stream().peek(System.out::println).filter(n -> n > 20).forEach(System.out::println);
Avoid using peek() for business logic.
Terminal Operations
Terminal operations trigger stream execution.
Common terminal operations:
- forEach()
- collect()
- count()
- reduce()
- findFirst()
- findAny()
- anyMatch()
- allMatch()
- noneMatch()
- min()
- max()
collect()
Collects results into a collection.
List<String> result =names.stream().map(String::toUpperCase).collect(Collectors.toList());
count()
long total =numbers.stream().filter(n -> n > 10).count();
reduce()
Reduces elements into a single result.
int sum =Stream.of(10,20,30).reduce(0, Integer::sum);System.out.println(sum);
Output:
60
findFirst()
Optional<String> name =names.stream().findFirst();
Match Operations
numbers.stream().anyMatch(n -> n > 100);
Other operations:
- allMatch()
- noneMatch()
Lazy Evaluation
Streams execute only when a terminal operation is called.
numbers.stream().filter(n -> {System.out.println(n);return n > 20;});
Nothing is printed because no terminal operation exists.
Adding:
.forEach(System.out::println);
Triggers execution.
Parallel Streams
Parallel Streams process data using multiple threads.
numbers.parallelStream().forEach(System.out::println);
Suitable for:
- Large datasets
- CPU-intensive computations
Avoid parallel streams for small collections or operations with side effects.
Stream vs Collection
| Collection | Stream |
|---|---|
| Stores data | Processes data |
| Can be modified | Cannot modify source |
| Multiple traversals | Single traversal |
| Eager | Lazy |
Real-World Example: Employee Filtering
employees.stream().filter(emp -> emp.getSalary() > 100000).map(Employee::getName).sorted().forEach(System.out::println);
Real-World Example: Product Price Calculation
double total =products.stream().map(Product::getPrice).reduce(0.0, Double::sum);
Best Practices
Prefer Streams for Collection Processing
Streams improve readability for filtering, mapping, grouping, and aggregation.
Keep Stream Operations Stateless
Avoid modifying external variables inside stream operations.
Use Method References When Appropriate
Prefer:
.map(String::toUpperCase)
Instead of:
.map(s -> s.toUpperCase())
when readability improves.
Avoid Parallel Streams by Default
Measure performance before introducing parallelism.
Do Not Reuse Streams
A Stream can be consumed only once.
Common Mistakes
Reusing a Stream
Stream<String> stream =names.stream();stream.count();stream.forEach(System.out::println);
Output:
IllegalStateException
Using Streams for Side Effects
Prefer pure transformations over modifying external state.
Forgetting Terminal Operations
Without a terminal operation, intermediate operations never execute.
Overusing Parallel Streams
Parallel execution is not always faster.
Ignoring Optional
Methods such as findFirst() return an Optional. Handle it safely instead of calling get() blindly.
Hands-on Exercise
Create a Java program that:
- Creates a list of employee objects.
- Filters employees with salaries greater than ₹100,000.
- Converts employee names to uppercase.
- Removes duplicate department names.
- Sorts employees by salary.
- Calculates the total salary using
reduce(). - Finds the first employee in the list.
- Counts employees in a specific department.
- Demonstrates
parallelStream(). - Uses
collect()to create a new list.
Summary
The Java Streams API provides a declarative, functional approach to processing collections. By combining intermediate and terminal operations into pipelines, developers can write concise, readable, and maintainable code. Features such as lazy evaluation, method references, lambda expressions, and parallel streams make the Streams API one of the most powerful additions introduced in Java 8.
Key Takeaways
- Streams process data; they do not store it.
- A stream pipeline consists of a source, intermediate operations, and a terminal operation.
- Intermediate operations are lazy.
- Terminal operations trigger execution.
- filter() selects elements.
- map() transforms elements.
- flatMap() flattens nested structures.
- collect() gathers results.
- reduce() aggregates values.
- distinct() removes duplicates.
- parallelStream() enables parallel processing.
- A stream can be consumed only once.
Professional Interview Questions
1What is the difference between a Collection and a Stream?
Professional Answer
A Collection is a data structure used to store and manage objects in memory, whereas a Stream is a sequence of elements used to process data from a source such as a collection, array, or file. Collections are reusable and support multiple traversals, while streams are designed for one-time traversal and functional-style processing.
Follow-up Questions
- Can a Stream modify the underlying collection?
- Why can a Stream be consumed only once?
Interview Tip: Remember: Collection → Stores Data, Stream → Processes Data.
2What are Intermediate and Terminal Operations?
Professional Answer
Intermediate operations transform a stream and return another stream, enabling pipeline construction. They are lazy and do not execute immediately. Terminal operations, such as collect(), forEach(), and reduce(), trigger the execution of the pipeline and produce a result or side effect.
Follow-up Questions
- Why are intermediate operations lazy?
- Name five terminal operations.
Interview Tip: Without a terminal operation, a stream pipeline does not execute.
3What is the difference between map() and flatMap()?
Professional Answer
map() transforms each element into another value, resulting in one output element for each input element. flatMap() is used when each input element can produce multiple output elements, such as flattening nested collections or arrays into a single stream.
Follow-up Questions
- When would you use flatMap()?
- Can flatMap() reduce nested lists to a single stream?
Interview Tip: A simple rule: map() → One-to-One Transformation, flatMap() → Many-to-One Flattening.