This is the lesson the course has been waiting for since module 4.
When you learned lambda expressions in 04-05, we told you their real destination was further ahead. When you mastered Function, Predicate, Consumer and Supplier in 04-06, we told you the same thing. In module 5, every BiblioTech report ended up as a nested loop with accumulators and flags. In 06-07, the "result object" pattern was left half-finished because Optional was missing. In 07-06, Files.lines() and Files.walk() showed up and all you could do with them was forEach. In 08-05 you mentioned parallelStream() and postponed it. In module 9, CatalogEnricher had to dodge stream() for six whole lessons.
All of that converges here.
Java 8, released in March 2014, was the biggest change in the history of the language. Java had been strictly imperative and object-oriented since 1995: to process a collection you declared a variable, wrote a loop and accumulated. Java 8 added a declarative, functional model in which you describe what you want to obtain and the library decides how to traverse it. It did not replace what came before —loops are still there and still correct—, but it changed the default style of the whole ecosystem.
And it added Optional, a tiny class whose purpose is to make it impossible for you to forget to check for the absence of a value. The null that findByReference has been returning since module 6, and that sixteen different places have to remember to check, is living on borrowed time.
By the end, BiblioTech will have its complete report by material type and by employee in three lines, its file traversals processed lazily without loading anything into memory, and not a single null meaning "not found".
Contents
- What Java 8 brought and what you have already seen
- What a stream is and what it is NOT
- The pipeline: source, intermediate operations, terminal operation
- Lazy evaluation: the real order of execution
- Stream sources
- Intermediate operations
flatMap: the difficult casetakeWhileanddropWhile- Terminal operations
reducein its three forms- The basic
Collectors groupingByandpartitioningBy- Primitive streams
- Parallel streams
- The BiblioTech report: from thirty lines to three
Optional: what it was created for- Creating and consuming an
Optional orElseversusorElseGet- The
Optionalantipatterns - What to return when there is no value
- BiblioTech refactored
- Common Mistakes and Tips
- Exercises
- What Java 8 brought and what you have already seen
Java 8 introduced a package of features that hold each other up. You already know a good part of them, and it is worth seeing the full map before starting:
| Feature | What it is | Where you saw it |
|---|---|---|
| Lambda expressions | Anonymous functions: x -> x * 2 |
04-05 |
| Functional interfaces | Interfaces with a single abstract method | 04-06 |
java.util.function |
Function, Predicate, Consumer, Supplier, BiFunction |
04-06 |
| Method references | Book::getTitle, System.out::println |
04-06 |
default methods |
Implementations in interfaces without breaking anyone | 04-01 |
static methods in interfaces |
Factories next to the contract | 04-01 |
Fluent Comparator |
comparing, thenComparing, reversed |
05-09 |
Improved Map |
computeIfAbsent, merge, getOrDefault, forEach |
05-05 |
CompletableFuture |
Asynchronous composition | 08-07 |
java.time |
The new date API | 10-05 |
| Streams | Declarative processing of sequences | This lesson |
Optional |
A "there may be a value" container | This lesson |
Everything in rows 1 to 4 is a prerequisite for this lesson, and that is why it arrives now and not earlier. A stream is a pipeline of operations that are handed lambdas and method references; without mastering that, map(Book::getTitle) would be noise.
The underlying reason for the whole package was very concrete: processors stopped getting faster and started getting more numerous. Taking advantage of eight cores with for loops means writing the partitioning, the threads and the collection by hand —which is what you did in module 8, and you know what it costs—. With a declarative description of what you want, the library can parallelise it by changing one word. That is parallelStream(), and we will get to it with all the caution it deserves.
- What a stream is and what it is NOT
A stream is a sequence of elements that supports aggregate operations. And the best way to understand it is by contrast, because the name invites confusion with InputStream (07-03), with which it has absolutely nothing to do.
A Stream is not... |
Explanation |
|---|---|
| ...a collection | It is not a data structure: it is a processing view over a source |
| ...a store of elements | It does not take memory proportional to the size; elements flow through it |
| ...something that modifies the source | filter deletes nothing from the original List |
| ...reusable | It is single-use: once consumed, it dies |
| ...indexed | There is no get(3); access is sequential |
...an InputStream |
Nothing to do with the byte I/O of 07-03 |
And what it is:
- A description of a computation over a sequence, which is not executed until the result is requested.
- Lazy: nothing happens until the terminal operation.
- Potentially infinite:
Stream.iterategenerates elements endlessly, andlimitbounds them. - Parallelisable by swapping
stream()forparallelStream().
The single-use property is the one that surprises people most:
List<Book> catalog = List.of(
new Book("978-0000000001", "Effective Java", 412, true),
new Book("978-0000000002", "Design Patterns", 395, false));
Stream<Book> stream = catalog.stream();
long howMany = stream.count(); // first terminal operation: OK
List<Book> list = stream.toList(); // second: BLOWS UPException in thread "main" java.lang.IllegalStateException:
stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:203)A stream is consumed once. If you need two traversals, create two streams from the source:
That is why you never store a Stream in a field or pass it around: you create it, use it and discard it, usually within a single expression.
And here is the contrast that sums up the lesson. The same problem —the titles of the books over 400 pages that are on loan, sorted alphabetically— solved both ways:
// BEFORE (module 5): imperative. It describes HOW.
List<String> result = new ArrayList<>();
for (Book book : catalog) {
if (book.getPages() > 400 && book.isOnLoan()) {
result.add(book.getTitle());
}
}
Collections.sort(result);// NOW: declarative. It describes WHAT.
List<String> result = catalog.stream()
.filter(book -> book.getPages() > 400)
.filter(Book::isOnLoan)
.map(Book::getTitle)
.sorted()
.toList();The differences are not only about line count:
- There is no accumulator variable and no mutable state to manage.
- It reads top to bottom like a sentence: of the books, those over 400 pages, the ones on loan, their titles, sorted.
- There are no indices or boundary conditions to get wrong.
- Switching to parallel is one word.
- The pipeline: source, intermediate operations, terminal operation
Every stream has exactly three parts:
graph LR
F["SOURCE<br/>collection, array,<br/>file, generator"] --> I1["INTERMEDIATE<br/>filter"]
I1 --> I2["INTERMEDIATE<br/>map"]
I2 --> I3["INTERMEDIATE<br/>sorted"]
I3 --> T["TERMINAL<br/>collect, forEach,<br/>count, reduce"]
T --> R["RESULT<br/>List, long, Optional..."]
| Part | How many | Returns | When it runs |
|---|---|---|---|
| Source | Exactly 1 | Stream<T> |
On creation |
| Intermediate operation | 0 or more | Stream<R> (chainable) |
Never on its own: lazy |
| Terminal operation | Exactly 1 | A result (or nothing) | It triggers all the processing |
The rule that explains everything: if a Stream method returns a Stream, it is intermediate and does nothing; if it returns anything else, it is terminal and does everything.
// This PRINTS NOTHING and TRAVERSES NOTHING
catalog.stream()
.filter(b -> b.getPages() > 400)
.map(Book::getTitle);Without a terminal operation the pipeline is assembled but closed. The compiler does not even warn you (although modern IDEs do). It is a real and frequent mistake: somebody writes list.stream().filter(...) expecting the list to be filtered, and nothing happens.
- Lazy evaluation: the real order of execution
This is conceptually the most important part of streams, and the one most people get wrong.
The wrong intuition: the stream applies filter to every element, produces an intermediate list, applies map to all of them, produces another one, and so on.
The reality: each element goes through the whole pipeline before the next one starts. There are no intermediate lists.
Let us prove it with traces:
package com.nexussoftware.bibliotech;
import java.util.List;
public class LazinessDemo {
public static void main(String[] args) {
List<Book> catalog = List.of(
new Book("978-0000000001", "Effective Java", 412),
new Book("978-0000000002", "Design Patterns", 395),
new Book("978-0000000003", "Refactoring", 448),
new Book("978-0000000004", "Clean Code", 464));
System.out.println("--- Building the pipeline ---");
List<String> result = catalog.stream()
.filter(book -> {
System.out.println(" filter(" + book.getTitle() + ")");
return book.getPages() > 400;
})
.map(book -> {
System.out.println(" map(" + book.getTitle() + ")");
return book.getTitle().toUpperCase();
})
.limit(2)
.toList();
System.out.println("--- Result: " + result + " ---");
}
}--- Building the pipeline ---
filter(Effective Java)
map(Effective Java)
filter(Design Patterns)
filter(Refactoring)
map(Refactoring)
--- Result: [EFFECTIVE JAVA, REFACTORING] ---Study that output calmly, because it contains three lessons.
1. Processing happens element by element, vertically. "Effective Java" goes through filter and then through map before "Design Patterns" touches the filter. There is no filtering phase followed by a mapping phase.
2. map does not run on the filtered-out elements. "Design Patterns" has 395 pages, does not pass the filter, and its map never runs. If map were an expensive operation —a database query, a network request—, this would be the difference between four calls and two.
3. "Clean Code" is never processed. The limit(2) short-circuited the pipeline: as soon as there were two results, the stream stopped asking the source for elements. The fourth book was not even filtered.
That behaviour is called operation fusion (loop fusion): the set of intermediate operations is conceptually compiled into a single traversal. Compare it with the naive approach:
// What the stream does NOT do: three traversals and two temporary lists
List<Book> filtered = new ArrayList<>();
for (Book b : catalog) if (b.getPages() > 400) filtered.add(b);
List<String> mapped = new ArrayList<>();
for (Book b : filtered) mapped.add(b.getTitle().toUpperCase());
List<String> limited = mapped.subList(0, Math.min(2, mapped.size()));A stream with twenty intermediate operations still performs a single traversal and zero intermediate lists. That is why chaining operations does not hurt performance the way you might fear.
Laziness also makes the impossible possible: working with infinite sequences.
// Stream.iterate generates elements ENDLESSLY
List<String> references = Stream.iterate(1, n -> n + 1)
.map(n -> String.format("LN-2026-%04d", n))
.limit(5)
.toList();
System.out.println(references);Without laziness, Stream.iterate would hang the program. With it, only the five elements the limit asks for are generated.
- Stream sources
| Source | Syntax | Notes |
|---|---|---|
| Collection | collection.stream() |
The usual one. It lives in Collection, so List, Set and Queue all have it |
| Array | Arrays.stream(array) |
Also Arrays.stream(array, from, to) |
| Individual values | Stream.of(a, b, c) |
Varargs |
| Empty | Stream.empty() |
Useful as a default value |
| One or none | Stream.ofNullable(x) |
Java 9: empty if x is null |
| Iteration | Stream.iterate(seed, f) |
Infinite; there is a variant with a predicate (Java 9) |
| Generation | Stream.generate(supplier) |
Infinite |
| Integer range | IntStream.range(0, 10) |
rangeClosed includes the end |
| Lines of a file | Files.lines(path, charset) |
It must be closed |
| File tree | Files.walk(path) |
It must be closed |
| Characters | text.chars() |
Returns an IntStream |
| Parts of a text | Pattern.compile(";").splitAsStream(line) |
Lazy, unlike split |
| Map | map.entrySet().stream() |
A Map has no stream() of its own |
// Collections
Stream<Book> s1 = catalog.stream();
Stream<String> s2 = new HashSet<>(List.of("a", "b")).stream();
// Arrays
String[] employees = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };
Stream<String> s3 = Arrays.stream(employees);
// Individual values
Stream<String> s4 = Stream.of("978-0000000001", "978-0000000002");
// Infinite, bounded with limit
Stream<Integer> s5 = Stream.iterate(1, n -> n * 2).limit(10);
Stream<Double> s6 = Stream.generate(Math::random).limit(5);
// With a stop condition (Java 9): limit is no longer needed
Stream<Integer> s7 = Stream.iterate(1, n -> n <= 100, n -> n * 2);
// Integer range
IntStream s8 = IntStream.rangeClosed(1, 12); // 1..12
// Map: always through its entries
Map<String, Integer> loansByEmployee = Map.of("Marta Ruiz", 4, "Diego Alonso", 2);
Stream<Map.Entry<String, Integer>> s9 = loansByEmployee.entrySet().stream();Files.lines(): picking up 07-06
In 07-06 you used Files.lines() with a plain forEach because the full API was still pending. Now it can be put to real use:
package com.nexussoftware.bibliotech.persistence;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;
import java.util.stream.Stream;
public class CatalogImporter {
/**
* Reads a catalogue CSV with LAZY processing.
*
* try-with-resources (06-06) is MANDATORY: Files.lines keeps a file
* descriptor open until the stream is closed.
*/
public List<Book> importFrom(Path file) throws IOException {
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
return lines
.skip(1) // skip the header
.map(String::strip)
.filter(line -> !line.isBlank()) // ignore blank lines
.filter(line -> !line.startsWith("#")) // ignore comments
.map(this::parse)
.filter(java.util.Objects::nonNull) // drop the malformed ones
.toList();
}
}
private Book parse(String line) {
String[] fields = line.split(";", -1);
if (fields.length < 4) {
return null;
}
try {
return new Book(fields[0], fields[1], fields[2], Integer.parseInt(fields[3]));
} catch (NumberFormatException e) {
return null;
}
}
/** Counts lines of a 2 GB file without loading it into memory. */
public long countLoansOf(Path file, String employee) throws IOException {
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
return lines.filter(l -> l.contains(employee)).count();
}
}
}Two critical points.
The try-with-resources is not optional. Files.lines() returns a stream backed by an open file. Without closing it, you exhaust the process's descriptors and end up with Too many open files. It is the same problem as in 07-01, with the difference that here the resource is hidden inside a stream.
The processing is genuinely lazy. countLoansOf over a two-gigabyte file does not load the file into memory: it reads a line, evaluates it, discards it. Compare it with Files.readAllLines(), which builds a complete List<String> and throws OutOfMemoryError on large files. It is the same distinction as in 07-04 between reading everything and reading with a BufferedReader, but now with the whole processing API on top.
And Files.walk(), also from 07-06, now pays off:
/** The three most recent backup files in a directory. */
public List<Path> recentBackups(Path directory) throws IOException {
try (Stream<Path> paths = Files.walk(directory, 2)) { // maximum depth 2
return paths
.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".bak"))
.sorted(Comparator.comparing(this::lastModified).reversed())
.limit(3)
.toList();
}
}
- Intermediate operations
They all return a Stream and none of them executes anything on its own.
filter: keep the ones that match
List<Book> onLoan = catalog.stream()
.filter(Book::isOnLoan)
.toList();
// Predicates can be composed (04-06)
Predicate<Book> longBook = b -> b.getPages() > 400;
Predicate<Book> available = b -> !b.isOnLoan();
List<Book> longAndAvailable = catalog.stream()
.filter(longBook.and(available))
.toList();Notice the Predicate<? super T>: PECS from 10-01, which now reads effortlessly.
map: transform each element
map changes the type of the stream. A Stream<Book> with map(Book::getTitle) becomes a Stream<String>:
List<String> titles = catalog.stream()
.map(Book::getTitle) // Stream<Book> -> Stream<String>
.toList();
// Chaining transformations
List<Integer> lengths = catalog.stream()
.map(Book::getTitle) // Stream<String>
.map(String::length) // Stream<Integer>
.toList();
// Into a record (04-07)
List<Card> cards = catalog.stream()
.map(b -> new Card(b.getIsbn(), b.getTitle(), !b.isOnLoan()))
.toList();map does not change the number of elements: N go in, N come out. It only changes their type or their value.
mapToInt, mapToObj and family
They convert between object streams and primitive streams (section 13):
// Object -> primitive
int totalPages = catalog.stream()
.mapToInt(Book::getPages) // Stream<Book> -> IntStream
.sum(); // sum() exists ONLY in IntStream
// Primitive -> object
List<String> references = IntStream.rangeClosed(1, 3)
.mapToObj(n -> String.format("LN-2026-%04d", n)) // IntStream -> Stream<String>
.toList();distinct: remove duplicates
It uses equals/hashCode (03-09). If your objects do not implement them properly, distinct removes nothing. Internally it is a LinkedHashSet, with the memory cost that implies.
sorted: sort
// Natural order: it requires T to implement Comparable (10-01)
List<String> sortedTitles = titles.stream().sorted().toList();
// With a Comparator (05-09)
List<Book> byPages = catalog.stream()
.sorted(Comparator.comparingInt(Book::getPages).reversed())
.toList();
// Composed ordering
List<Loan> sortedLoans = loans.stream()
.sorted(Comparator.comparing(Loan::getEmployee)
.thenComparing(Loan::getLoanDay)
.reversed())
.toList();sorted is a stateful operation: it needs to see all the elements before emitting the first one. That breaks element-by-element laziness and has two practical consequences:
- On an infinite stream it hangs.
Stream.iterate(1, n -> n+1).sorted()never finishes. - Put it as late as possible.
filter(...).sorted()sorts fewer elements thansorted().filter(...), and the result is the same.
peek: look without touching
It runs an action on each element and lets it through untouched. It has exactly one legitimate use: debugging.
List<String> result = catalog.stream()
.peek(b -> System.out.println("in: " + b.getTitle()))
.filter(b -> b.getPages() > 400)
.peek(b -> System.out.println(" passes the filter: " + b.getTitle()))
.map(Book::getTitle)
.toList();Why not use it for anything else:
- It is not guaranteed to run. The documentation says so explicitly: if the implementation can compute the result without traversing every element, it may skip the
peek. In Java 9+,list.stream().peek(...).count()does not run the peek, because the size is known without traversing. - It invites side effects.
peek(b -> otherList.add(b))is exactly the style streams came to eliminate, and in parallel it corrupts the list.
If you want to modify, use map. If you want to accumulate, use collect. peek is only for looking.
limit and skip
List<Book> firstThree = catalog.stream().limit(3).toList();
List<Book> skippedTwo = catalog.stream().skip(2).toList();
// Pagination: page 3 with 10 elements per page
int page = 3, size = 10;
List<Book> currentPage = catalog.stream()
.skip((long) (page - 1) * size)
.limit(size)
.toList();limit short-circuits: as soon as it has N elements, it stops asking the source (you saw it in section 4). skip does have to traverse the first N in order to discard them.
flatMap: the difficult case
flatMap: the difficult caseflatMap is the operation people struggle with most, and it deserves its own section.
Read it like this: map turns each element into another element; flatMap turns each element into a stream of elements, and then flattens all those streams into a single one.
graph LR
subgraph map
A1["Employee 1"] --> B1["List of loans 1"]
A2["Employee 2"] --> B2["List of loans 2"]
end
subgraph flatMap
C1["Employee 1"] --> D1["l1, l2"]
C2["Employee 2"] --> D2["l3"]
D1 --> E["l1, l2, l3"]
D2 --> E
end
The real BiblioTech case: each employee has a list of loans, and we want all the library's loans.
public class Employee {
private final String name;
private final List<Loan> loans;
public List<Loan> getLoans() { return loans; }
public String getName() { return name; }
}List<Employee> staff = List.of(
new Employee("Marta Ruiz", List.of(l1, l2, l3)),
new Employee("Diego Alonso", List.of(l4)),
new Employee("Nuria Vidal", List.of(l5, l6)));
// WITH map: not what you want
Stream<List<Loan>> withMap = staff.stream().map(Employee::getLoans);
List<List<Loan>> nested = withMap.toList();
System.out.println(nested.size()); // 3: one list per employee
// WITH flatMap: flattened
List<Loan> all = staff.stream()
.flatMap(employee -> employee.getLoans().stream()) // mind the .stream()!
.toList();
System.out.println(all.size()); // 6: every loanThe detail everyone forgets: the function passed to flatMap must return a Stream, not a List. That is why you have to write .stream() inside:
.flatMap(e -> e.getLoans().stream()) // GOOD
.flatMap(Employee::getLoans) // DOES NOT COMPILE: returns List, not StreamNow the flattened result can be processed further:
// Every overdue loan in the whole library, sorted
List<Loan> overdue = staff.stream()
.flatMap(e -> e.getLoans().stream())
.filter(Loan::isOverdue)
.sorted(Comparator.comparingInt(Loan::getDaysLate).reversed())
.toList();
// Every incident of every loan of every employee: TWO levels
List<Loan.Incident> allIncidents = staff.stream()
.flatMap(e -> e.getLoans().stream())
.flatMap(l -> l.getIncidents().stream())
.toList();Other common uses of flatMap:
// Split strings into words
List<String> words = titles.stream()
.flatMap(t -> Arrays.stream(t.split("\\s+")))
.map(String::toLowerCase)
.distinct()
.sorted()
.toList();
// Flatten a Map<String, List<Loan>>
List<Loan> flat = byEmployee.values().stream()
.flatMap(List::stream)
.toList();
// With Optional (Java 9): drop the empty ones in one go
List<Book> found = isbns.stream()
.map(catalog::findByIsbn) // Stream<Optional<Book>>
.flatMap(Optional::stream) // Stream<Book>, without the empties
.toList();That last one is a very useful idiom you will meet again in section 17.
takeWhile and dropWhile
takeWhile and dropWhileAdded in Java 9, they are cousins of filter with one crucial difference: they stop as soon as the condition fails, instead of carrying on evaluating.
| Operation | Behaviour |
|---|---|
filter(p) |
Keeps all the ones that match, traversing everything |
takeWhile(p) |
Takes elements up to the first one that does not match, and stops |
dropWhile(p) |
Discards elements up to the first one that does not match, and takes the rest |
List<Integer> pages = List.of(500, 480, 450, 380, 420, 300);
System.out.println(pages.stream().filter(p -> p > 400).toList());
System.out.println(pages.stream().takeWhile(p -> p > 400).toList());
System.out.println(pages.stream().dropWhile(p -> p > 400).toList());[500, 480, 450, 420] filter: ALL of those over 400
[500, 480, 450] takeWhile: stops at 380
[380, 420, 300] dropWhile: discards up to 380, then everythingThey only make sense on sorted data, and there they are very powerful:
// Log sorted by date: read only up to leaving the range, without traversing a 2 GB file
try (Stream<String> lines = Files.lines(logPath, StandardCharsets.UTF_8)) {
List<String> today = lines
.dropWhile(l -> !l.startsWith("2026-08-05")) // skip earlier days
.takeWhile(l -> l.startsWith("2026-08-05")) // stop on reaching tomorrow
.toList();
}With filter you would have to read the whole file. With takeWhile, reading stops as soon as the first line of the following day appears.
- Terminal operations
They trigger the processing and return a result (or nothing).
forEach and forEachOrdered
On a parallel stream, forEach does not guarantee the order; forEachOrdered does, at a performance cost:
catalog.parallelStream().forEach(System.out::println); // unpredictable order
catalog.parallelStream().forEachOrdered(System.out::println); // source orderTip: if you are going to build a collection, do not use forEach with an add. Use collect. forEach is for final effects: printing, sending, saving.
toList() and collect
// Java 16+: the short form. It returns an UNMODIFIABLE list.
List<String> titles = catalog.stream().map(Book::getTitle).toList();
// The classic form, with Collectors
List<String> titles2 = catalog.stream().map(Book::getTitle)
.collect(Collectors.toList()); // modifiable, with no type guarantee
// If you need a modifiable list of a specific type
List<String> modifiable = catalog.stream().map(Book::getTitle)
.collect(Collectors.toCollection(ArrayList::new));| Form | Modifiable | Accepts null |
Version |
|---|---|---|---|
.toList() |
No | Yes | Java 16+ |
.collect(Collectors.toList()) |
Usually yes (not guaranteed) | Yes | Java 8+ |
.collect(Collectors.toUnmodifiableList()) |
No | No | Java 10+ |
.collect(Collectors.toCollection(ArrayList::new)) |
Yes, guaranteed | Yes | Java 8+ |
Use .toList() by default. It is shorter, clearer and returns something unmodifiable, which is what you want 90% of the time.
count
Watch out for a surprising optimisation: since Java 9, if the stream can know its size without traversing, count() does not run the intermediate operations:
long n = catalog.stream()
.peek(b -> System.out.println("seen: " + b.getTitle()))
.count();
// It prints nothing: count() knows the list's size without traversing itWith a filter in front it does traverse, because the filter can change the number.
min and max
They return Optional because the stream can be empty:
Optional<Book> longest = catalog.stream()
.max(Comparator.comparingInt(Book::getPages));
longest.ifPresent(b -> System.out.println("The longest: " + b.getTitle()));
// With a default value
String title = catalog.stream()
.min(Comparator.comparing(Book::getTitle))
.map(Book::getTitle)
.orElse("(empty catalogue)");anyMatch, allMatch, noneMatch and short-circuiting
boolean anyOnLoan = catalog.stream().anyMatch(Book::isOnLoan);
boolean allAvailable = catalog.stream().allMatch(b -> !b.isOnLoan());
boolean noneWithoutName = catalog.stream().noneMatch(b -> b.getTitle().isBlank());All three short-circuit: they stop as soon as they know the answer.
System.out.println(catalog.stream()
.peek(b -> System.out.println("checking: " + b.getTitle()))
.anyMatch(b -> b.getPages() > 400));It only evaluated the first one: having found one that matches, the answer is already true.
The empty-stream trap, which follows mathematical logic and confuses everybody:
List<Book> empty = List.of();
System.out.println(empty.stream().anyMatch(b -> true)); // false
System.out.println(empty.stream().allMatch(b -> false)); // true!
System.out.println(empty.stream().noneMatch(b -> true)); // trueallMatch over an empty set is vacuously true: "every element satisfies X" has no counterexample if there are no elements. Mathematically correct and a source of bugs if you do not expect it.
findFirst and findAny
Optional<Book> first = catalog.stream()
.filter(b -> b.getPages() > 400)
.findFirst();
Optional<Book> any = catalog.parallelStream()
.filter(b -> b.getPages() > 400)
.findAny();| Operation | Sequential | Parallel |
|---|---|---|
findFirst() |
The first in encounter order | The first in order, with a coordination cost |
findAny() |
Usually the first | Any one, with no coordination: faster |
On sequential streams they are practically equivalent. In parallel, findAny is the right choice when any match will do.
reduce in its three forms
reduce in its three formsreduce combines every element into a single one by repeatedly applying a binary operation. It has three overloads and it is worth understanding them separately.
Form 1: accumulator only → returns Optional
Optional<Integer> totalPages = catalog.stream()
.map(Book::getPages)
.reduce((a, b) -> a + b);
System.out.println(totalPages.orElse(0));It returns Optional because if the stream is empty there is no value to return. There is no "zero" the operation could invent: reduce does not know whether you are combining numbers (where it would be 0), strings (where it would be "") or maxima (where it would be -∞).
Step by step, with [412, 395, 448]:
step 1: accumulator = 412 (first element)
step 2: accumulator = 412 + 395 = 807
step 3: accumulator = 807 + 448 = 1255
result: Optional[1255]Form 2: identity + accumulator → returns T
int totalPages = catalog.stream()
.map(Book::getPages)
.reduce(0, Integer::sum); // 0 is the identity
String allTitles = catalog.stream()
.map(Book::getTitle)
.reduce("", (a, b) -> a.isEmpty() ? b : a + " | " + b);Because there is an identity, there is no Optional: with an empty stream the identity is returned.
The identity must really be one: op(identity, x) must equal x for any x. For addition it is 0, for multiplication 1, for concatenation "", for maximum Integer.MIN_VALUE. Using 1 as the identity of a sum gives wrong results, and in parallel the error multiplies because the identity is applied in every partition:
// WRONG: incorrect identity
int sum = Stream.of(1, 2, 3, 4).reduce(10, Integer::sum);
System.out.println(sum); // 20, not 10
int parallelSum = Stream.of(1, 2, 3, 4).parallel().reduce(10, Integer::sum);
System.out.println(parallelSum); // 50! the 10 was added in every partitionForm 3: identity + accumulator + combiner → changes type
This form lets the result type be different from the element type. The third argument, the combiner, says how to merge two partial results, and it is only used in parallel.
// Summing the pages of Book objects, producing an int
int totalPages = catalog.parallelStream()
.reduce(0, // identity: an int
(sum, book) -> sum + book.getPages(), // accumulate Book into int
Integer::sum); // merge two partial intsgraph TD
A["full catalogue"] --> B["partition 1"]
A --> C["partition 2"]
B -->|"accumulator"| D["partial 1 = 807"]
C -->|"accumulator"| E["partial 2 = 464"]
D -->|"combiner"| F["total = 1271"]
E -->|"combiner"| F
In practice this third form is rarely used, because collect with Collectors is more readable and mapToInt(...).sum() solves the numeric case in one stroke. But understanding it explains how collect works inside, and why a Collector needs a combiner.
When to use reduce: when the result is a single value obtained by combining elements and there is no Collector that does it. For sums, averages and counts, use primitive streams or the Collectors; they are clearer and faster.
- The basic
Collectors
Collectorscollect is the most versatile terminal operation: it accumulates the elements into a structure using a Collector. The java.util.stream.Collectors class brings factories for everything.
toList, toSet, toMap
List<String> list = catalog.stream().map(Book::getTitle).collect(Collectors.toList());
Set<String> set = catalog.stream().map(Book::getAuthor).collect(Collectors.toSet());
// toMap: key and value
Map<String, Book> byIsbn = catalog.stream()
.collect(Collectors.toMap(Book::getIsbn, book -> book));
Map<String, String> titleByIsbn = catalog.stream()
.collect(Collectors.toMap(Book::getIsbn, Book::getTitle));The toMap trap: duplicate keys throw an exception.
// Two books by the same author
Map<String, String> byAuthor = catalog.stream()
.collect(Collectors.toMap(Book::getAuthor, Book::getTitle));Exception in thread "main" java.lang.IllegalStateException:
Duplicate key Martin Fowler (attempted merging values Refactoring and UML Distilled)
at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)The solution is the third argument, the merge function, which decides what to do with the conflict:
// Keep the first one
Map<String, String> first = catalog.stream()
.collect(Collectors.toMap(Book::getAuthor, Book::getTitle,
(existing, replacement) -> existing));
// Keep the last one
Map<String, String> last = catalog.stream()
.collect(Collectors.toMap(Book::getAuthor, Book::getTitle,
(existing, replacement) -> replacement));
// Combine
Map<String, String> combined = catalog.stream()
.collect(Collectors.toMap(Book::getAuthor, Book::getTitle,
(a, b) -> a + "; " + b));
// And with a fourth argument you choose the Map implementation
Map<String, String> ordered = catalog.stream()
.collect(Collectors.toMap(Book::getIsbn, Book::getTitle,
(a, b) -> a, TreeMap::new));Second trap: toMap does not accept null values. It throws NullPointerException, unlike HashMap.put. If your values can be null, filter first.
joining: concatenating strings
String titles = catalog.stream()
.map(Book::getTitle)
.collect(Collectors.joining(", "));
// Effective Java, Design Patterns, Refactoring
String withBorders = catalog.stream()
.map(Book::getTitle)
.collect(Collectors.joining(", ", "Catalogue [", "]"));
// Catalogue [Effective Java, Design Patterns, Refactoring]It uses StringBuilder inside, so it is efficient even with thousands of elements (you will see it quantified in 10-07).
Statistics: counting, summing, averaging, summarizing
long total = catalog.stream().collect(Collectors.counting());
int pages = catalog.stream().collect(Collectors.summingInt(Book::getPages));
double average = catalog.stream().collect(Collectors.averagingInt(Book::getPages));
double ratingSum = catalog.stream().collect(Collectors.summingDouble(Book::getRating));
double ratingAverage = catalog.stream().collect(Collectors.averagingDouble(Book::getRating));
// Everything at once, in a single traversal
IntSummaryStatistics stats = catalog.stream()
.collect(Collectors.summarizingInt(Book::getPages));
System.out.println("Books: " + stats.getCount());
System.out.println("Total: " + stats.getSum());
System.out.println("Average: " + stats.getAverage());
System.out.println("Minimum: " + stats.getMin());
System.out.println("Maximum: " + stats.getMax());summarizingInt computes the five statistics in a single traversal. With loops you would need five variables and care with the initial minimum and maximum.
Note: counting() and summingInt() on their own are unnecessarily indirect —stream().count() and stream().mapToInt(...).sum() are clearer—. Their real value appears as a downstream of groupingBy, in the next section.
groupingBy and partitioningBy
groupingBy and partitioningByHere is the operation that turns thirty lines of nested loops into one.
Simple groupingBy
Map<MaterialType, List<Material>> byType = inventory.stream()
.collect(Collectors.groupingBy(Material::getType));{BOOK=[Effective Java, Design Patterns, Refactoring],
MAGAZINE=[Java Magazine],
DVD=[Spring Course]}Compare it with the imperative equivalent, which is exactly what you wrote in module 5:
Map<MaterialType, List<Material>> byType = new HashMap<>();
for (Material m : inventory) {
byType.computeIfAbsent(m.getType(), t -> new ArrayList<>()).add(m);
}Four lines against one, and the one-liner says far more clearly what the intent is.
groupingBy with a downstream
The second argument is another Collector that decides what to do with each group, instead of accumulating it into a list. This is where counting, summing and friends make sense:
// How many materials of each type
Map<MaterialType, Long> countByType = inventory.stream()
.collect(Collectors.groupingBy(Material::getType, Collectors.counting()));
// {BOOK=3, MAGAZINE=1, DVD=1}
// Total pages by type
Map<MaterialType, Integer> pagesByType = inventory.stream()
.collect(Collectors.groupingBy(Material::getType,
Collectors.summingInt(Material::getPages)));
// Average rating by type
Map<MaterialType, Double> averageByType = inventory.stream()
.collect(Collectors.groupingBy(Material::getType,
Collectors.averagingDouble(Material::getRating)));
// Only the TITLES of each group: mapping transforms before accumulating
Map<MaterialType, List<String>> titlesByType = inventory.stream()
.collect(Collectors.groupingBy(Material::getType,
Collectors.mapping(Material::getTitle,
Collectors.toList())));
// The best rated of each type: it returns Optional inside the map
Map<MaterialType, Optional<Material>> bestByType = inventory.stream()
.collect(Collectors.groupingBy(Material::getType,
Collectors.maxBy(Comparator.comparingDouble(Material::getRating))));
// Without the Optional, with maxBy reduced (Java 9)
Map<MaterialType, Material> bestByType2 = inventory.stream()
.collect(Collectors.groupingBy(Material::getType,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingDouble(Material::getRating)),
opt -> opt.orElse(null))));
// Full statistics by type
Map<MaterialType, IntSummaryStatistics> statsByType = inventory.stream()
.collect(Collectors.groupingBy(Material::getType,
Collectors.summarizingInt(Material::getPages)));
// With a TreeMap so that the keys come out sorted
Map<String, Long> byAuthorSorted = catalog.stream()
.collect(Collectors.groupingBy(Book::getAuthor, TreeMap::new, Collectors.counting()));Two-level groupingBy
The downstream can be another groupingBy, and that is where nested grouping appears:
// Loans by employee and, within that, by material type
Map<String, Map<MaterialType, List<Loan>>> byEmployeeAndType = loans.stream()
.collect(Collectors.groupingBy(
Loan::getEmployee,
Collectors.groupingBy(l -> l.getMaterial().getType())));
// And with counting on the inner level
Map<String, Map<MaterialType, Long>> countByEmployeeAndType = loans.stream()
.collect(Collectors.groupingBy(
Loan::getEmployee,
Collectors.groupingBy(l -> l.getMaterial().getType(),
Collectors.counting())));That result, in module 5, was three nested loops, two HashMaps, two computeIfAbsent calls and about twenty lines.
partitioningBy
A special case of groupingBy with a Predicate: it always produces a Map<Boolean, ...> with exactly two keys, true and false, even if one group is empty.
Map<Boolean, List<Book>> byAvailability = catalog.stream()
.collect(Collectors.partitioningBy(Book::isOnLoan));
List<Book> onLoan = byAvailability.get(true);
List<Book> available = byAvailability.get(false);
// With a downstream
Map<Boolean, Long> count = catalog.stream()
.collect(Collectors.partitioningBy(b -> b.getPages() > 400,
Collectors.counting()));groupingBy(p::test) |
partitioningBy(p) |
|
|---|---|---|
| Keys | Only the ones that appear | Always true and false |
| Empty group | The key does not appear | It appears with an empty list |
| Performance | General-purpose map | Optimised for two keys |
That difference matters: with groupingBy, if no book is on loan, get(true) returns null and produces a NullPointerException. With partitioningBy, it returns an empty list.
teeing
Added in Java 12, it lets you apply two collectors at once and combine their results in a single traversal:
record CatalogSummary(long total, double averagePages) { }
CatalogSummary summary = catalog.stream()
.collect(Collectors.teeing(
Collectors.counting(),
Collectors.averagingInt(Book::getPages),
CatalogSummary::new));It is useful when you need two aggregates and do not want to traverse twice. It is mentioned for completeness; day to day it is rarely used.
- Primitive streams
Stream<Integer> holds Integer objects, and every operation involves autoboxing: wrapping an int in an object, with its memory allocation and its indirection. For large numeric sequences that is a notable waste.
Java 8 brings three specialised streams: IntStream, LongStream and DoubleStream.
| Aspect | Stream<Integer> |
IntStream |
|---|---|---|
| Stores | Integer objects |
primitive ints |
| Autoboxing | On every operation | None |
| Memory | ~16 bytes per element | 4 bytes |
| Numeric methods | No | sum, average, max, min, summaryStatistics |
map returns |
Stream<R> |
IntStream (use mapToObj to get out) |
// Sum without autoboxing
int totalPages = catalog.stream()
.mapToInt(Book::getPages)
.sum();
// Average: it returns OptionalDouble, because an empty stream has no average
OptionalDouble average = catalog.stream()
.mapToInt(Book::getPages)
.average();
System.out.printf("Average: %.1f pages%n", average.orElse(0.0));
// Statistics in one go
IntSummaryStatistics stats = catalog.stream()
.mapToInt(Book::getPages)
.summaryStatistics();
// Ranges
IntStream.range(0, 5).forEach(System.out::println); // 0,1,2,3,4
IntStream.rangeClosed(1, 5).forEach(System.out::println); // 1,2,3,4,5
// Back to objects
List<Integer> list = IntStream.rangeClosed(1, 5).boxed().toList();
List<String> refs = IntStream.rangeClosed(1, 3)
.mapToObj(n -> String.format("LN-2026-%04d", n))
.toList();
// Characters of a string
long vowels = "Effective Java".chars()
.filter(c -> "aeiouAEIOU".indexOf(c) >= 0)
.count();The primitive Optionals. average() returns OptionalDouble, not Optional<Double>; max() on an IntStream returns OptionalInt. They are autoboxing-free versions of Optional, with the same basic API (isPresent, getAsDouble, orElse) but without map, filter or flatMap. If you need to chain, convert them:
OptionalDouble average = catalog.stream().mapToInt(Book::getPages).average();
String text = average.isPresent()
? String.format("%.1f", average.getAsDouble())
: "no data";
// Or by converting to Optional<Double>
String text2 = average.stream().boxed().findFirst()
.map(d -> String.format("%.1f", d))
.orElse("no data");Practical rule: use primitive streams whenever you work with numbers. The code is shorter (sum() instead of reduce(0, Integer::sum)) and it avoids millions of temporary objects in large sequences.
- Parallel streams
We pick up 08-05, now with the full context.
Turning a stream parallel is trivial:
List<Book> result = catalog.parallelStream() // from the collection
.filter(b -> b.getPages() > 400)
.toList();
List<Book> result2 = catalog.stream()
.parallel() // from an existing stream
.filter(b -> b.getPages() > 400)
.toList();Underneath, the stream splits the data, processes the parts in several threads and combines the results. The work is done by the ForkJoinPool.commonPool() you met in 08-05.
graph TD
A["Source: 10000 elements"] --> B["Spliterator splits"]
B --> C1["partition 1"]
B --> C2["partition 2"]
B --> C3["partition 3"]
B --> C4["partition 4"]
C1 --> D1["thread 1: filter, map"]
C2 --> D2["thread 2: filter, map"]
C3 --> D3["thread 3: filter, map"]
C4 --> D4["thread 4: filter, map"]
D1 --> E["combine results"]
D2 --> E
D3 --> E
D4 --> E
E --> F["Final result"]
The commonPool and its consequences
System.out.println("Parallelism: " + ForkJoinPool.getCommonPoolParallelism());
System.out.println("Cores: " + Runtime.getRuntime().availableProcessors());The commonPool has by default cores − 1 threads (the calling thread takes part too). And this has a consequence you must keep firmly in mind:
Every parallel stream in the whole application shares the same pool. If one task blocks a commonPool thread with a network or I/O operation, it is blocking every other parallel stream in the application. That is why:
Never use
parallelStream()with blocking operations (I/O, network, waits). That is what the dedicatedExecutorServices of 08-05 and theCompletableFutureof 08-07 are for — or the virtual threads of 10-06.
You can run it on your own pool, although it is a workaround worth knowing about:
ForkJoinPool ownPool = new ForkJoinPool(4);
try {
List<Book> result = ownPool.submit(() ->
catalog.parallelStream().filter(...).toList()).get();
} finally {
ownPool.shutdown();
}When it helps and when it hurts
Parallelism is not free: splitting, coordinating threads and combining results has a fixed cost. It only pays off if the useful work is far greater than that cost.
| Factor | Favours parallel | Works against it |
|---|---|---|
| Size | Tens of thousands of elements or more | Hundreds or fewer |
| Cost per element | High (intensive computation) | Trivial (adding, comparing) |
| Source | ArrayList, arrays, IntStream.range (cheap splitting) |
LinkedList, Stream.iterate (expensive or impossible splitting) |
| Operations | Stateless: filter, map |
Stateful: sorted, distinct, limit |
| Terminal | reduce, sum, collect(toList) |
forEachOrdered, findFirst (they demand order) |
| Context | Desktop application, batch processing | Server with many concurrent requests |
That last row is often forgotten: on a server already serving 200 requests at once, every core is busy. Parallelising within each request adds nothing and adds coordination.
An honest measurement
package com.nexussoftware.bibliotech;
import java.util.*;
import java.util.stream.*;
public class ParallelMeasurement {
public static void main(String[] args) {
// WARNING: this is a HOMEMADE measurement. The numbers are
// indicative; to measure properly you need JMH (10-07).
List<Integer> small = IntStream.range(0, 1_000).boxed().toList();
List<Integer> large = IntStream.range(0, 10_000_000).boxed().toList();
warmUp(large);
measure("1,000 elements, trivial sum, sequential ",
() -> small.stream().mapToLong(Integer::longValue).sum());
measure("1,000 elements, trivial sum, PARALLEL ",
() -> small.parallelStream().mapToLong(Integer::longValue).sum());
measure("10M elements, trivial sum, sequential ",
() -> large.stream().mapToLong(Integer::longValue).sum());
measure("10M elements, trivial sum, PARALLEL ",
() -> large.parallelStream().mapToLong(Integer::longValue).sum());
measure("10M elements, heavy computation, sequential ",
() -> large.stream().mapToLong(ParallelMeasurement::costly).sum());
measure("10M elements, heavy computation, PARALLEL ",
() -> large.parallelStream().mapToLong(ParallelMeasurement::costly).sum());
// LinkedList: expensive splitting
List<Integer> linked = new LinkedList<>(IntStream.range(0, 1_000_000).boxed().toList());
measure("1M in a LinkedList, sequential ",
() -> linked.stream().mapToLong(Integer::longValue).sum());
measure("1M in a LinkedList, PARALLEL ",
() -> linked.parallelStream().mapToLong(Integer::longValue).sum());
}
private static long costly(int n) {
return (long) (Math.sqrt(n) * Math.log(n + 1) * Math.sin(n));
}
private static void warmUp(List<Integer> data) {
for (int i = 0; i < 5; i++) {
data.stream().mapToLong(Integer::longValue).sum();
data.parallelStream().mapToLong(Integer::longValue).sum();
}
}
private static void measure(String label, java.util.function.LongSupplier task) {
long start = System.nanoTime();
long result = task.getAsLong();
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.printf("%s -> %6d ms (result: %d)%n", label, ms, result);
}
}Indicative output on an 8-core machine:
1,000 elements, trivial sum, sequential -> 0 ms (result: 499500)
1,000 elements, trivial sum, PARALLEL -> 2 ms (result: 499500)
10M elements, trivial sum, sequential -> 58 ms (result: 49999995000000)
10M elements, trivial sum, PARALLEL -> 21 ms (result: 49999995000000)
10M elements, heavy computation, sequential -> 712 ms (result: -1583)
10M elements, heavy computation, PARALLEL -> 134 ms (result: -1583)
1M in a LinkedList, sequential -> 11 ms (result: 499999500000)
1M in a LinkedList, PARALLEL -> 34 ms (result: 499999500000)Four conclusions worth more than the table:
- With 1,000 elements, parallel is slower. The coordination cost exceeds the work.
- With 10 million and trivial work, it improves 2.8× on 8 cores. Not 8×: memory access and combining do not scale linearly.
- With costly work, it improves 5.3×. The more each element costs, the better the fixed cost is amortised.
- With
LinkedList, parallel is 3× SLOWER. Splitting a linked list requires traversing it; there is no index access. The data structure decides (module 5, and you will pick it up again in 10-07).
The golden rule: no shared state
// CATASTROPHIC: ArrayList is not thread-safe (08-06)
List<String> result = new ArrayList<>();
catalog.parallelStream().forEach(b -> result.add(b.getTitle()));This produces, unpredictably: lost elements, null in the middle of the list, ArrayIndexOutOfBoundsException or ConcurrentModificationException. And worst of all: it works correctly in tests with few elements and fails in production.
// CORRECT: collect handles the combining for you
List<String> result = catalog.parallelStream()
.map(Book::getTitle)
.toList();The rule, without exceptions: the lambdas of a parallel stream must not modify any shared state. Let collect or reduce build the result.
The checklist before writing parallelStream()
- Are there tens of thousands of elements or more?
- Is the cost per element appreciable?
- Is the source easy to split (
ArrayList, array, range)? - Are the operations stateless and non-blocking?
- Have you measured it and is it genuinely faster?
- Is the application not already saturating the cores?
If any answer is "no", use stream().
- The BiblioTech report: from thirty lines to three
Now, the demonstration that justifies the whole lesson. This is the report BiblioTechStatistics produced in module 5:
package com.nexussoftware.bibliotech.service;
import java.util.*;
/** OLD VERSION: the one from module 5. */
public class LegacyBiblioTechStatistics {
public Map<MaterialType, Long> countByType(List<Material> inventory) {
Map<MaterialType, Long> result = new HashMap<>();
for (Material m : inventory) {
Long current = result.get(m.getType());
result.put(m.getType(), current == null ? 1L : current + 1);
}
return result;
}
public Map<String, List<String>> titlesByEmployee(List<Loan> loans) {
Map<String, List<String>> result = new HashMap<>();
for (Loan l : loans) {
List<String> list = result.get(l.getEmployee());
if (list == null) {
list = new ArrayList<>();
result.put(l.getEmployee(), list);
}
list.add(l.getMaterial().getTitle());
}
for (List<String> list : result.values()) {
Collections.sort(list);
}
return result;
}
public String employeeWithMostLoans(List<Loan> loans) {
Map<String, Integer> count = new HashMap<>();
for (Loan l : loans) {
Integer current = count.get(l.getEmployee());
count.put(l.getEmployee(), current == null ? 1 : current + 1);
}
String best = null;
int maximum = -1;
for (Map.Entry<String, Integer> e : count.entrySet()) {
if (e.getValue() > maximum) {
maximum = e.getValue();
best = e.getKey();
}
}
return best; // null if the list was empty
}
public double averagePagesOnLoan(List<Material> inventory) {
int sum = 0;
int count = 0;
for (Material m : inventory) {
if (m.isOnLoan() && m instanceof Book) {
sum += ((Book) m).getPages();
count++;
}
}
return count == 0 ? 0.0 : (double) sum / count;
}
}And this is the same class with streams:
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.*;
import java.util.*;
import java.util.stream.Collectors;
/**
* STREAMS VERSION. Same behaviour, a third of the code,
* and every method reads like the sentence that describes it.
*/
public class BiblioTechStatistics {
/** How many materials there are of each type. */
public Map<MaterialType, Long> countByType(List<Material> inventory) {
return inventory.stream()
.collect(Collectors.groupingBy(Material::getType, Collectors.counting()));
}
/** Titles lent to each employee, sorted alphabetically. */
public Map<String, List<String>> titlesByEmployee(List<Loan> loans) {
return loans.stream()
.collect(Collectors.groupingBy(
Loan::getEmployee,
TreeMap::new,
Collectors.mapping(l -> l.getMaterial().getTitle(),
Collectors.collectingAndThen(
Collectors.toList(),
list -> { list.sort(null); return list; }))));
}
/** The employee with the most loans. Optional instead of null. */
public Optional<String> employeeWithMostLoans(List<Loan> loans) {
return loans.stream()
.collect(Collectors.groupingBy(Loan::getEmployee, Collectors.counting()))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey);
}
/** Average pages of the books on loan. */
public OptionalDouble averagePagesOnLoan(List<Material> inventory) {
return inventory.stream()
.filter(Material::isOnLoan)
.filter(Book.class::isInstance) // the instanceof of 10-03
.map(Book.class::cast)
.mapToInt(Book::getPages)
.average();
}
/** Full report by type: count, pages and average rating. */
public Map<MaterialType, TypeSummary> fullReport(List<Material> inventory) {
return inventory.stream()
.collect(Collectors.groupingBy(
Material::getType,
Collectors.collectingAndThen(
Collectors.toList(),
list -> new TypeSummary(
list.size(),
list.stream().mapToInt(Material::getPages).sum(),
list.stream().mapToDouble(Material::getRating)
.average().orElse(0.0)))));
}
public record TypeSummary(int count, int totalPages, double averageRating) { }
/** Loans by employee and type: the two-level report. */
public Map<String, Map<MaterialType, Long>> employeeTypeMatrix(List<Loan> loans) {
return loans.stream()
.collect(Collectors.groupingBy(
Loan::getEmployee,
TreeMap::new,
Collectors.groupingBy(l -> l.getMaterial().getType(),
() -> new EnumMap<>(MaterialType.class),
Collectors.counting())));
}
/** The N best rated materials. */
public List<Material> topRated(List<Material> inventory, int n) {
return inventory.stream()
.sorted(Comparator.comparingDouble(Material::getRating).reversed())
.limit(n)
.toList();
}
/** Every incident of every loan, grouped by severity. */
public Map<Severity, List<String>> incidentsBySeverity(List<Loan> loans) {
return loans.stream()
.flatMap(l -> l.getIncidents().stream())
.collect(Collectors.groupingBy(
Loan.Incident::severity,
() -> new EnumMap<>(Severity.class),
Collectors.mapping(Loan.Incident::description,
Collectors.toList())));
}
/** Summary line for the library dashboard. */
public String oneLineSummary(List<Material> inventory) {
return inventory.stream()
.collect(Collectors.groupingBy(Material::getType,
() -> new EnumMap<>(MaterialType.class),
Collectors.counting()))
.entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining(", ", "Inventory [", "]"));
}
}The program that runs it:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.domain.*;
import com.nexussoftware.bibliotech.service.BiblioTechStatistics;
import java.util.List;
public class BiblioTechReport {
public static void main(String[] args) {
List<Material> inventory = TestData.inventory();
List<Loan> loans = TestData.loans();
BiblioTechStatistics stats = new BiblioTechStatistics();
System.out.println("=== BIBLIOTECH REPORT ===");
System.out.println(stats.oneLineSummary(inventory));
System.out.println();
System.out.println("--- Materials by type ---");
stats.fullReport(inventory).forEach((type, r) ->
System.out.printf(" %-8s %2d units, %5d pages, rating %.2f%n",
type, r.count(), r.totalPages(), r.averageRating()));
System.out.println();
System.out.println("--- Loans by employee and type ---");
stats.employeeTypeMatrix(loans).forEach((employee, byType) ->
System.out.printf(" %-14s %s%n", employee, byType));
System.out.println();
System.out.println("--- Titles by employee ---");
stats.titlesByEmployee(loans).forEach((employee, titles) ->
System.out.printf(" %-14s %s%n", employee, titles));
System.out.println();
stats.employeeWithMostLoans(loans)
.ifPresentOrElse(e -> System.out.println("Top reader: " + e),
() -> System.out.println("There are no loans on record"));
System.out.printf("Average pages of what is on loan: %.1f%n",
stats.averagePagesOnLoan(inventory).orElse(0.0));
System.out.println();
System.out.println("--- Top 3 best rated ---");
stats.topRated(inventory, 3)
.forEach(m -> System.out.printf(" %.2f %s%n", m.getRating(), m.getTitle()));
}
}=== BIBLIOTECH REPORT ===
Inventory [BOOK=5, MAGAZINE=2, DVD=1]
--- Materials by type ---
BOOK 5 units, 2119 pages, rating 4.62
MAGAZINE 2 units, 140 pages, rating 3.90
DVD 1 units, 0 pages, rating 4.10
--- Loans by employee and type ---
Diego Alonso {BOOK=1, MAGAZINE=2}
Marta Ruiz {BOOK=3, DVD=1}
Nuria Vidal {BOOK=2}
--- Titles by employee ---
Diego Alonso [Java Magazine 04, Java Magazine 05, Refactoring]
Marta Ruiz [Clean Code, Design Patterns, Effective Java, Spring Course]
Nuria Vidal [Domain-Driven Design, Effective Java]
Top reader: Marta Ruiz
Average pages of what is on loan: 428.3
--- Top 3 best rated ---
4.85 Effective Java
4.72 Refactoring
4.60 Design PatternsThe comparison in numbers: 74 lines of loops with accumulators, manual maps and null checks, against 12 lines of expressions that read like their own statement. And the streams version does more: it returns Optional instead of null, it uses EnumMap for the enum keys and it sorts the keys with TreeMap.
Optional: what it was created for
Optional: what it was created forWe change subject, though not lesson: Optional arrived in the same release and out of the same philosophy.
Remember BiblioTech's findByReference from module 6:
public Loan findByReference(String reference) {
for (Loan l : loans) {
if (l.getReference().equals(reference)) {
return l;
}
}
return null; // "not found"
}The problem is not the null itself: it is that the signature lies. Loan findByReference(String) promises to return a Loan. The caller gets no hint that sometimes it will not:
Loan l = registry.findByReference("LN-2026-0041");
System.out.println(l.getEmployee()); // NullPointerException if it does not existAnd this mistake has an especially nasty property: the compiler cannot help you. There is no type that distinguishes "a Loan" from "a Loan or nothing". Tony Hoare, who introduced the null reference in ALGOL in 1965, publicly called it "my billion-dollar mistake" because of the amount of failures, vulnerabilities and debugging hours it has caused.
Optional<T> is the answer: a container that may hold a value or be empty, and whose type declares it explicitly.
public Optional<Loan> findByReference(String reference) {
for (Loan l : loans) {
if (l.getReference().equals(reference)) {
return Optional.of(l);
}
}
return Optional.empty();
}Now the signature tells the truth, and whoever uses it cannot ignore that:
Optional<Loan> l = registry.findByReference("LN-2026-0041");
// l.getEmployee(); // DOES NOT COMPILE: Optional has no getEmployee()
String employee = l.map(Loan::getEmployee).orElse("(no loan)");The compiler forces you to face the absence. That is the entire value of Optional, and it is enormous.
Notice as well the connection with 10-01: Optional<T> is the special case of Result<T> in which the failure carries no information. If you need to know why there is no value, use Result<T>; if it is enough to know that there is not one, Optional<T>.
- Creating and consuming an
Optional
OptionalCreation
// of: the value CANNOT be null. If it is, an immediate NullPointerException.
Optional<Book> a = Optional.of(book);
// ofNullable: it accepts null and produces an empty Optional
Optional<Book> b = Optional.ofNullable(mayBeNull);
// empty: explicitly empty
Optional<Book> c = Optional.empty();| Method | If the argument is null |
When to use it |
|---|---|---|
Optional.of(x) |
NullPointerException |
When you know there is a value. Failing fast is desirable |
Optional.ofNullable(x) |
Returns empty | When wrapping an old API that returns null |
Optional.empty() |
— | When there is no value |
Optional.of(null) is an error, not an empty Optional. It is a frequent mistake:
// WRONG: if the map does not have the key, this throws NullPointerException
Optional<Book> book = Optional.of(map.get(isbn));
// RIGHT
Optional<Book> book = Optional.ofNullable(map.get(isbn));Consuming: transform without unwrapping
The idiomatic way to use Optional is not to ask whether there is a value, but to chain operations that are applied only if there is one.
// map: transforms the value if it exists. If it is empty, it stays empty.
Optional<String> title = catalog.findByIsbn(isbn).map(Book::getTitle);
// Chaining
String uppercase = catalog.findByIsbn(isbn)
.map(Book::getTitle)
.map(String::toUpperCase)
.orElse("UNKNOWN");
// filter: empties the Optional if it does not match
Optional<Book> longAndAvailable = catalog.findByIsbn(isbn)
.filter(b -> b.getPages() > 400)
.filter(b -> !b.isOnLoan());flatMap: when the function already returns Optional.
public class Book {
public Optional<String> getPublisher() { ... } // it may not have one
}
// With map: you end up with nested Optionals
Optional<Optional<String>> wrong = catalog.findByIsbn(isbn).map(Book::getPublisher);
// With flatMap: flattened
Optional<String> right = catalog.findByIsbn(isbn).flatMap(Book::getPublisher);It is exactly the same distinction as in streams (section 7): map when the function returns a value, flatMap when it returns a container.
Consuming: getting the value
// orElse: default value
String title = opt.map(Book::getTitle).orElse("(unknown)");
// orElseGet: default value computed LAZILY (section 18)
Book book = opt.orElseGet(() -> loadFromNetwork(isbn));
// orElseThrow: throw if there is nothing
Book book2 = opt.orElseThrow(() ->
new BiblioTechException("There is no material " + isbn));
// orElseThrow() with no arguments (Java 10): throws NoSuchElementException
Book book3 = opt.orElseThrow();
// or: another Optional if this one is empty (Java 9)
Optional<Book> found = localCatalog.findByIsbn(isbn)
.or(() -> remoteCatalog.findByIsbn(isbn))
.or(() -> archiveCatalog.findByIsbn(isbn));That chained or is a very useful pattern: look in the first place, if not in the second, if not in the third — and the Suppliers are only evaluated if they are needed.
Consuming: running an action
// ifPresent: do something if there is a value
catalog.findByIsbn(isbn).ifPresent(b -> System.out.println("Found: " + b.getTitle()));
// ifPresentOrElse: and if not, something else (Java 9)
catalog.findByIsbn(isbn).ifPresentOrElse(
b -> System.out.println("Found: " + b.getTitle()),
() -> System.out.println("There is no material with ISBN " + isbn));Optional.stream(): the bridge to streams (Java 9)
It converts an Optional into a stream of zero or one element. Its star use is filtering out the empties from a collection of Optionals:
List<String> isbns = List.of("978-0000000001", "978-9999999999", "978-0000000003");
// BEFORE Java 9: two steps
List<Book> found1 = isbns.stream()
.map(catalog::findByIsbn) // Stream<Optional<Book>>
.filter(Optional::isPresent)
.map(Optional::get)
.toList();
// SINCE Java 9: one step
List<Book> found2 = isbns.stream()
.map(catalog::findByIsbn) // Stream<Optional<Book>>
.flatMap(Optional::stream) // Stream<Book>, without the empties
.toList();The second form is the idiomatic one and it never requires get().
orElse versus orElseGet
orElse versus orElseGetThey look the same and they are not, and the difference really matters.
T orElse(T other) // it takes an ALREADY COMPUTED value
T orElseGet(Supplier<? extends T> supplier) // it takes a FUNCTION that computes itorElse ALWAYS evaluates its argument, whether there is a value or not. orElseGet only evaluates it if the Optional is empty. Demonstration:
package com.nexussoftware.bibliotech;
import java.util.Optional;
public class OrElseVsOrElseGet {
private static Book loadFromNetwork(String isbn) {
System.out.println(" >>> QUERYING THE NETWORK for " + isbn + " (300 ms)");
return new Book(isbn, "Retrieved from the network");
}
public static void main(String[] args) {
Optional<Book> withValue = Optional.of(new Book("978-0000000001", "Effective Java"));
System.out.println("--- orElse with a value present ---");
Book a = withValue.orElse(loadFromNetwork("978-0000000001"));
System.out.println("Result: " + a.getTitle());
System.out.println();
System.out.println("--- orElseGet with a value present ---");
Book b = withValue.orElseGet(() -> loadFromNetwork("978-0000000001"));
System.out.println("Result: " + b.getTitle());
System.out.println();
System.out.println("--- orElseGet with an empty Optional ---");
Book c = Optional.<Book>empty().orElseGet(() -> loadFromNetwork("978-9999999999"));
System.out.println("Result: " + c.getTitle());
}
}--- orElse with a value present ---
>>> QUERYING THE NETWORK for 978-0000000001 (300 ms)
Result: Effective Java
--- orElseGet with a value present ---
Result: Effective Java
--- orElseGet with an empty Optional ---
>>> QUERYING THE NETWORK for 978-9999999999 (300 ms)
Result: Retrieved from the networkLook carefully at the first section. The Optional had a value, and the network was queried anyway — 300 milliseconds wasted and an unnecessary request to an external service. The value returned was the correct one, so the bug is invisible: it only shows up as inexplicable slowness.
And it can be worse than slow:
// A counter that is incremented even when the value exists
int nextId = opt.orElse(generateNewId()); // it always generates an ID!
// An empty Optional that produces NullPointerException
String s = opt.orElse(other.getTitle()); // if 'other' is null, it always blows up| Situation | Use |
|---|---|
| The default value is a constant or an already computed variable | orElse |
| The default value requires computation, I/O, network or a query | orElseGet |
| The default value has side effects | orElseGet |
| When in doubt | orElseGet |
// GOOD: a constant
String title = opt.map(Book::getTitle).orElse("(no title)");
// GOOD: expensive computation, evaluated only if needed
Book book = opt.orElseGet(() -> loadFromNetwork(isbn));
// GOOD: exception built only if needed
// (building an exception captures the stack trace, which is not free)
Book book2 = opt.orElseThrow(() -> new BiblioTechException("There is no " + isbn));That last point explains why orElseThrow takes a Supplier and not an already built exception: creating an exception fills in its stack trace, which is a measurable operation (10-07).
- The
Optional antipatterns
Optional antipatternsOptional is very frequently misused. These five are the antipatterns to recognise and avoid.
Antipattern 1: isPresent() + get()
// WRONG: it is an if in disguise, noisier than the original null
Optional<Book> opt = catalog.findByIsbn(isbn);
if (opt.isPresent()) {
System.out.println(opt.get().getTitle());
} else {
System.out.println("Not found");
}This adds nothing over checking for null: same lines, same structure, and on top of that you have added a wrapper object. The value of Optional lies in the functional API:
// RIGHT
catalog.findByIsbn(isbn).ifPresentOrElse(
b -> System.out.println(b.getTitle()),
() -> System.out.println("Not found"));
// Or if you need the value
String title = catalog.findByIsbn(isbn)
.map(Book::getTitle)
.orElse("Not found");Rule: if you write .get(), something is wrong. In fact, Optional.get() is flagged as a candidate for deprecation precisely because of how badly it is used; its alternative is orElseThrow(), which at least makes it clear that it can fail.
Antipattern 2: Optional as a method parameter
Three reasons why it is a mistake:
- The caller has to wrap:
search("java", Optional.of(BOOK))is noisier thansearch("java", BOOK). - An
Optionalcan benull. Nothing stopssearch("java", null), and then you have aNullPointerExceptionplus anOptional. The worst of both worlds. - There are better alternatives: method overloading.
// RIGHT
public List<Book> search(String text) {
return search(text, null);
}
public List<Book> search(String text, MaterialType type) { ... }Optional was designed for RETURN types. That is written explicitly in its documentation and in the JDK design notes.
Antipattern 3: Optional as an entity field
Optionalis not serialisable (07-05), so it breaks the entity's serialisation.- It adds an object per field and per instance: with a million books, a million extra objects.
- Persistence frameworks do not handle it well (Hibernate, 11-03).
// RIGHT: the field is null; the ACCESSOR returns Optional
public class Book {
private String publisher; // it can be null
public Optional<String> getPublisher() {
return Optional.ofNullable(publisher);
}
}That way the outside world never sees a null and the inside does not pay for the wrapper.
Antipattern 4: Optional of a collection
Now the caller has to check two absences: that the Optional holds a value and that the list is not empty. And there is no useful semantic difference between "there is no list" and "the list is empty".
// RIGHT: an empty list ALREADY means "there is nothing"
public List<Loan> loansOf(String employee) {
return loans.stream()
.filter(l -> l.getEmployee().equals(employee))
.toList(); // empty if there are none
}General rule: for collections, return the empty collection, never null or Optional. It is one of the oldest and most useful recommendations in Effective Java.
Antipattern 5: Optional.of(null) and returning null from an Optional
// WRONG: guaranteed NullPointerException
return Optional.of(map.get(key));
// WORSE: a method that returns Optional and sometimes returns null
public Optional<Book> find(String isbn) {
if (isbn == null) return null; // it destroys the whole point!
...
}A method that returns Optional must never return null. The caller will do .map(...) without checking, because that is what Optional is for, and will get a NullPointerException exactly where they were trusting there could not be one.
- What to return when there is no value
A decision table that sums up the criteria:
| Situation | Return | Why |
|---|---|---|
A search that may not find anything (findByIsbn) |
Optional<T> |
Absence is a normal, expected result |
| A collection that may be empty | An empty collection (List.of()) |
Empty already means "nothing"; never null or Optional |
| An array that may be empty | An empty array | Same as collections |
| A string that may be empty | "" |
Unless "empty" and "absent" mean different things |
| An operation that fails expectedly, with a reason | Result<T> (10-01) |
The error carries information |
| An operation that fails because of a programming error | An unchecked exception | IllegalArgumentException, IllegalStateException |
| An operation that fails for a recoverable external cause | A checked exception (06-01) | IOException, BiblioTechException |
| An internal field that may have no value | null in the field, Optional in the getter |
No memory or serialisation cost |
| An optional method parameter | Method overloading | Never Optional as a parameter |
| An average or aggregate of an empty stream | OptionalDouble / OptionalInt |
It is what the API returns |
- BiblioTech refactored
We close by applying everything at once. Repository<T> from 10-01 is modernised with streams and Optional:
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Identifiable;
import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.concurrent.ConcurrentHashMap;
/**
* Generic repository (10-01) with a streams and Optional API (10-04).
*/
public class Repository<T extends Identifiable> {
private final Map<String, T> byId = new ConcurrentHashMap<>();
private final String name;
public Repository(String name) {
this.name = Objects.requireNonNull(name, "name");
}
public Optional<T> save(T entity) {
Objects.requireNonNull(entity, "entity");
return Optional.ofNullable(byId.put(entity.getId(), entity));
}
/** The signature SAYS it may find nothing. */
public Optional<T> findById(String id) {
return Optional.ofNullable(byId.get(id));
}
public Optional<T> remove(String id) {
return Optional.ofNullable(byId.remove(id));
}
/**
* It exposes a stream instead of a copy of the list.
* The caller decides what to do without the repository
* having to offer a method for every possible query.
*/
public Stream<T> stream() {
return byId.values().stream();
}
/** Never null: an empty list already means "none". */
public List<T> find(Predicate<? super T> criterion) {
return stream().filter(criterion).toList();
}
/** The first one that matches, or empty. */
public Optional<T> findFirst(Predicate<? super T> criterion) {
return stream().filter(criterion).findFirst();
}
public List<T> listSorted(Comparator<? super T> order) {
return stream().sorted(order).toList();
}
public long count(Predicate<? super T> criterion) {
return stream().filter(criterion).count();
}
public boolean existsAny(Predicate<? super T> criterion) {
return stream().anyMatch(criterion);
}
/** Indexes by any derived key. */
public <K> Map<K, List<T>> groupBy(Function<? super T, ? extends K> classifier) {
return stream().collect(Collectors.groupingBy(classifier));
}
public int size() {
return byId.size();
}
@Override
public String toString() {
return "Repository[" + name + ", " + byId.size() + " entities]";
}
}And LoanManager stops checking for null:
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.annotations.Auditable;
import com.nexussoftware.bibliotech.domain.*;
import java.util.*;
import java.util.stream.Collectors;
public class LoanManager implements LoanService {
private final Repository<Material> catalog;
private final Repository<Loan> loans;
public LoanManager(Repository<Material> catalog, Repository<Loan> loans) {
this.catalog = catalog;
this.loans = loans;
}
/**
* BEFORE: findById returned null and it had to be checked.
* NOW: the absence is chained and produces the right failure.
*/
@Override
@Auditable(value = "lend", level = Severity.HIGH, logArguments = true)
public Result<Loan> lend(String isbn, String employee) {
return catalog.findById(isbn)
.map(material -> register(material, employee))
.orElseGet(() -> Result.failure("There is no material with ISBN " + isbn));
}
private Result<Loan> register(Material material, String employee) {
if (material.isOnLoan()) {
return Result.failure("The material '" + material.getTitle() + "' is already on loan");
}
material.markOnLoan(employee);
Loan loan = new Loan(nextReference(), material.getId(), employee);
loans.save(loan);
return Result.success(loan);
}
/** An empty list, never null. */
@Override
public List<Loan> loansOf(String employee) {
return loans.find(l -> l.getEmployee().equals(employee));
}
/** Optional instead of null. */
@Override
public Optional<Loan> findByReference(String reference) {
return loans.findById(reference);
}
/** The title of the material lent to an employee that is most overdue. */
public Optional<String> mostOverdueMaterialOf(String employee) {
return loans.stream()
.filter(l -> l.getEmployee().equals(employee))
.filter(Loan::isOverdue)
.max(Comparator.comparingInt(Loan::getDaysLate))
.flatMap(l -> catalog.findById(l.getIsbn())) // flatMap: it returns Optional
.map(Material::getTitle);
}
/** Total fine debt by employee, only for those who owe something. */
public Map<String, Double> debtByEmployee(FineCalculator calculator) {
return loans.stream()
.filter(Loan::isOverdue)
.collect(Collectors.groupingBy(
Loan::getEmployee,
TreeMap::new,
Collectors.summingDouble(l -> calculator.calculate(l.getDaysLate()))));
}
private String nextReference() {
return String.format("LN-2026-%04d", loans.size() + 1);
}
}Usage, without a single null check:
manager.lend("978-0000000001", "Marta Ruiz")
.map(Loan::getId)
.map(id -> "Loan registered: " + id)
.ifPresent(System.out::println);
manager.mostOverdueMaterialOf("Diego Alonso")
.ifPresentOrElse(
t -> System.out.println("Return urgently: " + t),
() -> System.out.println("Diego Alonso has nothing overdue"));
manager.debtByEmployee(calculator)
.forEach((employee, debt) -> System.out.printf(" %-14s %.2f EUR%n", employee, debt));Loan registered: LN-2026-0009
Return urgently: Refactoring
Diego Alonso 3.25 EUR
Marta Ruiz 1.50 EURCommon Mistakes and Tips
1. Reusing a stream. IllegalStateException: stream has already been operated upon or closed. A stream is single-use; create a new one from the source.
2. Forgetting the terminal operation. list.stream().filter(...) does absolutely nothing. If your stream produces neither a result nor an effect, check that it ends in collect, forEach, count, reduce or similar.
3. Modifying the source collection while traversing it. It produces ConcurrentModificationException, just like with a for-each (05-02). Collect into a new list.
4. Using forEach with an add to an external list. It is the imperative style in disguise, and in parallel it corrupts the list. Use collect.
5. Confusing map with flatMap. If your result is List<List<X>> or Optional<Optional<X>>, you wanted flatMap. And remember that the flatMap function must return a Stream, so you need the inner .stream().
6. Collectors.toMap with duplicate keys. IllegalStateException: Duplicate key. Pass the merge function as the third argument, or use groupingBy if what you really wanted was to group.
7. orElse with an expensive computation. It is evaluated always, whether the Optional has a value or not. Use orElseGet as soon as there is computation, I/O or side effects.
8. isPresent() + get(). It is an if with more noise. Use map, filter, ifPresent, ifPresentOrElse or orElse.
9. Optional as a parameter or as a field. It was designed for return types. As a parameter, use overloading; as a field, null inside and Optional in the getter.
10. parallelStream() without measuring. With few elements it is slower; with LinkedList it is much slower; with blocking operations it poisons the whole application's commonPool.
11. Not closing file streams. Files.lines() and Files.walk() keep resources open. Always in a try-with-resources.
12. Using peek for anything other than debugging. It is not guaranteed to run, and since Java 9 count() may skip it entirely.
13. sorted() on an infinite stream. It hangs: it needs every element. Put a limit before it.
Tip 1: one operation per line. Every .filter, .map and .collect on its own line, aligned. A seven-operation stream on a single line is unreadable.
Tip 2: extract complex predicates and functions. If the lambda takes more than two lines, give it a name:
Predicate<Book> isLongAndOnLoan = b -> b.getPages() > 400 && b.isOnLoan();
List<Book> result = catalog.stream().filter(isLongAndOnLoan).toList();Tip 3: prefer method references. map(Book::getTitle) reads better than map(b -> b.getTitle()).
Tip 4: do not force everything into streams. A loop with a complex break, with indices, or that modifies several variables at once, reads better as a loop. Streams shine at transformation and aggregation, not at intricate control flow.
Tip 5: put filter before map and sorted at the end. Fewer elements to transform and fewer elements to sort. The result is the same and the work is far less.
Tip 6: use primitive streams with numbers. mapToInt(...).sum() instead of map(...).reduce(0, Integer::sum): clearer and with no autoboxing.
Exercises
Exercise 1: the library dashboard
Write a class BiblioTechDashboard that generates, using streams exclusively, a text report with:
- Total materials and a breakdown by type.
- The 3 best rated materials, with their rating.
- Active loans grouped by employee, with the number and the sorted list of titles.
- The employee with the most loans (
Optional) and the one with the most accumulated days late. - Average, minimum and maximum pages of the books, in a single traversal.
- Materials never lent (the ones that appear in no loan).
- A text histogram with the number of loans per day of the week (use
loanDay % 7).
Requirements: not a single for loop, not a single null, and no .get() on an Optional.
Exercise 2: log analyser with lazy streams
BiblioTech produces a log with lines of the form DATE|LEVEL|OPERATION|EMPLOYEE|MILLISECONDS:
Write a LogAnalyser that, reading the file only once and without loading it into memory, produces:
- The number of operations per level.
- The 5 slowest operations, with their employee and duration.
- The average duration per operation type.
- Employees with at least one
SEVERE, sorted. - The percentage of operations above 200 ms.
- An
Optional<String>with the first corrupt line found (too few fields or non-numeric milliseconds), or empty if the file is sound.
Use a record LogLine and Optional for parsing each line. Generate a 50,000-line test file with streams.
Exercise 3: CardCache with Optional and parallelism measurement
Refactor BiblioTech's CardCache and measure parallelism honestly.
- Rewrite
CardCacheso thatfind(String isbn)returnsOptional<Card>and it has agetOrCompute(String, Function<String, Card>). - Add
List<Card> findAll(Collection<String> isbns)returning only the ones found, usingflatMap(Optional::stream). - Add
Map<Boolean, List<String>> classify(Collection<String> isbns)withpartitioningByto separate the ones in the cache from the ones that are not. - Write a benchmark comparing
stream()againstparallelStream()for an expensive card computation, with warm-up beforehand and several repetitions, over 100, 10,000 and 1,000,000 ISBNs, printing a table with the speedup in each case. - Demonstrate with code why accumulating into an
ArrayListwithforEachin parallel produces incorrect results.
Solutions
Solution 1
package com.nexussoftware.bibliotech.presentation;
import com.nexussoftware.bibliotech.domain.*;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* Library dashboard generated entirely with streams.
* Not one for, not one null, not one Optional.get().
*/
public class BiblioTechDashboard {
private final List<Material> inventory;
private final List<Loan> loans;
public BiblioTechDashboard(List<Material> inventory, List<Loan> loans) {
this.inventory = List.copyOf(inventory);
this.loans = List.copyOf(loans);
}
public String build() {
StringBuilder sb = new StringBuilder();
sb.append("=".repeat(58)).append('\n');
sb.append(" BIBLIOTECH DASHBOARD -- Nexus Software\n");
sb.append("=".repeat(58)).append('\n');
inventorySection(sb);
topSection(sb);
loansSection(sb);
rankingSection(sb);
pagesSection(sb);
neverLoanedSection(sb);
histogramSection(sb);
return sb.toString();
}
// --- 1. Inventory by type ---
private void inventorySection(StringBuilder sb) {
Map<MaterialType, Long> byType = inventory.stream()
.collect(Collectors.groupingBy(Material::getType,
() -> new EnumMap<>(MaterialType.class),
Collectors.counting()));
sb.append("\n1. INVENTORY (").append(inventory.size()).append(" materials)\n");
byType.forEach((type, n) ->
sb.append(String.format(" %-10s %3d %s%n", type, n, "#".repeat(n.intValue()))));
}
// --- 2. Top 3 best rated ---
private void topSection(StringBuilder sb) {
sb.append("\n2. BEST RATED\n");
inventory.stream()
.sorted(Comparator.comparingDouble(Material::getRating).reversed())
.limit(3)
.forEach(m -> sb.append(String.format(" %.2f %s%n",
m.getRating(), m.getTitle())));
}
// --- 3. Loans by employee ---
private void loansSection(StringBuilder sb) {
Map<String, List<String>> byEmployee = loans.stream()
.collect(Collectors.groupingBy(
Loan::getEmployee,
TreeMap::new,
Collectors.mapping(Loan::getMaterialTitle,
Collectors.collectingAndThen(
Collectors.toList(),
list -> list.stream().sorted().toList()))));
sb.append("\n3. ACTIVE LOANS\n");
byEmployee.forEach((employee, titles) -> {
sb.append(String.format(" %-14s (%d)%n", employee, titles.size()));
titles.forEach(t -> sb.append(" - ").append(t).append('\n'));
});
}
// --- 4. Rankings, with Optional ---
private void rankingSection(StringBuilder sb) {
sb.append("\n4. RANKINGS\n");
Optional<String> mostLoans = loans.stream()
.collect(Collectors.groupingBy(Loan::getEmployee, Collectors.counting()))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey);
sb.append(" Most loans: ")
.append(mostLoans.orElse("(there are no loans)")).append('\n');
Optional<String> mostDaysLate = loans.stream()
.filter(Loan::isOverdue)
.collect(Collectors.groupingBy(Loan::getEmployee,
Collectors.summingInt(Loan::getDaysLate)))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(e -> e.getKey() + " (" + e.getValue() + " days)");
sb.append(" Most days late: ")
.append(mostDaysLate.orElse("(nobody is overdue)")).append('\n');
}
// --- 5. Page statistics in ONE traversal ---
private void pagesSection(StringBuilder sb) {
IntSummaryStatistics stats = inventory.stream()
.filter(Book.class::isInstance)
.map(Book.class::cast)
.mapToInt(Book::getPages)
.summaryStatistics();
sb.append("\n5. PAGES OF THE BOOKS\n");
sb.append(String.format(" books=%d average=%.1f min=%d max=%d total=%d%n",
stats.getCount(), stats.getAverage(),
// With 0 books, getMin() returns MAX_VALUE: it must be handled
stats.getCount() == 0 ? 0 : stats.getMin(),
stats.getCount() == 0 ? 0 : stats.getMax(),
stats.getSum()));
}
// --- 6. Never lent ---
private void neverLoanedSection(StringBuilder sb) {
// Set of ISBNs ever lent: the lookup becomes O(1)
Set<String> everLoaned = loans.stream()
.map(Loan::getIsbn)
.collect(Collectors.toSet());
List<String> never = inventory.stream()
.filter(m -> !everLoaned.contains(m.getId()))
.map(Material::getTitle)
.sorted()
.toList();
sb.append("\n6. NEVER LENT (").append(never.size()).append(")\n");
sb.append(never.isEmpty()
? " (the whole catalogue has been out at least once)\n"
: never.stream().collect(Collectors.joining("\n ", " ", "\n")));
}
// --- 7. Histogram by day of the week ---
private void histogramSection(StringBuilder sb) {
String[] days = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };
Map<Integer, Long> byDay = loans.stream()
.collect(Collectors.groupingBy(l -> l.getLoanDay() % 7,
Collectors.counting()));
long max = byDay.values().stream().mapToLong(Long::longValue).max().orElse(1);
sb.append("\n7. LOANS PER DAY OF THE WEEK\n");
IntStream.range(0, 7).forEach(i -> {
long n = byDay.getOrDefault(i, 0L);
int bar = (int) (n * 30 / max);
sb.append(String.format(" %-10s %3d %s%n", days[i], n, "█".repeat(bar)));
});
}
}==========================================================
BIBLIOTECH DASHBOARD -- Nexus Software
==========================================================
1. INVENTORY (8 materials)
BOOK 5 #####
MAGAZINE 2 ##
DVD 1 #
2. BEST RATED
4.85 Effective Java
4.72 Refactoring
4.60 Design Patterns
3. ACTIVE LOANS
Diego Alonso (3)
- Java Magazine 04
- Java Magazine 05
- Refactoring
Marta Ruiz (4)
- Clean Code
- Design Patterns
- Effective Java
- Spring Course
Nuria Vidal (2)
- Domain-Driven Design
- Effective Java
4. RANKINGS
Most loans: Marta Ruiz
Most days late: Diego Alonso (23 days)
5. PAGES OF THE BOOKS
books=5 average=423.8 min=380 max=464 total=2119
6. NEVER LENT (1)
UML Distilled
7. LOANS PER DAY OF THE WEEK
Monday 3 ██████████████████████████████
Tuesday 1 ██████████
Wednesday 2 ████████████████████
Thursday 0
Friday 2 ████████████████████
Saturday 1 ██████████
Sunday 0Comments. Four points.
The set of lent ISBNs is a performance decision, not a stylistic one. Without it, section 6 would be filter(m -> loans.stream().noneMatch(l -> l.getIsbn().equals(m.getId()))), which is a stream nested inside another: O(n·m). With the Set, it is O(n+m). Streams do not exempt you from thinking about complexity (module 5).
IntSummaryStatistics with zero elements returns MAX_VALUE as the minimum and MIN_VALUE as the maximum, which is mathematically coherent (the minimum of the empty set is infinity) and visually absurd. It has to be handled.
getOrDefault(i, 0L) in the histogram avoids the null that get() would return for a day with no loans. It is the orElse equivalent for maps (05-05).
collectingAndThen is the piece that allows sorting within a group. It collects into a list and applies a final function. Without it, mapping(..., toList()) would give the lists in encounter order.
Solution 2
package com.nexussoftware.bibliotech.service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Analyses the BiblioTech log with lazy streams.
* It NEVER loads the file into memory: a 2 GB log is processed the same way.
*/
public class LogAnalyser {
/** A valid log line. Optional for parsing (04-07 + 10-04). */
public record LogLine(String date, String level, String operation,
String employee, long milliseconds) {
static Optional<LogLine> parse(String line) {
String[] c = line.split("\\|", -1);
if (c.length != 5) {
return Optional.empty();
}
try {
return Optional.of(new LogLine(
c[0].strip(), c[1].strip(), c[2].strip(), c[3].strip(),
Long.parseLong(c[4].strip())));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
}
public record Report(Map<String, Long> byLevel,
List<LogLine> slowest,
Map<String, Double> averageByOperation,
List<String> employeesWithErrors,
double slowPercentage,
long totalValid,
long totalCorrupt) { }
private final Path file;
public LogAnalyser(Path file) {
this.file = file;
}
/**
* A SINGLE traversal of the file: the valid lines are materialised
* in memory once and every aggregate is derived from there.
* For files that do not fit in memory, each aggregate would need
* its own lazy traversal.
*/
public Report analyse() throws IOException {
List<LogLine> valid;
long total;
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
valid = lines
.filter(l -> !l.isBlank())
.map(LogLine::parse)
.flatMap(Optional::stream) // drops the empties without get()
.toList();
}
total = countNonBlankLines();
Map<String, Long> byLevel = valid.stream()
.collect(Collectors.groupingBy(LogLine::level,
TreeMap::new, Collectors.counting()));
List<LogLine> slowest = valid.stream()
.sorted(Comparator.comparingLong(LogLine::milliseconds).reversed())
.limit(5)
.toList();
Map<String, Double> averageByOperation = valid.stream()
.collect(Collectors.groupingBy(LogLine::operation,
TreeMap::new,
Collectors.averagingLong(LogLine::milliseconds)));
List<String> employeesWithErrors = valid.stream()
.filter(l -> "SEVERE".equals(l.level()))
.map(LogLine::employee)
.distinct()
.sorted()
.toList();
long slow = valid.stream().filter(l -> l.milliseconds() > 200).count();
double percentage = valid.isEmpty() ? 0.0 : slow * 100.0 / valid.size();
return new Report(byLevel, slowest, averageByOperation, employeesWithErrors,
percentage, valid.size(), total - valid.size());
}
/** The first corrupt line, with LAZY processing: it stops when it finds it. */
public Optional<String> firstCorrupt() throws IOException {
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
return lines
.filter(l -> !l.isBlank())
.filter(l -> LogLine.parse(l).isEmpty())
.findFirst(); // SHORT-CIRCUITS: it does not read the rest of the file
}
}
private long countNonBlankLines() throws IOException {
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
return lines.filter(l -> !l.isBlank()).count();
}
}
// ------------------------------------------------------------------
public static void main(String[] args) throws IOException {
Path log = Path.of("bibliotech.log");
generateTestLog(log, 50_000);
LogAnalyser analyser = new LogAnalyser(log);
Report report = analyser.analyse();
System.out.println("=== LOG ANALYSIS ===");
System.out.printf("Valid lines: %d, corrupt: %d%n%n",
report.totalValid(), report.totalCorrupt());
System.out.println("Operations per level:");
report.byLevel().forEach((n, c) -> System.out.printf(" %-8s %6d%n", n, c));
System.out.println("\nThe 5 slowest:");
report.slowest().forEach(l -> System.out.printf(" %6d ms %-10s %s%n",
l.milliseconds(), l.operation(), l.employee()));
System.out.println("\nAverage duration per operation:");
report.averageByOperation().forEach((op, ms) ->
System.out.printf(" %-12s %7.1f ms%n", op, ms));
System.out.println("\nEmployees with severe errors: " + report.employeesWithErrors());
System.out.printf("Operations above 200 ms: %.2f %%%n",
report.slowPercentage());
analyser.firstCorrupt().ifPresentOrElse(
l -> System.out.println("\nFirst corrupt line: \"" + l + "\""),
() -> System.out.println("\nThere are no corrupt lines"));
}
/** Generates the test file, also with streams. */
private static void generateTestLog(Path target, int lines) throws IOException {
String[] levels = { "INFO", "INFO", "INFO", "WARNING", "SEVERE" };
String[] operations = { "lend", "return", "search", "export", "renew" };
String[] employees = { "Marta Ruiz", "Diego Alonso", "Nuria Vidal" };
Random random = new Random(42); // fixed seed: reproducible output
List<String> content = java.util.stream.IntStream.rangeClosed(1, lines)
.mapToObj(i -> {
if (i % 9_999 == 0) {
return "2026-08-05|CORRUPT LINE WITH NO FIELDS";
}
return String.format("2026-08-%02d|%s|%s|%s|%d",
1 + random.nextInt(28),
levels[random.nextInt(levels.length)],
operations[random.nextInt(operations.length)],
employees[random.nextInt(employees.length)],
1 + random.nextInt(500));
})
.toList();
Files.write(target, content, StandardCharsets.UTF_8);
}
}=== LOG ANALYSIS ===
Valid lines: 49995, corrupt: 5
Operations per level:
INFO 30012
SEVERE 9981
WARNING 10002
The 5 slowest:
500 ms search Nuria Vidal
500 ms lend Marta Ruiz
500 ms renew Diego Alonso
500 ms return Marta Ruiz
500 ms export Nuria Vidal
Average duration per operation:
export 250.4 ms
lend 250.7 ms
renew 250.3 ms
return 250.1 ms
search 250.9 ms
Employees with severe errors: [Diego Alonso, Marta Ruiz, Nuria Vidal]
Operations above 200 ms: 59.94 %
First corrupt line: "2026-08-05|CORRUPT LINE WITH NO FIELDS"Comments.
map(LogLine::parse).flatMap(Optional::stream) is the key idiom. Parsing returns Optional<LogLine>, and flatMap(Optional::stream) drops the empties and unwraps the full ones in a single step, with no isPresent(), no get() and no null.
firstCorrupt() genuinely short-circuits. findFirst() over a Files.lines() stops as soon as it finds the first match: in a two-gigabyte file with the corruption on line 12, twelve lines are read. With readAllLines() two gigabytes would be read.
The try-with-resources appears three times, one per opening of the file. And that is this design's conscious trade-off: the file is traversed three times (count, analyse, find corrupt) so as not to load it whole. If the file comfortably fitted in memory, a single initial toList() would be faster; if it did not fit, lazy reading is the only option. Choosing between the two is an engineering decision, not a stylistic one.
Solution 3
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Card;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Card cache with an API based on Optional and streams.
*/
public class CardCache {
private final Map<String, Card> cache = new ConcurrentHashMap<>();
private long hits = 0;
private long misses = 0;
/** Optional instead of null: the signature says it may not be there. */
public Optional<Card> find(String isbn) {
Optional<Card> result = Optional.ofNullable(cache.get(isbn));
if (result.isPresent()) { hits++; } else { misses++; }
return result;
}
/** Returns the cached one or computes and stores it. */
public Card getOrCompute(String isbn, Function<String, Card> computation) {
return cache.computeIfAbsent(isbn, key -> {
misses++;
return computation.apply(key);
});
}
public void save(Card card) {
cache.put(card.isbn(), card);
}
/** Only the ones found, with no isPresent() and no get(). */
public List<Card> findAll(Collection<String> isbns) {
return isbns.stream()
.map(cache::get)
.filter(Objects::nonNull)
.toList();
}
/** Equivalent version going through Optional, to see the idiom. */
public List<Card> findAllWithOptional(Collection<String> isbns) {
return isbns.stream()
.map(isbn -> Optional.ofNullable(cache.get(isbn)))
.flatMap(Optional::stream)
.toList();
}
/** true = in the cache, false = they have to be computed. ALWAYS both keys. */
public Map<Boolean, List<String>> classify(Collection<String> isbns) {
return isbns.stream()
.collect(Collectors.partitioningBy(cache::containsKey));
}
public int size() { return cache.size(); }
public double hitRate() {
long total = hits + misses;
return total == 0 ? 0.0 : (double) hits / total;
}
}The benchmark:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.domain.Card;
import java.util.*;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class ParallelBenchmark {
/** Expensive computation that simulates generating a card. */
private static Card computeCard(String isbn) {
double accumulated = 0;
for (int i = 1; i <= 2_000; i++) {
accumulated += Math.sqrt(i) * Math.log(i + isbn.hashCode() % 7 + 8);
}
return new Card(isbn, "Card " + isbn + " (" + (long) accumulated + ")", true);
}
public static void main(String[] args) {
System.out.println("Available cores: " + Runtime.getRuntime().availableProcessors());
System.out.println("commonPool parallelism: "
+ ForkJoinPool.getCommonPoolParallelism());
System.out.println();
System.out.printf("%-12s %14s %14s %10s%n",
"SIZE", "SEQUENTIAL", "PARALLEL", "SPEEDUP");
System.out.println("-".repeat(54));
for (int size : new int[] { 100, 10_000, 1_000_000 }) {
List<String> isbns = generateIsbns(size);
// WARM-UP: without it you measure the interpreter, not compiled code (10-07)
warmUp(isbns.subList(0, Math.min(500, size)));
long sequential = measureMedian(() ->
isbns.stream().map(ParallelBenchmark::computeCard).count());
long parallel = measureMedian(() ->
isbns.parallelStream().map(ParallelBenchmark::computeCard).count());
System.out.printf("%-12d %11d ms %11d ms %9.2fx%n",
size, sequential, parallel,
parallel == 0 ? 0.0 : (double) sequential / parallel);
}
System.out.println();
demonstrateParallelCorruption();
}
private static List<String> generateIsbns(int howMany) {
return IntStream.rangeClosed(1, howMany)
.mapToObj(n -> String.format("978-%010d", n))
.toList();
}
private static void warmUp(List<String> sample) {
for (int i = 0; i < 3; i++) {
sample.stream().map(ParallelBenchmark::computeCard).count();
sample.parallelStream().map(ParallelBenchmark::computeCard).count();
}
}
/** Median of 5 repetitions: less sensitive to a one-off spike than the mean. */
private static long measureMedian(Runnable task) {
long[] times = new long[5];
for (int i = 0; i < times.length; i++) {
long start = System.nanoTime();
task.run();
times[i] = (System.nanoTime() - start) / 1_000_000;
}
Arrays.sort(times);
return times[times.length / 2];
}
/** Why you NEVER accumulate into an unsafe collection from a parallel stream. */
private static void demonstrateParallelCorruption() {
List<String> isbns = generateIsbns(100_000);
System.out.println("--- Accumulating into an ArrayList from parallelStream ---");
for (int attempt = 1; attempt <= 3; attempt++) {
List<String> target = new ArrayList<>(); // NOT thread-safe
try {
isbns.parallelStream().forEach(target::add);
System.out.printf(" attempt %d: expected 100000, got %d%s%n",
attempt, target.size(),
target.size() == 100_000 ? "" : " <-- ELEMENTS LOST");
} catch (Exception e) {
System.out.printf(" attempt %d: %s%n", attempt, e.getClass().getSimpleName());
}
}
System.out.println("--- The correct way: collect ---");
List<String> correct = isbns.parallelStream().collect(Collectors.toList());
System.out.println(" collect: " + correct.size() + " elements, always");
}
}Indicative output (8 cores):
Available cores: 8
commonPool parallelism: 7
SIZE SEQUENTIAL PARALLEL SPEEDUP
------------------------------------------------------
100 3 ms 2 ms 1.50x
10000 287 ms 49 ms 5.86x
1000000 28640 ms 4712 ms 6.08x
--- Accumulating into an ArrayList from parallelStream ---
attempt 1: expected 100000, got 87423 <-- ELEMENTS LOST
attempt 2: ArrayIndexOutOfBoundsException
attempt 3: expected 100000, got 92108 <-- ELEMENTS LOST
--- The correct way: collect ---
collect: 100000 elements, alwaysComments. Four things this exercise demonstrates better than any explanation.
The warm-up changes the numbers. Without it, the first measurement includes bytecode interpretation before the JIT compiles the hot method, and it can be five or ten times slower. That is why the benchmark warms up before measuring and takes the median of five repetitions. Even so it is still a homemade measurement: in 10-07 you will see why only JMH gives results you can genuinely trust.
With 100 elements parallelism barely helps, and with a cheaper computation it would be counterproductive. With 10,000 and 1,000,000 the speedup settles around 6× on 8 cores: not 8×, because the splitting, the combining and shared memory do not scale linearly. Amdahl's law in action.
The ArrayList corruption is non-deterministic, and that is what makes it dangerous. Three attempts, three different results: two with lost elements and one with an exception. If this were in production, it would fail intermittently and irreproducibly — the worst kind of bug. And it would work perfectly in tests with ten elements.
collect works because it knows its contract. Each thread accumulates into its own container and then they are combined; there is no shared state between threads at any point. It is exactly the role of the combiner in the three-argument reduce of section 10.
Conclusion
It has arrived, and it was worth the wait.
You know what a stream is and —just as importantly— what it is not: it is not a collection, it does not store elements, it does not modify the source, it has no indices, it has nothing to do with InputStream, and it is single-use (IllegalStateException: stream has already been operated upon or closed). It is a description of a computation over a sequence.
You have mastered the pipeline: a source, zero or more intermediate operations that return Stream and do nothing, and exactly one terminal operation that triggers everything. And you have seen lazy evaluation with traces: each element goes through the whole pipeline before the next one starts, map does not run on what the filter discarded, and limit(2) short-circuits the traversal so that the fourth element is never processed. A stream with twenty operations is still a single traversal and zero intermediate lists.
You know the sources —stream(), Arrays.stream, Stream.of, iterate/generate with limit, IntStream.range, and Files.lines() and Files.walk() from 07-06, now with the whole API on top and always in a try-with-resources—. The intermediate operations: filter, map, mapToInt/mapToObj, distinct (which depends on equals/hashCode), sorted (stateful: never on an infinite stream and always as late as possible), peek (only for debugging, because it is not guaranteed to run), limit, skip, and takeWhile/dropWhile which stop instead of carrying on filtering. And flatMap, the difficult case: it turns each element into a stream and flattens them all — with the inner .stream() everybody forgets.
The terminal operations: forEach/forEachOrdered, Java 16's toList() which returns something unmodifiable, collect, count (which since Java 9 may skip the intermediates), min/max which return Optional, anyMatch/allMatch/noneMatch which short-circuit —with allMatch returning true vacuously on an empty stream—, and findFirst/findAny with their real difference in parallel. And reduce in its three forms: with no identity it returns Optional because there is no universal zero; with an identity it returns T and the identity must really be one, or in parallel it is applied in every partition and the result is absurd; with a combiner it changes type and explains why a Collector needs to combine.
Collectors in depth: toList/toSet/toMap —with its IllegalStateException: Duplicate key and the merge function that fixes it—, joining with a prefix and a suffix, counting, summingInt, averagingDouble, summarizingInt which gives five statistics in one traversal, mapping and collectingAndThen as downstreams, teeing for two collectors at once, groupingBy simple, with a downstream and two-level, and partitioningBy which always brings both keys. With that, the BiblioTech report that in module 5 was 74 lines of nested loops, manual maps and null checks is 12 lines that read like their own statement — and they do more: Optional instead of null, EnumMap for enum keys, TreeMap for sorted keys.
You know how to use primitive streams (IntStream, LongStream, DoubleStream) to avoid autoboxing, with boxed to come back, mapToObj to get out, and average() returning OptionalDouble because an empty stream has no average.
And on parallel streams you have the judgement, not just the syntax. You know they run on the ForkJoinPool.commonPool shared by the whole application, and that this is why they are never used with blocking operations. You know they pay off with tens of thousands of elements, an appreciable cost per element, a source that is easy to split (ArrayList, arrays, ranges — never LinkedList, which was 3× slower in parallel) and stateless operations. And you know the rule that admits no exceptions: no shared state in the lambdas, because accumulating into an ArrayList from a parallelStream loses elements non-deterministically, works in the tests and fails in production.
And you have Optional. You know what it was created for: because Loan findByReference(String) lies, and the compiler cannot help you remember the null — Tony Hoare's "billion-dollar mistake". With Optional<Loan>, the signature tells the truth and the compiler forces you to face the absence. You create it with of (which rejects null), ofNullable (which accepts it) or empty. You consume it without unwrapping it: map, flatMap, filter, ifPresent, ifPresentOrElse, a chained or to look in several places, stream() to drop the empties in one go. And you know the difference between orElse and orElseGet, demonstrated with traces: orElse evaluates its argument always, and an unnecessary 300 ms network query is an invisible bug that only shows up as inexplicable slowness.
You recognise the five antipatterns: isPresent() + get() (an if with more noise), Optional as a parameter (use overloading; and an Optional can be null too), Optional as a field (it is not serialisable and it multiplies objects: null inside, Optional in the getter), Optional of a collection (the empty collection already means "nothing") and Optional.of(null). And you have the table of what to return when there is no value: Optional for searches, an empty collection for collections, Result<T> when the failure carries information, an exception when it is a real error.
BiblioTech has changed on the inside. BiblioTechStatistics produces two-level reports in three lines. Repository<T> exposes a stream() and returns Optional from searches and empty lists from queries. LoanManager.lend chains findById().map().orElseGet() without a single if (x == null). CatalogImporter processes a two-gigabyte CSV without loading it into memory. LogAnalyser finds the first corrupt line by reading twelve lines out of two gigabytes. And the null that meant "not found" has disappeared from the code.
One debt remains, and it is one of the oldest in the course. Loan still has int loanDay and int dueDay. The exercise 1 histogram computes the day of the week with loanDay % 7, which is a hack that only works by accident. FineCalculator subtracts integers and calls that "days late". Nobody can answer "does this loan fall due in a month?" without arbitrarily deciding whether a month is 30 or 31 days. The CSV exports numbers that mean nothing outside BiblioTech. And the file timestamps, which in 07-06 you displayed as FileTime without being able to do anything with them, are still waiting.
In 10-05, Dates and Times with java.time, it ends. You will see why Date and Calendar were a disaster —mutable, with months starting at zero, thread-unsafe, with the shared SimpleDateFormat that is the classic Java application bug— and why java.time is the opposite: immutable, thread-safe, fluent and explicit about whether there is a time zone or not. You will learn when to use LocalDate, LocalDateTime, ZonedDateTime and Instant —and the golden rule of what to store in the database and what to show the user—, the difference between Duration and Period, the temporal adjusters for computing due dates, time zones with the two dangerous daylight-saving cases, formatting with DateTimeFormatter and Locale, and Clock as an injectable time source, without which it is impossible to test code that depends on the date.
And by the end, Loan will have LocalDate loanDate and LocalDate dueDate, fines will be computed with ChronoUnit.DAYS.between, notices will use TemporalAdjusters to land on a working day, and the CSV will carry ISO-8601 dates that any system in the world understands.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
