Java Stream API: Complete Guide with Examples. Practical Tasks
Run a single line: Arrays.asList(new int[]{1, 2, 3}).size(). You expect 3, you get 1. The compiler says nothing, your test fails, and half an hour disappears into a bug that is not where you are looking. That is exercise 4 on this page — and Stream API and Optional have a few more corners like it.
This page collects nine hands-on exercises on Java Stream API, Optional and Collectors: from a plain filter → map → collect pipeline to grouping with downstream collectors and Optional chains that contain no if at all. The exercises get harder as you go, so work through them in order — each one builds on techniques from the previous ones. Write your own code first, then compare it with the expected output printed in the comments.
What to review before you start
Every exercise maps to a specific lesson in the module. If a task statement feels unclear, go back to the theory instead of reaching for a ready-made answer.
| Lesson | What you need to know | Exercises |
|---|---|---|
| What is Stream API | Sources, intermediate and terminal operations, laziness | 1, 2 |
| Stream API Methods | filter, map, limit, reduce, min, max | 1, 2, 3 |
| Convert int[] to List | Array ↔ List, boxed(), mapToInt(), how Arrays.asList behaves | 4 |
| Collectors in Java | groupingBy, partitioningBy, counting, averagingDouble, joining | 5, 6, 7 |
| What Is Optional | Why Optional exists, of, ofNullable, empty | 8 |
| Optional Methods | filter, map, orElse, orElseGet, orElseThrow | 8, 9 |
1. Basic pipeline: filter, map, collect
You are given a list of movie titles. Build a new list that contains only the titles longer than 10 characters, converted to upper case. The original list must stay untouched. Solve it with a single pipeline — no for loops and no intermediate variables.
import java.util.List;
import java.util.stream.Collectors;
public class Task1 {
public static void main(String[] args) {
List<String> movies = List.of(
"Interstellar", "The Mist", "Inception",
"Schindler's List", "Django", "The Shawshank Redemption");
List<String> result = /* your code */;
System.out.println(result);
// Expected output:
// [INTERSTELLAR, SCHINDLER'S LIST, THE SHAWSHANK REDEMPTION]
}
} Going further: sort the result by title length in descending order without introducing another collection.
2. Infinite streams and limit
Streams do not have to come from collections. Write two methods, each returning a List<Long> built from an infinite source that is cut off by limit():
powersOfTwo(int n)— the firstnpowers of two, starting at 1 (that is, 20).fibonacci(int n)— the firstnFibonacci numbers, starting with 0 and 1.
For the second method you will need the "two-element state" trick: the value flowing through the stream is a pair of neighbouring numbers, and only the first of them is exposed to the caller.
import java.util.List;
import java.util.stream.Stream;
public class Task2 {
public static List<Long> powersOfTwo(int n) {
return Stream.iterate(1L, /* your code */)
.limit(n)
/* your code */;
}
public static List<Long> fibonacci(int n) {
return Stream.iterate(new long[]{0, 1}, pair -> new long[]{/* your code */})
/* your code */;
}
public static void main(String[] args) {
System.out.println(powersOfTwo(5)); // [1, 2, 4, 8, 16]
System.out.println(fibonacci(10)); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
}
} Think before you run: what happens if you remove limit() from fibonacci? Answer first, then check.
3. Terminal operations: reduce, min, max
This exercise has two parts.
- Use
reduce()to compute the product of all numbers in a list. For an empty list the result must be 1. - Find the longest and the shortest word in a list using
max()andmin()with aComparator. Both methods return anOptional— work out why, and handle the empty case explicitly.
import java.util.Comparator;
import java.util.List;
public class Task3 {
public static int product(List<Integer> numbers) {
return /* your code: reduce */;
}
public static String longest(List<String> words) {
return /* your code: max + Comparator, empty list -> "" */;
}
public static String shortest(List<String> words) {
return /* your code: min + Comparator, empty list -> "" */;
}
public static void main(String[] args) {
System.out.println(product(List.of(2, 3, 4))); // 24
System.out.println(product(List.of())); // 1
System.out.println(longest(List.of("cat", "elephant", "ox"))); // elephant
System.out.println(shortest(List.of("cat", "elephant", "ox"))); // ox
}
} 4. Array to list and back
Implement four conversions and test one widespread misconception along the way:
int[]→List<Integer>;int[]→ArrayList<Integer>explicitly, so that the list is guaranteed to be mutable — add an element to it and confirm that nothing is thrown;List<Integer>→int[];- print the size of
Arrays.asList(numbers), wherenumbersis anint[], and explain the number you get.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Task4 {
public static void main(String[] args) {
int[] numbers = {5, 3, 9, 1};
List<Integer> list = /* your code */;
ArrayList<Integer> arrayList = /* your code */;
arrayList.add(42);
int[] back = /* your code */;
System.out.println(list); // [5, 3, 9, 1]
System.out.println(arrayList); // [5, 3, 9, 1, 42]
System.out.println(Arrays.toString(back)); // [5, 3, 9, 1]
System.out.println(Arrays.asList(numbers).size()); // what prints here?
}
} The trap in exercise 4
For an int[], Arrays.asList(numbers) returns a List<int[]> of size 1: generics do not accept primitives, so the whole array becomes the single element of the list. The compiler reports nothing. The working options are Arrays.stream(numbers).boxed() or an Integer[] array of objects. Also keep in mind that Arrays.asList itself returns a fixed-size list — calling add() on it throws UnsupportedOperationException.
5. Grouping with Collectors.groupingBy
You are given a list of employees (a record Employee with name, department and salary). Write two methods:
countByDepartment— aMap<String, Long>with the number of employees per department (groupingBy+counting());averageSalaryByDepartment— aMap<String, Double>with the average salary per department (groupingBy+averagingDouble()).
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Task5 {
record Employee(String name, String department, double salary) { }
public static Map<String, Long> countByDepartment(List<Employee> employees) {
return /* your code */;
}
public static Map<String, Double> averageSalaryByDepartment(List<Employee> employees) {
return /* your code */;
}
public static void main(String[] args) {
List<Employee> employees = List.of(
new Employee("Anna", "IT", 3000),
new Employee("Brian", "IT", 5000),
new Employee("Clara", "HR", 2000),
new Employee("Daniel", "HR", 2500),
new Employee("Emma", "Sales", 4000));
System.out.println(countByDepartment(employees));
// {HR=2, IT=2, Sales=1}
System.out.println(averageSalaryByDepartment(employees));
// {HR=2250.0, IT=4000.0, Sales=4000.0}
}
} Going further: return the result as a TreeMap with departments in alphabetical order. Hint: groupingBy has an overload that accepts a map factory.
6. Splitting into two groups: partitioningBy
You are given a list of exam results. Split the students into those who passed (a score of 60 or above) and those who did not, using Collectors.partitioningBy. Then print the size of each group without opening a second stream just to count.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Task6 {
record Result(String student, int score) { }
public static void main(String[] args) {
List<Result> results = List.of(
new Result("Anna", 91),
new Result("Brian", 45),
new Result("Clara", 60),
new Result("Daniel", 59));
Map<Boolean, List<Result>> byPassed = /* your code */;
Map<Boolean, Long> counts = /* your code: partitioningBy + counting */;
System.out.println(byPassed.get(true)); // [Result[student=Anna, score=91], Result[student=Clara, score=60]]
System.out.println(byPassed.get(false)); // [Result[student=Brian, score=45], Result[student=Daniel, score=59]]
System.out.println(counts); // {false=2, true=2}
}
} Think it through: how does partitioningBy differ from groupingBy(r -> r.score() >= 60)? Test your answer on an empty list — the difference shows up immediately.
7. Building a string with Collectors.joining
Turn a list of products into one string: items separated by a comma and a space, the whole thing wrapped in square brackets. Normalise each name to "first letter capital" form and drop names that are empty or blank.
import java.util.List;
import java.util.stream.Collectors;
public class Task7 {
public static String format(List<String> products) {
return products.stream()
/* your code: drop blanks, normalise the case */
.collect(Collectors.joining(/* delimiter, prefix, suffix */));
}
public static void main(String[] args) {
System.out.println(format(List.of("apple", " ", "banana", "CHERRY", "")));
// [Apple, Banana, Cherry]
System.out.println(format(List.of()));
// []
}
} 8. Optional instead of null in a lookup
You have a user store backed by a Map<Long, User>. Write a method findById(long id) that returns an Optional<User> and never returns null. On top of it, implement two calling styles:
getOrThrow(long id)— throws your ownUserNotFoundExceptionviaorElseThrow();getOrGuest(long id)— returns a guest user viaorElseGet(), and the placeholder object must be created only when the user is genuinely missing.
import java.util.Map;
import java.util.Optional;
public class Task8 {
record User(long id, String name) { }
static class UserNotFoundException extends RuntimeException {
UserNotFoundException(long id) {
super("User not found: id=" + id);
}
}
private static final Map<Long, User> USERS = Map.of(
1L, new User(1, "Anna"),
2L, new User(2, "Brian"));
public static Optional<User> findById(long id) {
return /* your code */;
}
public static User getOrThrow(long id) {
return /* your code: orElseThrow */;
}
public static User getOrGuest(long id) {
return /* your code: orElseGet */;
}
public static void main(String[] args) {
System.out.println(findById(1)); // Optional[User[id=1, name=Anna]]
System.out.println(findById(99)); // Optional.empty
System.out.println(getOrGuest(99)); // User[id=-1, name=Guest]
System.out.println(getOrThrow(99)); // UserNotFoundException
}
} Do not turn Optional back into a null check
The pair if (opt.isPresent()) { opt.get(); } compiles and works, but it is the same if (x != null) with more typing. Exercise 8 deliberately asks for orElseThrow() and orElseGet(): they state the intent right in the call. Calling get() without checking first throws NoSuchElementException — since Java 10 there is a no-argument orElseThrow() whose name is honest about that.
9. An Optional chain with no null checks
The method receives a string that may be null. Return the length of the string if it is neither null nor blank, and 0 otherwise. if statements and the ternary operator are banned: build a chain of Optional.ofNullable → filter → map → orElse.
The second part is an experiment. Implement lengthOrElse and lengthOrElseGet, where the default value comes from a method with a side effect (a console print), and run both on a non-empty string. Count how many times the message appears in each case and explain the result.
import java.util.Optional;
public class Task9 {
public static int length(String text) {
return Optional.ofNullable(text)
/* your code: filter + map */
.orElse(0);
}
static int expensiveDefault() {
System.out.println("Computing the default value...");
return 0;
}
public static int lengthOrElse(String text) {
return Optional.ofNullable(text).map(String::length).orElse(expensiveDefault());
}
public static int lengthOrElseGet(String text) {
return Optional.ofNullable(text).map(String::length).orElseGet(Task9::expensiveDefault);
}
public static void main(String[] args) {
System.out.println(length("Java")); // 4
System.out.println(length(" ")); // 0
System.out.println(length(null)); // 0
lengthOrElse("Java"); // how many lines are printed?
lengthOrElseGet("Java"); // and here?
}
} Where people slip in exercise 9
The argument of orElse(expensiveDefault()) is an ordinary method call: Java evaluates it BEFORE orElse runs, even when the Optional holds a value. That is why the message is printed for a non-empty string too. The argument of orElseGet is a Supplier, so it runs only when the Optional is empty. The rule is short: if the default is a constant or an object you already have, use orElse; if it is a database call, an object creation or any computation, use orElseGet.
About mutability of the result
In exercises 1 and 7 the result can be collected two ways. Stream.toList() (Java 16 and later) is shorter but returns an unmodifiable list: add() on it throws UnsupportedOperationException. collect(Collectors.toList()) guarantees neither the exact type nor mutability, although in practice it hands you an ArrayList. When the list has to be modified afterwards, say so explicitly with collect(Collectors.toCollection(ArrayList::new)), as in exercise 4.
Check yourself before looking up solutions
Walk through this list before you go looking for an answer key. If any point makes you hesitate, go back to the matching exercise.
- Does all of your code compile and print exactly the output stated in the comments?
- Are there any
forloops orif (x != null)checks left where the task asked for a pipeline or anOptionalchain? - Can you explain out loud why
max()andmin()return anOptionalwhilecount()does not? - Is it clear why
Arrays.asList(new int[]{...})has size 1, and how to get a list of four elements instead? - Can you name the difference between
orElseandorElseGeton the spot, with an example where the choice affects performance?
Frequently asked questions
Should I use collect(Collectors.toList()) or toList() in these exercises?
Either one counts as correct. stream.toList() arrived in Java 16 and is shorter, but it returns an unmodifiable list — adding an element to it ends in UnsupportedOperationException. collect(Collectors.toList()) works from Java 8 onwards and in practice returns an ArrayList, yet the specification promises neither that class nor mutability. When mutability matters (exercise 4), spell the intent out: collect(Collectors.toCollection(ArrayList::new)).
Why can a Stream not be reused across two exercises?
A stream is single-use: after a terminal operation it is closed, and touching it again throws IllegalStateException: stream has already been operated upon or closed. If you need two results from the same data — the count and the average from exercise 5, for instance — either open a new stream from the collection or use one collector that computes both at once: Collectors.summarizingDouble or Collectors.teeing (Java 12 and later).
Would parallelStream() make these solutions faster?
No. On lists of a handful of elements a parallel stream is almost always slower than a sequential one: splitting the data and synchronising the results costs more than the work itself. On top of that, forEach does not preserve encounter order in parallel mode, and collectors such as groupingBy require a deliberate choice between the plain and the concurrent version. Parallelism pays off on large data sets and independent operations with no shared mutable state — a separate topic, not a shortcut for speeding up a practice example.
Can Optional be used as a field or a method parameter?
It is discouraged, and interviewers ask about it often. Optional was designed as a return type for methods that may find nothing — exactly how it is used in exercise 8. It is not serializable, it adds a wrapper object per value, and as a field it can itself be null, which brings the original problem straight back. For parameters, prefer an overloaded method; for fields, store the plain value and return an Optional from the getter.
Comments