Module 1 · Lesson 01

Functional Programming in Java

Java has always been object-oriented. In Java 8, it gained a second paradigm: functional programming. Understanding why it was added — and what problem it solves — is the foundation for everything that follows.

The Problem with Anonymous Classes

Before Java 8, passing behavior around required anonymous inner classes. The intent was buried in ceremony:

SortingExample.java — before Java 8Java 7
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");

// The intent: sort by length.
// What you had to write:
Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
});

// With Java 8 lambdas — same intent, no ceremony:
names.sort((a, b) -> Integer.compare(a.length(), b.length()));

// Or even cleaner with a method reference:
names.sort(Comparator.comparingInt(String::length));

Two Programming Paradigms

🏛️
Object-Oriented (OOP)

Models the world as objects with state and behavior. Focuses on what things are. Classes, encapsulation, inheritance.

λ
Functional (FP)

Models programs as transformations of data. Focuses on what to do with data. Functions, immutability, composition.

Java doesn't force you to choose — you can (and should) use both. OOP for modeling your domain, FP for processing data collections. They complement each other perfectly.

Core Functional Principles in Java

1. Functions as Values

In FP, functions are first-class citizens — you can store them in variables, pass them to methods, and return them from methods. Java achieves this through lambdas and functional interfaces.

Functions as valuesJava
// Store a function in a variable
Predicate<String> isLong = s -> s.length() > 5;

// Pass it to a method
List<String> longNames = names.stream()
    .filter(isLong)    // function passed as argument
    .toList();

// Compose functions
Predicate<String> isShort = isLong.negate();
Predicate<String> startsWithA = s -> s.startsWith("A");
Predicate<String> shortAndA = isShort.and(startsWithA);

2. Immutability

Functional code avoids mutating state. Instead of modifying a list, you produce a new one. Streams embody this — operations on a stream never modify the source collection.

3. Pure Functions

A pure function's output depends only on its inputs — no side effects, no reading global state. Pure lambdas are easier to test, reason about, and parallelize.

💡
Why This Matters for Your Career

Stream pipelines appear in virtually every Java codebase. Interviewers frequently ask you to rewrite imperative loops as stream operations. This is not optional knowledge for a junior Java dev.

QuizCheck Your Understanding
1. What fundamental change did Java 8 introduce to how behavior can be passed around?
Java 8 added the ability to extend multiple classes simultaneously
Java 8 introduced lambdas, allowing functions to be passed as values without anonymous class boilerplate
Java 8 made all methods static by default to support functional-style calls
Java 8 removed the need for interfaces entirely
Before Java 8, passing behavior required anonymous inner classes — verbose and hard to read. Lambdas let you express the same intent in a single line. Under the hood, Java still uses functional interfaces; lambdas are syntactic sugar that the compiler converts.
2. What does "immutability" mean in the context of streams?
Stream objects cannot be assigned to variables
Stream operations are synchronized and thread-safe by default
Stream operations never modify the source collection — they produce new results
Once created, a stream cannot have any operations applied to it
Calling .stream().filter(...).map(...) on a List does not change the original List. Each operation returns a new stream (or a final result). The source data is untouched. This is a core guarantee of the Stream API.
Module 1 · Lesson 02

Lambda Expressions

A lambda is a concise way to represent an anonymous function — a block of code you can pass around like data. Mastering the syntax variants and understanding capture rules will make you fluent in modern Java.

Lambda Syntax

Lambdas have three parts: parameters, the arrow ->, and a body. The body can be a single expression or a block.

Lambda syntax formsJava
// Form 1: No parameters
Runnable r = () -> System.out.println("Running!");

// Form 2: Single parameter (parens optional)
Consumer<String> print = s -> System.out.println(s);

// Form 3: Multiple parameters
Comparator<Integer> cmp = (a, b) -> a - b;

// Form 4: Block body (multiple statements, explicit return)
Function<String, String> format = name -> {
    String trimmed = name.trim();
    return trimmed.substring(0, 1).toUpperCase() + trimmed.substring(1);
};

// Form 5: With explicit types (rarely needed — compiler infers)
BiFunction<Integer, Integer, Integer> add = (Integer x, Integer y) -> x + y;
ℹ️
Type Inference

The compiler infers parameter types from context. If the target type is Predicate<String>, the compiler knows the lambda takes a String. You almost never need to write the types explicitly.

Variable Capture

Lambdas can reference variables from the enclosing scope — but only if those variables are effectively final (never reassigned after initialization).

Variable capture rulesJava
int threshold = 5;  // effectively final — never reassigned

Predicate<String> longEnough = s -> s.length() > threshold;  // ✅ OK

threshold = 10;  // now reassigned — lambda above would fail to compile

// ❌ This does NOT compile:
int count = 0;
list.forEach(s -> count++);  // ERROR: count is not effectively final

// ✅ Use AtomicInteger for mutable counters in lambdas:
AtomicInteger count = new AtomicInteger(0);
list.forEach(s -> count.incrementAndGet());

this in Lambdas vs Anonymous Classes

this reference differenceJava
public class MyClass {
    private String name = "MyClass";

    void demo() {
        // Anonymous class: 'this' refers to the anonymous class instance
        Runnable anon = new Runnable() {
            public void run() {
                System.out.println(this);  // prints anonymous class
            }
        };

        // Lambda: 'this' refers to the ENCLOSING class (MyClass)
        Runnable lam = () -> System.out.println(this.name);  // "MyClass"
    }
}
QuizCheck Your Understanding
1. Which of these lambdas will NOT compile?
Runnable r = () -> System.out.println("ok");
Predicate<String> p = s -> s.isEmpty();
int x = 5; x++; Predicate<Integer> p = n -> n > x;
BiFunction<Integer,Integer,Integer> f = (a,b) -> a + b;
The third option won't compile because x is mutated (x++) after it's initialized, so it's not effectively final. A lambda can only capture variables that are never reassigned. The fix: don't increment x after the declaration, or use AtomicInteger.
2. A lambda that takes two Strings and returns a boolean — which functional interface fits?
Function<String, Boolean>
Predicate<String>
BiPredicate<String, String>
BiConsumer<String, String>
BiPredicate<T, U> takes two arguments and returns boolean. Predicate<T> takes one. Function<T,R> returns R (not necessarily boolean). BiConsumer takes two arguments but returns void. Match the shape of your lambda to the shape of the functional interface.
Module 1 · Lesson 03

Functional Interfaces

A functional interface is any interface with exactly one abstract method. It's the type system's way of representing a function. Java ships with a rich set of them in java.util.function — learn the core four and you'll handle 90% of cases.

The Core Four

InterfaceShapeUse forExample
Predicate<T>T → booleanTesting / filterings -> s.length() > 3
Function<T,R>T → RTransformings -> s.toUpperCase()
Consumer<T>T → voidSide effectss -> System.out.println(s)
Supplier<T>() → TProducing values() -> new ArrayList<>()
Core four in useJava
// Predicate — test a condition
Predicate<Integer> isEven = n -> n % 2 == 0;
isEven.test(4);   // true
isEven.test(7);   // false

// Function — transform a value
Function<String, Integer> strLen = String::length;
strLen.apply("hello");  // 5

// Consumer — do something, return nothing
Consumer<String> logger = msg -> System.out.println("[LOG] " + msg);
logger.accept("Started");  // prints [LOG] Started

// Supplier — produce a value from nothing
Supplier<List<String>> listFactory = ArrayList::new;
List<String> list = listFactory.get();  // fresh list each call

Composing Functions

Functional interfaces include default methods for composition — building complex transformations from simple pieces.

Function compositionJava
Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;

// andThen: trim first, THEN uppercase
Function<String, String> clean = trim.andThen(upper);
clean.apply("  hello  ");  // "HELLO"

// Predicate composition
Predicate<String> notEmpty = s -> !s.isEmpty();
Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> valid = notEmpty.and(notBlank);

// UnaryOperator — Function<T,T> shorthand
UnaryOperator<Integer> doubleIt = n -> n * 2;
UnaryOperator<Integer> addTen  = n -> n + 10;
UnaryOperator<Integer> doubleThenAdd = doubleIt.andThen(addTen);
doubleThenAdd.apply(5);  // (5*2)+10 = 20

Creating Your Own Functional Interface

Custom functional interfaceJava
@FunctionalInterface  // optional but documents intent; compiler enforces 1 abstract method
public interface TriFunction<A, B, C, R> {
    R apply(A a, B b, C c);
}

TriFunction<Integer, Integer, Integer, Integer> clamp =
    (val, min, max) -> Math.min(Math.max(val, min), max);

clamp.apply(150, 0, 100);  // 100
QuizCheck Your Understanding
1. You need to transform a List<String> to a List<Integer> (lengths). Which functional interface maps a String to an Integer?
Predicate<String>
Function<String, Integer>
Consumer<String>
Supplier<Integer>
Function<T, R> maps a value of type T to a value of type R. For String → Integer, you'd use Function<String, Integer> with String::length. In a stream: .map(String::length). Predicate returns boolean, Consumer returns void, Supplier takes no input.
2. What does @FunctionalInterface guarantee?
The interface will be automatically implemented by the compiler
The compiler will enforce that the interface has exactly one abstract method
The interface can only be used with lambda expressions, not anonymous classes
The interface methods will execute in parallel
@FunctionalInterface is a documentation annotation that also triggers a compile-time check. If you accidentally add a second abstract method, the compiler reports an error. It doesn't change runtime behavior — any single-abstract-method interface works as a lambda target whether annotated or not.
Module 1 · Lesson 04

Method References

Method references are shorthand for lambdas that do nothing but call an existing method. When a lambda is just x -> SomeClass.someMethod(x), you can write SomeClass::someMethod instead — cleaner signal, same behavior.

Four Kinds of Method References

KindSyntaxEquivalent Lambda
Static methodClassName::staticMethodx -> ClassName.staticMethod(x)
Instance (specific object)obj::instanceMethodx -> obj.instanceMethod(x)
Instance (arbitrary object)ClassName::instanceMethodx -> x.instanceMethod()
ConstructorClassName::newx -> new ClassName(x)
All four formsJava
// 1. Static method reference
Function<String, Integer> parseInt = Integer::parseInt;
parseInt.apply("42");  // 42

// 2. Bound instance method reference (specific object)
String prefix = "Hello, ";
Function<String, String> greet = prefix::concat;
greet.apply("World");  // "Hello, World"

// 3. Unbound instance method reference (applied to argument)
Function<String, String> toUpper = String::toUpperCase;
toUpper.apply("java");  // "JAVA"

// 4. Constructor reference
Function<String, StringBuilder> sbFactory = StringBuilder::new;
StringBuilder sb = sbFactory.apply("initial");

// In streams — this is where they really shine
List<String> names = List.of("alice", "bob", "charlie");
names.stream()
     .map(String::toUpperCase)        // unbound instance
     .forEach(System.out::println);    // bound to System.out
🔑
When to use method references vs lambdas

Use a method reference when the lambda body is only a method call with no extra logic. If you need to transform the argument, combine calls, or add a condition — write a lambda. Clarity wins over brevity.

🎯Challenge: Refactor to Method References

Rewrite each lambda below as a method reference where possible:

Refactor theseJava
list.stream().map(s -> s.trim()).collect(toList());
list.stream().filter(s -> !s.isEmpty()).collect(toList());
list.stream().forEach(s -> System.out.println(s));
list.stream().map(s -> new StringBuilder(s)).collect(toList());
list.stream().map(s -> Integer.parseInt(s)).collect(toList());
QuizCheck Your Understanding
1. What is the method reference equivalent of s -> s.toUpperCase()?
String::toUpperCase()
String::toUpperCase
s::toUpperCase
String.toUpperCase::apply
String::toUpperCase is an unbound instance method reference. The compiler knows it takes a String parameter (the object the method is called on) and returns a String. Note: no parentheses — method references never have () after the method name.
Module 2 · Lesson 05

What is a Stream?

A Stream is a sequence of elements supporting sequential and parallel aggregate operations. It's not a data structure — it doesn't store elements. It's a pipeline: data flows through it, gets transformed, and produces a result.

The Pipeline Model

Every stream pipeline has three parts: a source, zero or more intermediate operations, and a terminal operation.

// Stream pipeline anatomy
List.of(...)
Source
.filter()
Intermediate
.map()
Intermediate
.sorted()
Intermediate
.collect()
Terminal

Creating Streams

Stream sourcesJava
// From a Collection
Stream<String> s1 = names.stream();

// From values directly
Stream<String> s2 = Stream.of("a", "b", "c");

// From an array
Stream<String> s3 = Arrays.stream(namesArray);

// Infinite stream — generates values on demand
Stream<Integer> naturals = Stream.iterate(1, n -> n + 1);

// Range of integers (use IntStream for primitives — no boxing)
IntStream range = IntStream.rangeClosed(1, 10);  // 1 to 10 inclusive

// From a file
Stream<String> lines = Files.lines(Path.of("data.txt"));  // remember to close!

Laziness — The Key Insight

Intermediate operations are lazy: they don't execute until a terminal operation is called. This enables optimization — Java can short-circuit, fuse operations, and avoid processing elements that won't appear in the result.

Laziness demonstrationJava
Stream.of("Alice", "Bob", "Charlie", "Dave")
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.length() > 3;
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .findFirst();  // terminal operation

// Output: filter: Alice → map: Alice → (done — found it!)
// Bob/Charlie/Dave are never even processed
⚠️
Streams are single-use

Once a terminal operation is called, the stream is consumed and cannot be reused. Attempting to operate on a consumed stream throws IllegalStateException. If you need to process the same data twice, call .stream() again on the collection.

QuizCheck Your Understanding
1. What happens when you call .filter() on a stream without a terminal operation?
The filter runs immediately and stores the filtered elements
Nothing — intermediate operations are lazy and execute only when a terminal operation is called
A compile error occurs because filter requires collect immediately after
The filter runs but returns an empty stream until collect is called
Intermediate operations return a new Stream with a description of the operation to perform — no data is processed yet. Processing begins only when a terminal operation (collect, findFirst, forEach, count, etc.) is invoked. This laziness is what enables short-circuit optimizations like stopping after findFirst().
Module 2 · Lesson 06

Intermediate Operations

Intermediate operations transform a stream into another stream. They're the building blocks of your pipeline — chained together, they describe what to do with each element before the terminal operation collects the result.

The Essential Operations

OperationWhat it doesReturns
filter(Predicate)Keep elements matching the predicateStream<T>
map(Function)Transform each element T → RStream<R>
sorted()Natural order sortStream<T>
sorted(Comparator)Custom order sortStream<T>
distinct()Remove duplicates (uses equals)Stream<T>
limit(n)Keep first n elementsStream<T>
skip(n)Skip first n elementsStream<T>
peek(Consumer)Observe elements without modifying (debugging)Stream<T>
mapToInt/Long/DoubleConvert to primitive stream (avoids boxing)IntStream etc.
Intermediate operations in actionJava
List<String> words = List.of("stream", "lambda", "java", "stream", "api");

List<String> result = words.stream()
    .filter(w -> w.length() > 3)      // ["stream","lambda","java","stream"]
    .distinct()                          // ["stream","lambda","java"]
    .sorted()                            // ["java","lambda","stream"]
    .map(String::toUpperCase)            // ["JAVA","LAMBDA","STREAM"]
    .limit(2)                           // ["JAVA","LAMBDA"]
    .collect(Collectors.toList());

// peek() for debugging — inspect without changing the stream
List<Integer> lengths = words.stream()
    .filter(w -> w.length() > 3)
    .peek(w -> System.out.println("After filter: " + w))
    .map(String::length)
    .peek(n -> System.out.println("After map: " + n))
    .collect(Collectors.toList());

map vs mapToInt — Avoid Boxing

Using map(String::length) produces a Stream<Integer> — boxed integers. For numeric processing, use primitive streams to skip the boxing overhead:

Primitive streamsJava
// Stream<Integer> — boxes each int (slow for large data)
int total = words.stream()
    .map(String::length)        // Stream<Integer>
    .reduce(0, Integer::sum);

// IntStream — no boxing (fast)
int total = words.stream()
    .mapToInt(String::length)   // IntStream
    .sum();                       // IntStream has sum(), average(), min(), max() built in

// IntStream statistics
IntSummaryStatistics stats = words.stream()
    .mapToInt(String::length)
    .summaryStatistics();
stats.getAverage();  // 4.8
stats.getMax();      // 6
💡
Order matters for performance

Put filter() before map() when possible — it reduces the number of elements that need transforming. Put limit() early for infinite streams. sorted() and distinct() are stateful and require seeing all elements, so they're inherently expensive on large streams.

QuizCheck Your Understanding
1. What does peek() do to the stream elements?
It modifies elements in place, like a side-effect map
It observes each element (e.g., for logging) without changing the stream
It pauses execution until the next operation is ready
It copies each element and adds the copy to the stream
peek() takes a Consumer — it performs an action on each element (like printing it) but doesn't change the element or the stream. It's primarily a debugging tool to inspect values at different stages of the pipeline without breaking the chain.
2. Why prefer mapToInt() over map() for numeric computations?
mapToInt() supports more operations than map()
mapToInt() avoids boxing int to Integer, reducing memory and CPU overhead
mapToInt() runs operations in parallel automatically
mapToInt() is safer — it handles null automatically
map(String::length) creates a Stream<Integer> where each int is boxed (wrapped in an Integer object). mapToInt() creates an IntStream of raw primitives — no object allocation per element. For large collections this matters. IntStream also adds domain-specific methods: sum(), average(), summaryStatistics().
Module 2 · Lesson 07

Terminal Operations

Terminal operations trigger the pipeline. They consume the stream, produce a result (or a side effect), and leave the stream closed. Picking the right terminal operation is as important as building the right pipeline.

Reduction Operations

reduce() — the general caseJava
List<Integer> nums = List.of(1, 2, 3, 4, 5);

// reduce(identity, accumulator)
int sum = nums.stream().reduce(0, (acc, n) -> acc + n);  // 15
int product = nums.stream().reduce(1, (acc, n) -> acc * n);  // 120

// Without identity — returns Optional (stream might be empty)
Optional<Integer> max = nums.stream().reduce(Integer::max);

// Shorthand methods on IntStream
nums.stream().mapToInt(Integer::intValue).sum();
nums.stream().mapToInt(Integer::intValue).average();  // OptionalDouble
nums.stream().mapToInt(Integer::intValue).min();
nums.stream().mapToInt(Integer::intValue).max();

Finding & Matching

find and match operationsJava
List<String> names = List.of("Alice", "Bob", "Charlie");

// findFirst — returns first element matching pipeline (Optional)
Optional<String> first = names.stream()
    .filter(s -> s.startsWith("C"))
    .findFirst();  // Optional["Charlie"]

// anyMatch / allMatch / noneMatch — return boolean
boolean anyLong  = names.stream().anyMatch(s -> s.length() > 4);  // true
boolean allShort = names.stream().allMatch(s -> s.length() < 10); // true
boolean noneNum  = names.stream().noneMatch(s -> s.matches("\\d+"));  // true

// count — number of elements after pipeline
long longNames = names.stream().filter(s -> s.length() > 3).count();  // 2

forEach

forEach — side effects onlyJava
// Good: printing, logging, storing to external system
names.stream()
     .filter(s -> s.length() > 3)
     .forEach(System.out::println);

// ❌ Bad: modifying a collection inside forEach (side-effect anti-pattern)
List<String> result = new ArrayList<>();
names.stream()
     .filter(s -> s.length() > 3)
     .forEach(result::add);  // works but defeats the purpose — use collect instead

// ✅ Good: use collect for building collections
List<String> result = names.stream()
     .filter(s -> s.length() > 3)
     .collect(Collectors.toList());
QuizCheck Your Understanding
1. Why does reduce(accumulator) without an identity return Optional?
Optional makes reduce thread-safe in parallel streams
reduce always returns Optional regardless of parameters
The stream might be empty — with no identity value, there's no sensible default to return
Optional is returned to indicate that reduce is a lazy operation
If you call reduce(Integer::max) on an empty stream, what should it return? There's no "maximum of nothing." By returning Optional, Java forces you to handle the empty case explicitly. With an identity value like reduce(0, Integer::sum), the identity (0) is the default for empty streams, so no Optional is needed.
2. You want to check if ANY name in a list starts with "Z". Which is most efficient?
names.stream().filter(s -> s.startsWith("Z")).count() > 0
names.stream().anyMatch(s -> s.startsWith("Z"))
names.stream().filter(s -> s.startsWith("Z")).findFirst().isPresent()
All three are equivalent in performance
anyMatch() short-circuits — it stops as soon as it finds a matching element. count() must process the entire stream to count matches. findFirst() also short-circuits but is semantically "find one" not "does any match." Use anyMatch for boolean checks — it's the clearest expression of intent and the most efficient.
Module 2 · Lesson 08

Collectors in Depth

collect() is the most versatile terminal operation. The Collectors utility class provides ready-made collectors for lists, sets, maps, grouping, partitioning, joining, and more. This is where streams become truly powerful.

Basic Collectors

Collecting to standard collectionsJava
import static java.util.stream.Collectors.*;

List<String> names = List.of("Alice", "Bob", "Alice", "Charlie");

// toList() — ordered, allows duplicates
List<String> list = names.stream().collect(toList());

// toSet() — no duplicates, unordered
Set<String> set = names.stream().collect(toSet());  // {"Alice","Bob","Charlie"}

// joining() — concatenate strings
String csv = names.stream().collect(joining(", "));  // "Alice, Bob, Alice, Charlie"
String fmt = names.stream().collect(joining(", ", "[", "]"));  // "[Alice, Bob, Alice, Charlie]"

// counting() — count elements
long n = names.stream().collect(counting());  // 4

// Java 16+: toList() as terminal op directly (immutable)
List<String> immutable = names.stream().filter(s -> s.length() > 3).toList();

groupingBy — The Power Move

groupingBy — the most useful CollectorJava
List<String> words = List.of("cat", "car", "dog", "deer", "duck");

// Group by first letter → Map<Character, List<String>>
Map<Character, List<String>> byLetter = words.stream()
    .collect(groupingBy(s -> s.charAt(0)));
// {c=[cat,car], d=[dog,deer,duck]}

// Group and count instead of listing
Map<Character, Long> countByLetter = words.stream()
    .collect(groupingBy(s -> s.charAt(0), counting()));
// {c=2, d=3}

// Group employees by department, collect to names only
Map<String, List<String>> namesByDept = employees.stream()
    .collect(groupingBy(
        Employee::getDepartment,
        mapping(Employee::getName, toList())
    ));

partitioningBy & toMap

Partitioning and toMapJava
// partitioningBy — special groupingBy for boolean split
Map<Boolean, List<Integer>> evenOdd = nums.stream()
    .collect(partitioningBy(n -> n % 2 == 0));
// {true=[2,4], false=[1,3,5]}

// toMap — build a Map from stream elements
Map<String, Integer> wordLengths = words.stream()
    .collect(toMap(
        w -> w,             // key: the word itself
        String::length      // value: its length
    ));
// {cat=3, car=3, dog=3, deer=4, duck=4}

// toMap with merge function (handles duplicate keys)
Map<Integer, String> lengthToWords = words.stream()
    .collect(toMap(
        String::length,
        w -> w,
        (existing, newVal) -> existing + "," + newVal  // merge on collision
    ));
🎯Challenge: Employee Analytics

Given a List<Employee> where Employee has name, department, salary, and active fields, write stream pipelines to produce:

  • The average salary per department: Map<String, Double>
  • The highest-paid active employee's name
  • A comma-separated list of all active employees sorted by salary descending
  • Count of employees per department, sorted by count descending
QuizCheck Your Understanding
1. What's the difference between groupingBy and partitioningBy?
groupingBy is for strings; partitioningBy is for numbers
partitioningBy splits into exactly two groups (true/false); groupingBy can create any number of groups by any key
partitioningBy is faster because it creates fewer groups
groupingBy requires a Comparator; partitioningBy requires a Predicate
partitioningBy takes a Predicate and always produces Map<Boolean, List<T>> — exactly two buckets. groupingBy takes a Function and can produce any number of groups keyed by whatever the function returns. Use partitioningBy when the grouping criterion is naturally binary (passed/failed, active/inactive).
Module 3 · Lesson 09

flatMap & Optional Streams

flatMap is the operation that trips up most developers learning streams. Once it clicks, you'll reach for it constantly. It solves the "stream of streams" problem — flattening nested structures into a single stream.

map vs flatMap

// map: one element in → one element out
["a b", "c d"]
Source
.map(split)
map
[["a","b"], ["c","d"]]
Stream of arrays
// flatMap: one element in → zero or more out, merged into one stream
["a b", "c d"]
Source
.flatMap(split)
flatMap
["a","b","c","d"]
Flat stream
flatMap examplesJava
List<String> sentences = List.of("hello world", "java streams");

// map gives Stream<String[]> — nested, not useful
sentences.stream().map(s -> s.split(" "));   // Stream<String[]>

// flatMap flattens to Stream<String>
sentences.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))
    .distinct()
    .sorted()
    .forEach(System.out::println);
// hello, java, streams, world

// Real-world: orders with multiple items
List<Order> orders = getOrders();

List<Item> allItems = orders.stream()
    .flatMap(order -> order.getItems().stream())  // Order → Stream<Item>
    .collect(Collectors.toList());

// Get all unique tags across all blog posts
Set<String> allTags = posts.stream()
    .flatMap(post -> post.getTags().stream())
    .collect(Collectors.toSet());

Optional with Streams

Optional.stream() — Java 9+Java
// Optional.stream() converts Optional to 0 or 1 element stream
// Super useful with flatMap to filter out empty Optionals:

List<String> ids = List.of("1", "bad", "3", "4");

List<Integer> validIds = ids.stream()
    .map(MyUtils::tryParseInt)      // returns Optional<Integer>
    .flatMap(Optional::stream)       // empty Optionals disappear, present ones unwrap
    .collect(Collectors.toList());
// [1, 3, 4] — "bad" silently dropped

// Helper method
static Optional<Integer> tryParseInt(String s) {
    try { return Optional.of(Integer.parseInt(s)); }
    catch (NumberFormatException e) { return Optional.empty(); }
}
QuizCheck Your Understanding
1. You have List<List<Integer>> matrix. How do you get a flat List<Integer> of all values?
matrix.stream().map(List::stream).collect(toList())
matrix.stream().flatMap(List::stream).collect(toList())
matrix.stream().flatten().collect(toList())
matrix.stream().reduce(List::addAll).get()
flatMap(List::stream) converts each inner List into a Stream and merges all those streams into one. map(List::stream) would give you Stream<Stream<Integer>> — nested streams that are hard to work with. There's no flatten() method on Stream.
Module 3 · Lesson 10

Parallel Streams & Performance

Parallel streams split your data across CPU cores and process chunks simultaneously. It's one line to enable — but knowing when it helps and when it hurts is what separates knowledgeable devs from those who cargo-cult it.

Enabling Parallelism

Sequential vs parallelJava
List<Integer> bigList = /* 10 million integers */;

// Sequential — uses one thread
long count = bigList.stream()
    .filter(n -> n % 2 == 0)
    .count();

// Parallel — uses ForkJoinPool.commonPool() (all available CPU cores)
long count = bigList.parallelStream()
    .filter(n -> n % 2 == 0)
    .count();

// Or switch mid-pipeline
long count = bigList.stream()
    .parallel()
    .filter(n -> n % 2 == 0)
    .count();

When Parallel Helps — and When It Doesn't

Good candidates for parallel

Large datasets (100k+ elements), CPU-intensive operations, no ordering requirements, stateless operations, independent elements.

Bad candidates for parallel

Small datasets (thread overhead exceeds benefit), I/O-bound operations, operations that require ordering, shared mutable state.

Parallel stream pitfallsJava
// ❌ Race condition — shared mutable state
List<Integer> results = new ArrayList<>();  // NOT thread-safe
bigList.parallelStream().filter(n -> n > 0).forEach(results::add);  // data corruption!

// ✅ Use thread-safe collector instead
List<Integer> results = bigList.parallelStream()
    .filter(n -> n > 0)
    .collect(Collectors.toList());  // Collectors are designed for parallel use

// ❌ Order-sensitive operations with parallel (results non-deterministic)
bigList.parallelStream().findFirst();  // use findAny() with parallel — faster, no order guarantee
bigList.parallelStream().findAny();    // ✅ better for parallel
⚠️
Measure before parallelizing

Parallel streams have real overhead: thread coordination, data splitting, result merging. On a list of 100 elements, parallel is almost always slower than sequential. Always benchmark with realistic data sizes before assuming parallel helps.

Stream Performance Summary

Performance best practicesJava
// 1. Use primitive streams to avoid boxing
intList.stream().mapToInt(Integer::intValue).sum();  // faster than .reduce(0, Integer::sum)

// 2. Filter early to reduce pipeline elements
stream.filter(expensive).map(cheap);   // ✅
stream.map(cheap).filter(expensive);   // ❌ applies map to more elements

// 3. Short-circuit for early exit
stream.filter(p).findFirst();   // stops at first match
stream.filter(p).anyMatch(q);   // stops as soon as any matches

// 4. Avoid sorted() on large streams when possible
// sorted() requires seeing all elements (O(n log n)) — use only when needed

// 5. Don't use parallel on small collections or I/O-bound tasks
// Rule of thumb: parallel pays off at 10k+ elements with CPU work
🏆Final Challenge: Build a Word Frequency Analyzer

Given a large List<String> of sentences, write a stream pipeline that:

  • Splits all sentences into individual words (flatMap)
  • Normalizes to lowercase and trims whitespace
  • Filters out stop words (a, the, is, in, of, and...)
  • Produces a Map<String, Long> of word → frequency
  • Returns the top 10 most frequent words as a sorted List<Map.Entry<String,Long>>
  • Bonus: Rerun with parallelStream() on 1M sentences and compare timing
Final QuizCourse Completion Check
1. When does a parallel stream reliably outperform sequential?
Always — parallel is always faster because it uses more CPU cores
When the stream has any filter operations
Large datasets with CPU-intensive, stateless operations and no ordering requirements
Any time the collection has more than 10 elements
Parallel streams win when: the data is large enough to amortize thread overhead, the work per element is computationally expensive (not just a simple comparison), elements can be processed independently, and you don't need ordered results. I/O-bound work, small collections, and operations requiring shared state are all poor fits.
2. A colleague writes: list.parallelStream().forEach(results::add). What's the bug?
parallelStream() doesn't support forEach
ArrayList is not thread-safe — multiple threads adding concurrently causes data corruption or lost elements
results::add is a static method reference and can't be used with forEach
There is no bug — parallelStream automatically synchronizes collection writes
ArrayList's add() is not synchronized. With multiple threads calling it simultaneously, you get race conditions: elements get lost, the internal array gets corrupted, or you see stale data. Fix: use a thread-safe collector (.collect(Collectors.toList())) or a concurrent collection like CopyOnWriteArrayList.
3. What's the output of: Stream.of("a","b","c").map(String::toUpperCase).filter(s -> s.compareTo("B") >= 0).findFirst().orElse("none")?
"A"
"B"
"C"
"none"
The pipeline: map makes ["A","B","C"]. filter keeps elements >= "B" alphabetically: ["B","C"]. findFirst() returns Optional["B"]. orElse is not invoked since the Optional has a value. Answer: "B". This also demonstrates laziness: "A" is mapped then filtered out, "B" is mapped, passes filter, findFirst returns it — "C" is never even processed.
🎓
Course Complete — You Know Streams

You can now write functional Java that senior devs respect: lambdas, method references, the full Stream API, Collectors, flatMap, and parallel. Practice by refactoring any imperative loop you encounter into a stream pipeline. That muscle memory is what makes it stick.

Roadmap