Sitemap

Java Streams: From Basic Operations to Interview-Level Problems

6 min readAug 23, 2026

--

Java Streams are one of those topics that look simple at first:

employees.stream()
.filter(...)
.map(...)
.toList();

But once interview questions start asking things like:

“Find departments having more than 3 employees”

or

“Find the second-highest distinct salary”

you quickly realize that Streams are less about memorizing methods and more about understanding how data changes at every step of the pipeline.

In this article, we’ll go from the fundamentals of Streams to some of the most useful interview patterns.

Press enter or click to view image in full size

What Exactly Is a Stream?

A Stream is a way of processing a collection of data through a pipeline.

For example:

List<Integer> numbers = List.of(10, 20, 15, 30, 25);
List<Integer> result = numbers.stream()
.filter(n -> n > 15)
.map(n -> n * 2)
.sorted()
.toList();

The pipeline is:

Collection
↓
stream()
↓
filter()
↓
map()
↓
sorted()
↓
toList()

The most important thing to understand is that Stream operations are generally divided into:

  • Intermediate operations
  • Terminal operations

Intermediate vs Terminal Operations

Intermediate Operations

They return another Stream and can be chained.

Common ones are:

filter()
map()
flatMap()
sorted()
distinct()
limit()
skip()
peek()

For example:

employees.stream()
.filter(e -> e.getSalary() > 50000)
.map(Employee::getName)
.sorted();

Terminal Operations

They produce the final result.

Examples:

collect()
toList()
forEach()
count()
min()
max()
reduce()
findFirst()
findAny()
anyMatch()
allMatch()
noneMatch()

Once a terminal operation executes, the Stream is consumed.

1. filter() — Select Data

filter() keeps only elements satisfying a condition.

List<Employee> result = employees.stream()
.filter(e -> e.getSalary() > 60000)
.toList();

Think:

Employee → condition → Employee

For example:

.filter(e -> e.getDepartment().equals("IT"))

returns only IT employees.

2. map() — Transform Data

map() transforms one type of data into another.

Suppose we have:

List<Employee> employees;

and we only want names:

List<String> names = employees.stream()
.map(Employee::getName)
.toList();

The transformation is:

Employee
↓
getName()
↓
String

This is one of the most important Stream concepts.

3. flatMap() — Flatten Nested Collections

Suppose we have:

List<List<Integer>> numbers = List.of(
List.of(1, 2),
List.of(3, 4),
List.of(5, 6)
);

Using flatMap():

List<Integer> result = numbers.stream()
.flatMap(List::stream)
.toList();

Result:

[1, 2, 3, 4, 5, 6]

Think:

[[1,2], [3,4], [5,6]]
↓
[1,2,3,4,5,6]

4. distinct() — Remove Duplicates

List<Integer> result = numbers.stream()
.distinct()
.toList();

For:

[10, 20, 20, 30, 30, 40]

we get:

[10, 20, 30, 40]

Important Interview Point

distinct() works on the current stream elements.

For example:

employees.stream()
.distinct()

removes duplicate Employee objects based on their equality semantics.

If you want distinct salaries, first map to salary:

employees.stream()
.map(Employee::getSalary)
.distinct()

This becomes extremely important in questions like second-highest salary.

5. sorted() — Sorting Data

Ascending:

List<Integer> result = numbers.stream()
.sorted()
.toList();

For objects:

List<Employee> result = employees.stream()
.sorted(Comparator.comparingInt(Employee::getSalary))
.toList();

Descending:

List<Employee> result = employees.stream()
.sorted(
Comparator.comparingInt(Employee::getSalary)
.reversed()
)
.toList();

A common mistake is writing:

Employee::getSalary()

That’s incorrect.

A method reference is:

Employee::getSalary

No parentheses.

6. limit() and skip()

Get the top 3:

employees.stream()
.sorted(
Comparator.comparingInt(Employee::getSalary)
.reversed()
)
.limit(3)
.toList();

Skip the first 3:

employees.stream()
.skip(3)
.toList();

These become useful for ranking and pagination-style problems.

Finding Maximum and Minimum

Instead of sorting the entire collection, we can directly use max() and min().

Highest salary:

Employee employee = employees.stream()
.max(Comparator.comparingInt(Employee::getSalary))
.orElseThrow();

Lowest salary:

Employee employee = employees.stream()
.min(Comparator.comparingInt(Employee::getSalary))
.orElseThrow();

Why orElseThrow()?

Because max() and min() return:

Optional<Employee>

not directly:

Employee

Similarly:

findFirst()
findAny()
max()
min()

commonly return Optional.

Numeric Streams

For numeric calculations, Java provides:

IntStream
LongStream
DoubleStream

For example:

int sum = employees.stream()
.mapToInt(Employee::getSalary)
.sum();

Average:

double average = employees.stream()
.mapToInt(Employee::getSalary)
.average()
.orElse(0);

Notice:

sum()     → int
average() → OptionalDouble
max() → OptionalInt
min() → OptionalInt
count() → long

reduce() — General Aggregation

reduce() combines elements into a single result.

For example, calculating a sum:

int sum = numbers.stream()
.reduce(0, Integer::sum);

Conceptually:

0 + 10 = 10
10 + 20 = 30
30 + 30 = 60
60 + 40 = 100

You can also calculate a product:

int product = numbers.stream()
.reduce(1, (a, b) -> a * b);

For employee salaries:

int totalSalary = employees.stream()
.map(Employee::getSalary)
.reduce(0, Integer::sum);

For a simple numeric sum, however, this is cleaner:

int totalSalary = employees.stream()
.mapToInt(Employee::getSalary)
.sum();

Think of it this way:

sum() is a specialized operation.
reduce() is a general way to combine values.

anyMatch(), allMatch(), noneMatch()

These are useful for condition-based questions.

Any employee earns more than 100,000?

boolean result = employees.stream()
.anyMatch(e -> e.getSalary() > 100000);

Every employee earns more than 20,000?

boolean result = employees.stream()
.allMatch(e -> e.getSalary() > 20000);

No employee earns less than 10,000?

boolean result = employees.stream()
.noneMatch(e -> e.getSalary() < 10000);

Easy way to remember:

anyMatch  → at least one
allMatch → everyone
noneMatch → nobody

The Most Important Part: Collectors

The real power of Streams starts showing up when we use Collectors.

Get Gaurikhard’s stories in your inbox

Join Medium for free to get updates from this writer.

Import:

import java.util.stream.Collectors;

Some important collectors are:

toList()
toSet()
toMap()
joining()
groupingBy()
partitioningBy()
counting()
summingInt()
averagingInt()
mapping()
maxBy()
minBy()

groupingBy() — One of the Most Important Interview Patterns

Suppose employees have:

name
department
salary
age

We want to group employees by department.

Map<String, List<Employee>> result =
employees.stream()
.collect(
Collectors.groupingBy(
Employee::getDepartment
)
);

Result could look like:

IT       → [Employee1, Employee2, Employee3]
HR → [Employee4, Employee5]
Finance → [Employee6, Employee7]

Remember:

groupingBy()

is a Collector, so it normally appears inside:

.collect(...)

Not:

stream().groupingBy(...) // ❌

groupingBy() + counting()

Now suppose the question is:

How many employees are there in each department?

We can use:

Map<String, Long> result =
employees.stream()
.collect(
Collectors.groupingBy(
Employee::getDepartment,
Collectors.counting()
)
);

Result:

IT       → 5
HR → 3
Finance → 4

The pattern is:

groupingBy
+
counting

groupingBy() + averagingInt()

Question:

Find the average salary for every department.

Map<String, Double> result =
employees.stream()
.collect(
Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(
Employee::getSalary
)
)
);

Result:

IT       → 75000.0
HR → 60000.0
Finance → 82000.0

groupingBy() + summingInt()

Question:

Find the total salary paid by each department.

Map<String, Integer> result =
employees.stream()
.collect(
Collectors.groupingBy(
Employee::getDepartment,
Collectors.summingInt(
Employee::getSalary
)
)
);

This is another very common interview pattern.

The Interesting One: Filter a Map

Now let’s solve a more advanced problem.

Find departments having more than 3 employees.

First:

Map<String, Long> departmentCounts =
employees.stream()
.collect(
Collectors.groupingBy(
Employee::getDepartment,
Collectors.counting()
)
);

We now have:

IT       → 5
HR → 3
Finance → 6
Sales → 2

But this is a Map, not a Stream.

To stream over it:

departmentCounts.entrySet().stream()

Each entry contains:

entry.getKey()

for the department, and:

entry.getValue()

for the employee count.

Now filter:

.filter(entry -> entry.getValue() > 3)

And convert the entries back to a Map:

.collect(
Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue
)
);

The complete solution:

Map<String, Long> result =
employees.stream()
.collect(
Collectors.groupingBy(
Employee::getDepartment,
Collectors.counting()
)
)
.entrySet()
.stream()
.filter(entry -> entry.getValue() > 3)
.collect(
Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue
)
);

The pipeline is:

Employees
↓
groupingBy(department)
↓
counting()
↓
Map<String, Long>
↓
entrySet()
↓
stream()
↓
filter(value > 3)
↓
toMap()
↓
Map<String, Long>

This pattern is extremely useful in interviews.

Second-Highest Distinct Salary

Another classic interview question:

Find the second-highest distinct salary.

Suppose:

100000
90000
90000
80000
70000

Expected:

90000

The correct pipeline is:

int secondHighest = employees.stream()
.map(Employee::getSalary)
.distinct()
.sorted(Comparator.reverseOrder())
.skip(1)
.findFirst()
.orElseThrow();

Let’s break it down:

Employee
↓
map salary
↓
100000, 90000, 90000, 80000, 70000
↓
distinct
↓
100000, 90000, 80000, 70000
↓
sort descending
↓
100000, 90000, 80000, 70000
↓
skip(1)
↓
90000, 80000, 70000
↓
findFirst()
↓
90000

Notice why distinct() comes after map().

If we did:

employees.stream()
.distinct()

we’d be working with Employee objects, not salaries.

A Very Useful Mental Model

Whenever you get a Stream interview question, don’t immediately start writing code.

First convert the English question into operations.

For example:

“Get names of IT employees earning above 60,000”

Think:

filter department
↓
filter salary
↓
map name
↓
toList

Code:

List<String> result = employees.stream()
.filter(e -> e.getDepartment().equals("IT"))
.filter(e -> e.getSalary() > 60000)
.map(Employee::getName)
.toList();

“Top 3 highest-paid employees”

Think:

sort descending
↓
limit 3
List<Employee> result = employees.stream()
.sorted(
Comparator.comparingInt(Employee::getSalary)
.reversed()
)
.limit(3)
.toList();

“Average salary by department”

Think:

group
↓
average
Map<String, Double> result =
employees.stream()
.collect(
Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(
Employee::getSalary
)
)
);

Stream Interview Cheat Sheet

RequirementStream PatternSelect elementsfilter()Transform elementsmap()Flatten nested dataflatMap()Remove duplicatesdistinct()Sortsorted()First Nlimit()Skip Nskip()Find maximummax()Find minimummin()SummapToInt().sum()AveragemapToInt().average()Combine valuesreduce()At least oneanyMatch()EveryoneallMatch()NobodynoneMatch()GroupgroupingBy()Count groupsgroupingBy() + counting()Average groupsgroupingBy() + averagingInt()Sum groupsgroupingBy() + summingInt()Filter a MapentrySet().stream()Second highestmap → distinct → sorted → skip

Final Takeaway

The biggest mistake while learning Java Streams is trying to memorize every method.

Instead, learn to recognize patterns.

When you see:

“employees earning more than X”

think:

filter()

When you see:

“get employee names”

think:

map()

When you see:

“highest salary”

think:

max()

When you see:

“for each department”

think:

groupingBy()

When you see:

“how many in each department”

think:

groupingBy() + counting()

And when you see:

“departments having more than 3 employees”

think:

groupingBy
↓
counting
↓
Map
↓
entrySet().stream()
↓
filter()
↓
toMap()

Once you start translating English → Stream pipeline, Java Streams become much less intimidating — and interview questions start looking like combinations of the same 10–15 building blocks.

--

--