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

  1. What Java 8 brought and what you have already seen
  2. What a stream is and what it is NOT
  3. The pipeline: source, intermediate operations, terminal operation
  4. Lazy evaluation: the real order of execution
  5. Stream sources
  6. Intermediate operations
  7. flatMap: the difficult case
  8. takeWhile and dropWhile
  9. Terminal operations
  10. reduce in its three forms
  11. The basic Collectors
  12. groupingBy and partitioningBy
  13. Primitive streams
  14. Parallel streams
  15. The BiblioTech report: from thirty lines to three
  16. Optional: what it was created for
  17. Creating and consuming an Optional
  18. orElse versus orElseGet
  19. The Optional antipatterns
  20. What to return when there is no value
  21. BiblioTech refactored
  22. Common Mistakes and Tips
  23. Exercises

  1. 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.

  1. 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.iterate generates elements endlessly, and limit bounds them.
  • Parallelisable by swapping stream() for parallelStream().

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 UP
Exception 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:

long howMany = catalog.stream().count();
List<Book> list = catalog.stream().toList();

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.

  1. 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);
(no output)

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.

  1. 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);
[LN-2026-0001, LN-2026-0002, LN-2026-0003, LN-2026-0004, LN-2026-0005]

Without laziness, Stream.iterate would hang the program. With it, only the five elements the limit asks for are generated.

  1. 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();
    }
}

  1. Intermediate operations

They all return a Stream and none of them executes anything on its own.

filter: keep the ones that match

Stream<Book> filter(Predicate<? super T> criterion)
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

<R> Stream<R> map(Function<? super T, ? extends R> transformation)

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

List<String> uniqueEmployees = loans.stream()
        .map(Loan::getEmployee)
        .distinct()
        .toList();

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:

  1. On an infinite stream it hangs. Stream.iterate(1, n -> n+1).sorted() never finishes.
  2. Put it as late as possible. filter(...).sorted() sorts fewer elements than sorted().filter(...), and the result is the same.

peek: look without touching

Stream<T> peek(Consumer<? super T> action)

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.

  1. flatMap: the difficult case

flatMap is the operation people struggle with most, and it deserves its own section.

<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> transformation)

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 loan

The 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 Stream

Now 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.

  1. takeWhile and dropWhile

Added 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 everything

They 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.

  1. Terminal operations

They trigger the processing and return a result (or nothing).

forEach and forEachOrdered

catalog.stream()
        .filter(Book::isOnLoan)
        .forEach(b -> System.out.println(b.getTitle()));

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 order

Tip: 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

long onLoan = catalog.stream().filter(Book::isOnLoan).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 it

With 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));
checking: Effective Java
true

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));   // true

allMatch 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.

  1. reduce in its three forms

reduce 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<T> reduce(BinaryOperator<T> accumulator)
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

T reduce(T identity, BinaryOperator<T> accumulator)
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 partition

Form 3: identity + accumulator + combiner → changes type

<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner)

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 ints
graph 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.

  1. The basic Collectors

collect 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());
Books:   4
Total:   1719
Average: 429.75
Minimum: 395
Maximum: 464

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.

  1. groupingBy and partitioningBy

Here 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())));
{Marta Ruiz={BOOK=3, DVD=1},
 Diego Alonso={BOOK=1, MAGAZINE=2},
 Nuria Vidal={BOOK=2}}

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.

  1. 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.

  1. 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());
Parallelism: 7
Cores:       8

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 dedicated ExecutorServices of 08-05 and the CompletableFuture of 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:

  1. With 1,000 elements, parallel is slower. The coordination cost exceeds the work.
  2. With 10 million and trivial work, it improves 2.8× on 8 cores. Not 8×: memory access and combining do not scale linearly.
  3. With costly work, it improves 5.3×. The more each element costs, the better the fixed cost is amortised.
  4. 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()

  1. Are there tens of thousands of elements or more?
  2. Is the cost per element appreciable?
  3. Is the source easy to split (ArrayList, array, range)?
  4. Are the operations stateless and non-blocking?
  5. Have you measured it and is it genuinely faster?
  6. Is the application not already saturating the cores?

If any answer is "no", use stream().

  1. 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 Patterns

The 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.

  1. Optional: what it was created for

We 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 exist

And 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>.

  1. Creating and consuming an Optional

Creation

// 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().

  1. orElse versus orElseGet

They 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 it

orElse 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 network

Look 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).

  1. The Optional antipatterns

Optional 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

// WRONG
public List<Book> search(String text, Optional<MaterialType> type) { ... }

Three reasons why it is a mistake:

  1. The caller has to wrap: search("java", Optional.of(BOOK)) is noisier than search("java", BOOK).
  2. An Optional can be null. Nothing stops search("java", null), and then you have a NullPointerException plus an Optional. The worst of both worlds.
  3. 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

// WRONG
public class Book {
    private Optional<String> publisher;      // NO
}
  • Optional is 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

// WRONG
public Optional<List<Loan>> loansOf(String employee) { ... }

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.

  1. 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

  1. 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 EUR

Common 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:

  1. Total materials and a breakdown by type.
  2. The 3 best rated materials, with their rating.
  3. Active loans grouped by employee, with the number and the sorted list of titles.
  4. The employee with the most loans (Optional) and the one with the most accumulated days late.
  5. Average, minimum and maximum pages of the books, in a single traversal.
  6. Materials never lent (the ones that appear in no loan).
  7. 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:

2026-08-05|INFO|lend|Marta Ruiz|12
2026-08-05|SEVERE|return|Diego Alonso|340

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.

  1. Rewrite CardCache so that find(String isbn) returns Optional<Card> and it has a getOrCompute(String, Function<String, Card>).
  2. Add List<Card> findAll(Collection<String> isbns) returning only the ones found, using flatMap(Optional::stream).
  3. Add Map<Boolean, List<String>> classify(Collection<String> isbns) with partitioningBy to separate the ones in the cache from the ones that are not.
  4. Write a benchmark comparing stream() against parallelStream() 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.
  5. Demonstrate with code why accumulating into an ArrayList with forEach in 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       0

Comments. 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, always

Comments. 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

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved