Streams & Optional
A lot of everyday code follows the same pattern: take a collection, keep only some items, transform them, and combine the result into a total, a list or a report. With loops, you write the mechanics every time: a counter, an if, an add. A stream lets you describe what you want as a pipeline of steps, and Java does the looping for you.
In this lesson you will build stream pipelines with the most useful operations, group and summarise data with collectors, and use Optional, the type Java uses when a result might be missing. You need lambdas and method references from Lambdas & Functional Interfaces.
Your first stream pipeline
A stream pipeline has three parts:
- A source:
list.stream(),Stream.of(...),Arrays.stream(array)orIntStream.range(a, b). - Zero or more intermediate operations, each returning a new stream:
filter,map,sorted,distinct,limit,skip. - One terminal operation that produces the result:
count,toList,forEach,sum,collect,findFirst,anyMatch.
import java.util.List;
public class First {
public static void main(String[] args) {
List<Integer> marks = List.of(67, 92, 45, 88, 31, 74, 92);
// Loop version
int count = 0;
for (int m : marks) {
if (m >= 60) {
count++;
}
}
System.out.println("Loop: " + count + " first-division marks");
// Stream version
long passed = marks.stream()
.filter(m -> m >= 60)
.count();
System.out.println("Stream: " + passed + " first-division marks");
List<String> report = marks.stream()
.filter(m -> m >= 60)
.distinct()
.sorted()
.map(m -> m + "%")
.toList();
System.out.println(report);
System.out.println("Original list unchanged: " + marks);
}
}Loop: 5 first-division marks Stream: 5 first-division marks [67%, 74%, 88%, 92%] Original list unchanged: [67, 92, 45, 88, 31, 74, 92]
Read the second pipeline from top to bottom like a recipe: keep marks of 60 and above, remove duplicates, sort, turn each number into text with a percent sign, collect into a list. filter takes a Predicate, map takes a Function, and toList() (Java 16) returns an unmodifiable list.
Three facts about streams:
- A stream never changes its source; the original list is untouched.
- Streams are lazy: nothing runs until the terminal operation is called.
- A stream can be used only once. Calling a second terminal operation on the same stream throws
IllegalStateException: stream has already been operated upon or closed. Create a new stream from the source instead.
Common operations
import java.util.List;
import java.util.stream.IntStream;
public class Ops {
record Order(String customer, String city, int amount) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Aarav", "Jaipur", 1250),
new Order("Meera", "Kochi", 480),
new Order("Kabir", "Jaipur", 2999),
new Order("Isha", "Pune", 760),
new Order("Rohan", "Kochi", 1999));
int total = orders.stream().mapToInt(Order::amount).sum();
double average = orders.stream().mapToInt(Order::amount).average().orElse(0);
System.out.println("Total: Rs " + total + ", average: Rs " + average);
List<String> bigSpenders = orders.stream()
.filter(o -> o.amount() >= 1000)
.sorted((a, b) -> b.amount() - a.amount())
.map(Order::customer)
.toList();
System.out.println("Big spenders: " + bigSpenders);
System.out.println("Any from Pune? " + orders.stream().anyMatch(o -> o.city().equals("Pune")));
System.out.println("All above Rs 400? " + orders.stream().allMatch(o -> o.amount() > 400));
int product = IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b);
System.out.println("5! = " + product);
System.out.println(IntStream.range(0, 5).boxed().toList());
}
}Total: Rs 7488, average: Rs 1497.6 Big spenders: [Kabir, Rohan, Aarav] Any from Pune? true All above Rs 400? true 5! = 120 [0, 1, 2, 3, 4]
mapToIntturns a stream of objects into anIntStream, a special stream ofintvalues withsum,average,minandmax.average()returns anOptionalDouble, because an empty stream has no average;orElse(0)supplies a fallback.anyMatch,allMatchandnoneMatchanswer yes/no questions and stop as soon as the answer is known.reduce(start, operator)combines all elements into one value.IntStream.range(0, 5)produces 0 to 4;rangeClosed(1, 5)includes 5.boxed()convertsints toIntegers so they can go into a list.
Collectors: grouping and joining
The terminal operation collect with the helpers in java.util.stream.Collectors builds richer results: maps of groups, totals per group, or one joined string.
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class Collect {
record Order(String customer, String city, int amount) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Aarav", "Jaipur", 1250),
new Order("Meera", "Kochi", 480),
new Order("Kabir", "Jaipur", 2999),
new Order("Isha", "Pune", 760),
new Order("Rohan", "Kochi", 1999));
Map<String, Long> perCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.counting()));
System.out.println(perCity);
Map<String, Integer> salesPerCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new,
Collectors.summingInt(Order::amount)));
System.out.println(salesPerCity);
Map<Boolean, List<String>> split = orders.stream()
.collect(Collectors.partitioningBy(o -> o.amount() >= 1000,
Collectors.mapping(Order::customer, Collectors.toList())));
System.out.println(split);
String names = orders.stream()
.map(Order::customer)
.sorted()
.collect(Collectors.joining(", ", "Customers: ", "."));
System.out.println(names);
}
}{Jaipur=2, Kochi=2, Pune=1}
{Jaipur=4249, Kochi=2479, Pune=760}
{false=[Meera, Isha], true=[Aarav, Kabir, Rohan]}
Customers: Aarav, Isha, Kabir, Meera, Rohan.groupingBy(key, mapFactory, downstream) groups elements by a key and then summarises each group with a downstream collector such as counting() or summingInt(...). Passing TreeMap::new keeps the cities sorted; the simple form groupingBy(Order::city) uses a HashMap with no guaranteed order. partitioningBy splits into exactly two groups, true and false. joining glues strings together with a separator, prefix and suffix. This kind of report, written with loops and maps, would take three times as many lines.
Optional: a value that may be missing
Some operations may have no result: the first student called Priya, the maximum of an empty list. Instead of returning null and risking a NullPointerException, they return an Optional<T>, a small box that is either empty or holds one value. The type itself tells the caller "check before you use".
import java.util.List;
import java.util.Optional;
public class Opt {
record Student(String name, String city, int marks) {}
static Optional<Student> findByName(List<Student> list, String name) {
return list.stream().filter(s -> s.name().equalsIgnoreCase(name)).findFirst();
}
public static void main(String[] args) {
List<Student> students = List.of(
new Student("Ananya", "Lucknow", 91),
new Student("Farhan", "Pune", 78));
Optional<Student> found = findByName(students, "farhan");
Optional<Student> missing = findByName(students, "Priya");
System.out.println(found.isPresent() + " " + missing.isPresent());
System.out.println(found.map(Student::city).orElse("unknown"));
System.out.println(missing.map(Student::city).orElse("unknown"));
found.ifPresent(s -> System.out.println("Found " + s.name()));
missing.ifPresentOrElse(
s -> System.out.println("Found " + s.name()),
() -> System.out.println("No student called Priya"));
Optional<Student> topper = students.stream()
.max((a, b) -> Integer.compare(a.marks(), b.marks()));
System.out.println("Topper: " + topper.orElseThrow().name());
String nickname = null;
System.out.println(Optional.ofNullable(nickname).orElse("(no nickname)"));
try {
missing.orElseThrow();
} catch (Exception e) {
System.out.println(e);
}
}
}true false Pune unknown Found Farhan No student called Priya Topper: Ananya (no nickname) java.util.NoSuchElementException: No value present
The most useful Optional methods:
orElse(fallback)returns the value or a default;orElseGet(() -> ...)computes the default only when needed.map(...)transforms the value if there is one, and stays empty otherwise, so you can chain safely.ifPresent(...)andifPresentOrElse(...)run code depending on whether a value exists.orElseThrow()returns the value or throwsNoSuchElementException; use it when a missing value really is a bug.Optional.ofNullable(x)wraps a value that might benull.
Use Optional as a return type for methods that may not find anything. Do not use it for fields or method parameters, and avoid calling get() without checking; that just moves the NullPointerException somewhere else.
Streams or loops?
Streams shine for filter-transform-collect work and summaries like the reports above. Plain loops are still better when you need to break early in complex ways, update several variables at once, or when a loop is simply easier to read. Both are correct Java; choose the clearer one. Avoid changing outside variables or collections from inside a stream's lambdas; return results instead.
Common mistakes
- Forgetting the terminal operation.
list.stream().filter(...).map(...)alone does nothing. - Reusing a stream. A stream can be consumed once; call
list.stream()again. - Expecting
toList()to be modifiable. It returns an unmodifiable list; usecollect(Collectors.toCollection(ArrayList::new))if you need to add to it. - Calling
optional.get()without checking. UseorElse,map,ifPresentororElseThrow. - Using
peekorforEachto change outside state. Keep stream lambdas free of side effects.
Key points
- A stream pipeline is a source, intermediate operations (
filter,map,sorted,distinct) and one terminal operation (toList,count,sum,collect). - Streams are lazy, single-use and never modify their source.
mapToIntgivessum,average,minandmax;reducecombines elements.Collectors.groupingBy,counting,summingInt,partitioningByandjoiningbuild reports.Optionalrepresents a value that may be missing; useorElse,map,ifPresentandorElseThrow.- Java 24 adds
gatherwithGathererssuch aswindowFixedandscan.
Exercise
A cricket coach records runs as records Innings(String player, int runs): Rohan 45, Kabir 12, Rohan 78, Aarav 101, Kabir 33, Aarav 0, Rohan 9. Using streams, print: the total runs per player (sorted by name), the player with the highest single innings, the number of fifties or more, and an Optional search for a player named "Isha" that prints "No innings for Isha" when absent.
Show solution
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class Coach {
record Innings(String player, int runs) {}
public static void main(String[] args) {
List<Innings> scores = List.of(
new Innings("Rohan", 45), new Innings("Kabir", 12), new Innings("Rohan", 78),
new Innings("Aarav", 101), new Innings("Kabir", 33), new Innings("Aarav", 0),
new Innings("Rohan", 9));
Map<String, Integer> totals = scores.stream()
.collect(Collectors.groupingBy(Innings::player, TreeMap::new,
Collectors.summingInt(Innings::runs)));
System.out.println("Totals: " + totals);
scores.stream()
.max(Comparator.comparingInt(Innings::runs))
.ifPresent(i -> System.out.println("Best innings: " + i.player() + " " + i.runs()));
long fifties = scores.stream().filter(i -> i.runs() >= 50).count();
System.out.println("Fifties or more: " + fifties);
scores.stream()
.filter(i -> i.player().equals("Isha"))
.findFirst()
.ifPresentOrElse(
i -> System.out.println("Isha scored " + i.runs()),
() -> System.out.println("No innings for Isha"));
}
}Totals: {Aarav=101, Kabir=45, Rohan=132}
Best innings: Aarav 101
Fifties or more: 2
No innings for IshaComparator.comparingInt builds the comparator for max, which returns an Optional because the list could be empty. findFirst also returns an Optional, handled with ifPresentOrElse.
Quick Quiz
1. What happens if a stream pipeline has no terminal operation?
Intermediate operations only describe the work; the terminal operation triggers it.
2. Which operation transforms each element into another value?
map applies a function to every element; filter keeps or drops elements.
3. What does Collectors.groupingBy(Order::city, Collectors.counting()) produce?
groupingBy groups by the key, and the downstream counting() counts each group.
4. What should a method return when a search may find nothing?
Optional makes the possibility of "no result" part of the method's type.
Learn Java with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
