Modern Java (8 → 21) — Complete Notes

Lambdas, functional interfaces, method references and Streams; then records, sealed types, pattern matching and text blocks. This is the difference between code that looks current and code that looks like 2012.

00. What changed, and why

Java 8 (2014) was the biggest change in the language's history: it added functions as values. Java 9–21 then spent a decade removing ceremony — less boilerplate, more of the compiler doing the boring work for you.

Two separate revolutions are bundled into "modern Java", and it helps to keep them apart:

Era Big idea Features
Java 8 Behaviour can be passed around as data Lambdas, functional interfaces, method refs, Streams, Optional
Java 9–21 Say what you mean, in fewer words var, record, sealed, pattern matching, text blocks, switch expressions
Which versions matter

LTS (Long-Term Support) releases are what companies actually run: 8, 11, 17, 21. Everything in between ships features that later land in an LTS. If someone says "we're on 17", they have records, sealed types and text blocks — but not virtual threads or pattern matching for switch.

The same job, 2012 vs today
// Java 7 — 9 lines of scaffolding to say "sort by name"
Collections.sort(people, new Comparator<Person>() {
    @Override
    public int compare(Person a, Person b) {
        return a.getName().compareTo(b.getName());
    }
});

// Java 8+ — the same instruction, and nothing else
people.sort(Comparator.comparing(Person::getName));

01. Lambdas

A lambda is a function you can store in a variable and pass to a method. It is Java's answer to "I want to give you behaviour, not an object."

The syntax, from longest to shortest
(String a, String b) -> { return a.length() - b.length(); }   // full: types + braces + return
(a, b) -> { return a.length() - b.length(); }                 // types inferred
(a, b) -> a.length() - b.length()                             // single expression: braces+return implicit
x -> x * 2                                                    // ONE param: parens optional
() -> System.out.println("hi")                                // ZERO params: parens required
() -> { }                                                     // do nothing
What a lambda really is

A lambda is not a new kind of thing in the type system. It is an instance of a functional interface — an interface with exactly one abstract method. x -> x * 2 has no type by itself; it only means something once the compiler knows which interface you're assigning it to. That's why it's called a target type.

The same lambda text, two different types
interface Doubler   { int apply(int x); }
interface Describer { String apply(int x); }

Doubler d = x -> x * 2;          // here the lambda IS a Doubler
Function<Integer,Integer> f = x -> x * 2;   // here the identical text IS a Function

// var v = x -> x * 2;           // COMPILE ERROR — no target type, compiler can't tell what it is

Capturing — lambdas and effectively final

A lambda can use variables from around it. But local variables it captures must be effectively final — assigned once and never changed.

What you may and may not capture
int factor = 2;
Function<Integer,Integer> f = x -> x * factor;   // OK — factor is effectively final
// factor = 3;                                    // COMPILE ERROR: adding this line breaks the lambda above

int count = 0;
list.forEach(x -> count++);      // COMPILE ERROR — can't mutate a captured local

// Fields are fine — only LOCALS have the restriction
class Counter {
    int count = 0;
    void run(List<Integer> list) { list.forEach(x -> count++); }   // OK (but not thread-safe!)
}
Why the restriction exists

Local variables live on the stack, which dies when the method returns — but the lambda may outlive it (stored in a field, handed to another thread). So Java copies the value into the lambda. If the original could still change, you'd have two values silently disagreeing. Forcing "effectively final" makes the copy safe and unambiguous.

Lambdas are not anonymous classes

Inside a lambda, this refers to the enclosing instance. Inside an anonymous inner class, this refers to the anonymous object itself. Lambdas also don't create a new scope for names — you can't shadow a local variable in a lambda parameter. And lambdas are compiled with invokedynamic, not into a Foo$1.class file.

02. Functional interfaces

A functional interface has exactly one abstract method (a "SAM" — Single Abstract Method). That's the only requirement, and it's what makes a lambda possible.

Declaring one
@FunctionalInterface              // optional, but ALWAYS use it
interface Validator {
    boolean validate(String s);   // exactly one abstract method

    // These do NOT count against the limit:
    default Validator negate() { return s -> !validate(s); }   // default methods are fine
    static Validator alwaysTrue() { return s -> true; }        // static methods are fine
}

Validator notEmpty = s -> !s.isEmpty();
System.out.println(notEmpty.validate("hi"));   // true
Why @FunctionalInterface is worth typing

It's not required — the compiler infers "functional" from the shape. But the annotation makes it a compile error for anyone to add a second abstract method later. Without it, someone adds a method, and the breakage shows up as a confusing error at every lambda call site instead of at the interface.

The Object methods don't count

An interface can redeclare equals, hashCode or toString as abstract and still be functional — every object already inherits them from Object, so they don't need implementing. This is exactly why Comparator<T> is functional despite declaring both compare and equals.

The built-in zoo — you rarely need your own

java.util.function has ~43 interfaces, but they're all variations on six. Learn these and you can read any modern Java API.

Interface Method Takes → Returns Use it for
Supplier<T> get() nothing → T Lazily produce a value
Consumer<T> accept(T) T → nothing Do something with it (side effect)
Function<T,R> apply(T) TR Transform
Predicate<T> test(T) Tboolean Ask a yes/no question
UnaryOperator<T> apply(T) TT Transform to the same type
BinaryOperator<T> apply(T,T) T,TT Combine two into one
All six in action
Supplier<String>          now      = () -> LocalDate.now().toString();
Consumer<String>          print    = s -> System.out.println(s);
Function<String,Integer>  length   = s -> s.length();
Predicate<String>         isEmpty  = s -> s.isEmpty();
UnaryOperator<String>     shout    = s -> s.toUpperCase();
BinaryOperator<Integer>   sum      = (a, b) -> a + b;

print.accept(now.get());              // 2026-07-16
System.out.println(length.apply("hello"));   // 5
System.out.println(sum.apply(2, 3));         // 5

The variations

Pattern Means Example
Bi* Takes two arguments BiFunction<T,U,R>, BiConsumer<T,U>
Int/Long/Double* Primitive version — no boxing IntPredicate, IntFunction<R>
ToInt/ToLong/ToDouble* Returns a primitive ToIntFunction<T>
Why the primitive variants exist

Function<Integer,Integer> boxes every single value into an Integer object — allocation and pointer-chasing on every call. IntUnaryOperator works on raw int. In a hot loop over millions of elements this is a large, measurable difference. It's the same reason IntStream exists.

Composing them — the default methods are the real payoff
Predicate<String> notNull  = s -> s != null;
Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> valid    = notNull.and(notBlank);       // and / or / negate
System.out.println(valid.test("  "));    // false

Function<Integer,Integer> times2 = x -> x * 2;
Function<Integer,Integer> plus3  = x -> x + 3;
System.out.println(times2.andThen(plus3).apply(5));   // 13  -> (5*2)+3   [times2 FIRST]
System.out.println(times2.compose(plus3).apply(5));   // 16  -> (5+3)*2   [plus3 FIRST]

Consumer<String> logIt = s -> log.info(s);
Consumer<String> both  = logIt.andThen(System.out::println);   // run both, in order

03. Method references

A method reference is shorthand for a lambda that does nothing but call one existing method. If your lambda is just "call this thing", :: says it with less noise.

The same thing, twice
list.forEach(s -> System.out.println(s));   // lambda
list.forEach(System.out::println);          // method reference — identical behaviour

The four kinds

Kind Syntax Equivalent lambda
1. Static method Integer::parseInt s -> Integer.parseInt(s)
2. Instance method of a specific object System.out::println s -> System.out.println(s)
3. Instance method of an arbitrary object String::toUpperCase s -> s.toUpperCase()
4. Constructor ArrayList::new () -> new ArrayList<>()
Kind 2 vs kind 3 — the only confusing part

Both look like Thing::method. The difference is what's on the left. If it's an object (System.out), that object receives the call and the lambda argument becomes the parameter. If it's a class (String), the lambda's first argument becomes the receiver — the thing the method is called on.

Bound vs unbound, side by side
// KIND 2 (bound) — the receiver is fixed: always System.out
Consumer<String> c = System.out::println;
c.accept("hi");             // System.out.println("hi")   — arg is the PARAMETER

// KIND 3 (unbound) — the receiver is whatever gets passed in
Function<String,String> f = String::toUpperCase;
f.apply("hi");              // "hi".toUpperCase()         — arg is the RECEIVER

// Kind 3 with extra args: first arg is the receiver, the rest are parameters
BiFunction<String,String,Boolean> starts = String::startsWith;
starts.apply("hello", "he");   // "hello".startsWith("he")  -> true
Constructor refs are how you plug factories into APIs
Supplier<List<String>>   maker  = ArrayList::new;      // () -> new ArrayList<>()
Function<String,Integer>  parser = Integer::new;        // x -> new Integer(x)
IntFunction<String[]>     array  = String[]::new;       // n -> new String[n]

// The classic use — telling collect() what container to build
List<String> names = stream.collect(Collectors.toCollection(ArrayList::new));
String[] arr = stream.toArray(String[]::new);

04. Streams — the mental model

A Stream is not a collection. It is a pipeline — a recipe for processing elements, which does nothing at all until you ask for a result.

Loop vs stream — the shift from "how" to "what"
// Imperative — you manage the loop, the temp list, the conditions
List<String> result = new ArrayList<>();
for (Person p : people) {
    if (p.getAge() >= 18) {
        result.add(p.getName().toUpperCase());
    }
}
result.sort(Comparator.naturalOrder());

// Declarative — you describe the outcome
List<String> result = people.stream()
    .filter(p -> p.getAge() >= 18)
    .map(p -> p.getName().toUpperCase())
    .sorted()
    .toList();

The three parts of every pipeline

Source → intermediate ops → terminal op
people.stream()                         // 1. SOURCE       — creates the stream
      .filter(p -> p.getAge() >= 18)    // 2. INTERMEDIATE — lazy, returns a Stream
      .map(Person::getName)             // 2. INTERMEDIATE — lazy, returns a Stream
      .toList();                        // 3. TERMINAL     — triggers everything, returns a result
Laziness is the whole design

Intermediate operations build up a plan and return immediately — they don't touch a single element. Nothing runs until a terminal operation asks for a value. This lets Java fuse the whole pipeline into one pass and stop early when it can.

Proof: no terminal op = no work at all
Stream<String> s = list.stream().map(x -> {
    System.out.println("mapping " + x);
    return x.toUpperCase();
});
System.out.println("nothing printed yet!");   // ...and indeed nothing was.
// The map() lambda has never run. Add .toList() and it all fires.
Element-at-a-time, not stage-at-a-time — this surprises everyone
Stream.of("a", "b", "c")
    .filter(s -> { System.out.println("filter " + s); return true; })
    .map(s   -> { System.out.println("map " + s);    return s; })
    .toList();

// Output — each element goes ALL the way through before the next one starts:
// filter a
// map a
// filter b
// map b
// filter c
// map c
// NOT: filter a, filter b, filter c, map a, map b, map c
Short-circuiting — laziness saves real work
// An INFINITE stream, and yet this terminates instantly:
Stream.iterate(1, x -> x + 1)     // 1, 2, 3, 4, ... forever
      .map(x -> x * x)
      .filter(x -> x % 2 == 1)
      .limit(3)                   // stop as soon as we have 3
      .forEach(System.out::println);   // 1  9  25

// Only ~5 elements were ever generated. An eager implementation would hang forever.
A stream is single-use

Once a terminal operation runs, the stream is consumed. Touching it again throws IllegalStateException: stream has already been operated upon or closed. If you need two results, either collect once and reuse the collection, or build the stream twice from the source.

The single-use trap
Stream<String> s = list.stream();
s.forEach(System.out::println);   // fine
long n = s.count();               // IllegalStateException!

// Fix: make a new stream each time
list.stream().forEach(System.out::println);
long n = list.stream().count();

A List is a warehouse of boxes — the goods exist, sitting there. A Stream is a conveyor belt with instructions posted along it ("inspect here", "relabel there"). Posting instructions costs nothing. Only when someone at the end says "fill this crate" does the belt start moving — and each box travels the entire belt before the next one is released. Once the belt has run, it's done; you can't rewind it.

Creating streams

Every source you'll actually use
list.stream()                             // from any Collection
Arrays.stream(arr)                        // from an array
Stream.of("a", "b", "c")                  // from explicit values
Stream.empty()                            // zero elements
Stream.iterate(1, x -> x + 1)             // infinite: 1, 2, 3...
Stream.iterate(1, x -> x < 100, x -> x * 2)  // Java 9+: with a built-in stop condition
Stream.generate(Math::random)             // infinite: random values
IntStream.range(0, 5)                     // 0,1,2,3,4   (upper bound EXCLUSIVE)
IntStream.rangeClosed(1, 5)               // 1,2,3,4,5   (upper bound INCLUSIVE)
"a,b,c".chars()                           // IntStream of chars
Files.lines(path)                         // stream a file lazily (close it!)
map.entrySet().stream()                   // streaming a Map

05. Stream operations & collectors

Four verbs carry most of the work: filter (fewer), map (different), reduce (one), collect (into a container).

Intermediate operations

Operation Does
filter(Predicate) Keep elements that match
map(Function) Transform each element 1→1
flatMap(Function) Transform each element 1→many, then flatten
distinct() Remove duplicates (uses equals/hashCode)
sorted() / sorted(Comparator) Sort (stateful — must buffer everything)
limit(n) / skip(n) First n / drop first n
peek(Consumer) Look at each element as it passes — debugging only
takeWhile / dropWhile Java 9+: take/drop while a predicate holds, then stop
mapToInt/Obj/... Switch between object and primitive streams
flatMap — the one people get stuck on
List<List<String>> nested = List.of(
    List.of("a", "b"),
    List.of("c", "d")
);

// map gives you a Stream of Lists — still nested, probably not what you want
Stream<List<String>> wrong = nested.stream().map(x -> x);

// flatMap flattens one level: Stream<List<String>> -> Stream<String>
List<String> flat = nested.stream()
    .flatMap(List::stream)         // each list becomes its elements
    .toList();                     // [a, b, c, d]

// Real use: every order's every item, as one stream
List<Item> allItems = orders.stream()
    .flatMap(o -> o.getItems().stream())
    .toList();
map vs flatMap in one line

map: one in → one out. flatMap: one in → a stream out, all of which get spliced into a single flat stream. Use flatMap whenever map would leave you holding a Stream<List<X>> or Stream<Optional<X>>.

takeWhile vs filter — stop vs skip
List<Integer> nums = List.of(1, 2, 3, 10, 4, 5);

nums.stream().filter(n -> n < 5).toList();      // [1, 2, 3, 4]  — checks ALL, keeps matches
nums.stream().takeWhile(n -> n < 5).toList();   // [1, 2, 3]     — STOPS at the first failure (10)
nums.stream().dropWhile(n -> n < 5).toList();   // [10, 4, 5]    — drops until first failure, keeps rest

Terminal operations

Operation Returns
toList() (Java 16+) An unmodifiable List — the modern default
collect(Collector) Anything — see collectors below
forEach(Consumer) Nothing — side effects only
reduce(...) One combined value
count() long
anyMatch/allMatch/noneMatch boolean (short-circuits)
findFirst()/findAny() Optional<T> (short-circuits)
min/max(Comparator) Optional<T>
toArray() An array
reduce — three forms, increasing power
List<Integer> nums = List.of(1, 2, 3, 4);

// 1. No identity -> Optional (the stream might be empty!)
Optional<Integer> sum1 = nums.stream().reduce((a, b) -> a + b);   // Optional[10]

// 2. With identity -> plain value (identity is the answer for an empty stream)
int sum2 = nums.stream().reduce(0, (a, b) -> a + b);               // 10
int sum3 = nums.stream().reduce(0, Integer::sum);                  // same, clearer

// 3. With identity + combiner -> for parallel streams / different result type
int totalChars = words.stream()
    .reduce(0,
            (acc, w) -> acc + w.length(),   // accumulator: partial + element
            Integer::sum);                  // combiner: merges partials (parallel only)

// In practice, prefer the specialised version — clearer and no boxing:
int sum4 = nums.stream().mapToInt(Integer::intValue).sum();
The identity must be a true identity

reduce(identity, op) requires op(identity, x) == x for every x. For + that's 0; for * it's 1; for string concat it's "". Use 1 for a sum and a parallel stream will quietly give you the wrong answer, because the identity gets applied once per chunk.

Collectors — where results actually get built

The ones worth memorising
import static java.util.stream.Collectors.*;

// --- to containers ---
.collect(toList())                 // mutable ArrayList (pre-16 default)
.toList()                          // Java 16+: UNMODIFIABLE — prefer this
.collect(toSet())
.collect(toCollection(TreeSet::new))          // pick the exact implementation
.collect(toMap(Person::getId, Person::getName))   // key fn, value fn

// --- to a String ---
.collect(joining())                // "abc"
.collect(joining(", "))            // "a, b, c"
.collect(joining(", ", "[", "]"))  // "[a, b, c]"   (delimiter, prefix, suffix)

// --- grouping: the most useful one in real code ---
Map<String, List<Person>> byCity =
    people.stream().collect(groupingBy(Person::getCity));

Map<String, Long> countByCity =
    people.stream().collect(groupingBy(Person::getCity, counting()));

Map<String, List<String>> namesByCity =
    people.stream().collect(groupingBy(Person::getCity, mapping(Person::getName, toList())));

// --- partitioning: groupingBy for a boolean; ALWAYS has both true and false keys ---
Map<Boolean, List<Person>> adults =
    people.stream().collect(partitioningBy(p -> p.getAge() >= 18));

// --- numbers ---
.collect(counting())
.collect(summingInt(Person::getAge))
.collect(averagingInt(Person::getAge))
.collect(summarizingInt(Person::getAge))   // count+sum+min+max+average in one pass
Two toMap traps

1. Duplicate keys throw IllegalStateException. Pass a merge function to decide: toMap(k, v, (a, b) -> a). 2. Null values throw NPEtoMap uses Map.merge internally, which forbids nulls, even for a HashMap that would otherwise allow them.

toMap done safely
// Throws if two people share a city:
Map<String,String> m = people.stream().collect(toMap(Person::getCity, Person::getName));

// Explicit about collisions — keep the first:
Map<String,String> m = people.stream()
    .collect(toMap(Person::getCity, Person::getName, (first, second) -> first));

// ...or pick the map implementation too:
Map<String,String> m = people.stream()
    .collect(toMap(Person::getCity, Person::getName, (a, b) -> a, TreeMap::new));

Parallel streams — the honest guidance

One word, and a lot of caveats
long count = hugeList.parallelStream()
    .filter(x -> expensive(x))
    .count();
// Splits across the common ForkJoinPool (CPU cores - 1 threads, shared JVM-wide)
Parallel is usually slower

Splitting, coordinating and merging costs real time. It only pays off with a large dataset, a CPU-bound per-element cost, and an easily splittable source (ArrayList, arrays — not LinkedList). Worse, it uses the shared common pool, so one slow parallel stream can stall unrelated code across the JVM. Never use it for blocking I/O. Measure, don't guess.

Never do this — the stream contract requires no shared mutable state
List<String> results = new ArrayList<>();
list.parallelStream().forEach(results::add);   // BROKEN — ArrayList isn't thread-safe.
                                               // Lost updates, or ArrayIndexOutOfBoundsException.
// Correct — let collect() handle the merging:
List<String> results = list.parallelStream().toList();

06. Optional

Optional<T> is a box that holds either a value or nothing. Its purpose is to make "there might be no answer" part of the type, so the compiler forces the caller to think about it instead of being ambushed by a NullPointerException.

The problem it solves
// Returning null: the signature LIES. It says "returns User", so callers trust it.
User findUser(String id) { return null; }
findUser("x").getName();      // NullPointerException at runtime

// Returning Optional: the signature is HONEST — "maybe a User"
Optional<User> findUser(String id) { return Optional.empty(); }
findUser("x").map(User::getName).orElse("unknown");   // caller cannot forget
Creating and unwrapping
// --- create ---
Optional.of(value)            // value MUST be non-null, else immediate NPE
Optional.ofNullable(value)    // null becomes empty — use this for legacy APIs
Optional.empty()

// --- unwrap ---
opt.orElse("default")                 // eager: the default is ALWAYS evaluated
opt.orElseGet(() -> compute())        // lazy:  only called if empty  <- prefer this
opt.orElseThrow()                     // Java 10+: NoSuchElementException if empty
opt.orElseThrow(() -> new UserNotFound(id))
opt.ifPresent(v -> use(v))
opt.ifPresentOrElse(v -> use(v), () -> log.warn("missing"))   // Java 9+

// --- transform, without ever unwrapping ---
opt.map(User::getName)                     // Optional<User> -> Optional<String>
opt.filter(u -> u.getAge() >= 18)          // empty if it doesn't match
opt.flatMap(u -> u.findManager())          // when the fn ITSELF returns an Optional
opt.or(() -> findInCache(id))              // Java 9+: fallback Optional
orElse vs orElseGet — a real bug, not a style nit

orElse(expensive()) evaluates expensive() every time — even when the Optional has a value, because it's just a method argument. orElseGet(() -> expensive()) only runs it when empty. If the default hits a database, inserts a row, or costs real money, this is a genuine defect.

See it happen
String expensive() { System.out.println("called!"); return "default"; }

Optional<String> present = Optional.of("actual");
present.orElse(expensive());          // prints "called!"  — then returns "actual". Wasted.
present.orElseGet(() -> expensive()); // prints nothing    — returns "actual".
Chaining beats nested null checks
// Before — the arrow of doom
String city = null;
if (user != null) {
    Address a = user.getAddress();
    if (a != null) {
        City c = a.getCity();
        if (c != null) city = c.getName();
    }
}
if (city == null) city = "unknown";

// After — one expression, no way to get it wrong
String city = Optional.ofNullable(user)
    .map(User::getAddress)
    .map(Address::getCity)
    .map(City::getName)
    .orElse("unknown");
✓ Do
  • Use it as a return type for "might legitimately find nothing".
  • Chain with map/flatMap/filter.
  • Use orElseGet when the default costs anything.
  • Use orElseThrow when absence really is an error.
✗ Don't
  • Use it as a field — it isn't Serializable and adds a wrapper per object.
  • Use it as a parameter — callers must wrap; just overload the method.
  • Return Optional<List> — return an empty list instead.
  • Call get() — deprecated in spirit; orElseThrow() says the same thing honestly.
  • Write if (opt.isPresent()) opt.get() — that's just a null check with extra steps.
Optional can itself be null

return null; from a method declared Optional<T> compiles fine and is the single worst thing you can do with it — the caller's .map() NPEs on the very thing meant to prevent NPEs. Return Optional.empty().

07. Records (Java 16+)

A record is a class whose only job is to hold data. You declare the fields; Java writes the constructor, accessors, equals, hashCode and toString for you.

Before — 40 lines to hold two values
public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) { this.x = x; this.y = y; }

    public int getX() { return x; }
    public int getY() { return y; }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point p = (Point) o;
        return x == p.x && y == p.y;
    }
    @Override public int hashCode() { return Objects.hash(x, y); }
    @Override public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
After — identical behaviour
public record Point(int x, int y) { }

Point p = new Point(1, 2);
p.x();                              // 1        — note: x(), NOT getX()
System.out.println(p);              // Point[x=1, y=2]
p.equals(new Point(1, 2));          // true     — value equality, for free
What you get automatically

A canonical constructor · an accessor per component named x() not getX() · equals comparing all components · a matching hashCode · a readable toString. The class is final and every field is private final.

Rules and limits

Can it… Why
Extend a class No Implicitly extends java.lang.Record; also implicitly final
Implement interfaces Yes Very common — especially with sealed
Have instance methods Yes Add any behaviour you like
Have static fields/methods Yes Factories, constants
Have extra instance fields No State is exactly the components — that's the whole promise
Be mutable No All components are final
Override the generated members Yes Write your own toString etc. if you want
The compact constructor — validation and normalisation
public record Range(int lo, int hi) {

    // Compact form: no parameter list, no assignments. Runs BEFORE the fields are set.
    public Range {
        if (lo > hi) throw new IllegalArgumentException("lo > hi: " + lo + " > " + hi);
        lo = Math.max(lo, 0);      // reassigning the PARAMETER normalises what gets stored
    }

    // Extra behaviour is fine
    public int length() { return hi - lo; }

    // Static factory
    public static Range of(int lo, int hi) { return new Range(lo, hi); }
}

new Range(5, 3);     // IllegalArgumentException
new Range(-4, 10);   // stored as Range[lo=0, hi=10]
Records are shallowly immutable

record Team(String name, List<String> members) — the members reference can't be reassigned, but the list itself can still be mutated by anyone holding it. For true immutability, defensively copy in the compact constructor: members = List.copyOf(members);.

Defensive copying
public record Team(String name, List<String> members) {
    public Team {
        members = List.copyOf(members);   // now genuinely immutable (also rejects nulls)
    }
}

List<String> src = new ArrayList<>(List.of("ann"));
Team t = new Team("A", src);
src.add("bob");
System.out.println(t.members());   // [ann] — unaffected. Without the copy: [ann, bob].

08. Sealed types (Java 17+)

sealed lets a type declare exactly which types may extend it. It turns "anyone can subclass this" into a closed, known list — which the compiler can then reason about.

Declaring a closed hierarchy
public sealed interface Shape permits Circle, Square, Triangle { }

public record Circle(double radius)          implements Shape { }
public record Square(double side)            implements Shape { }
public record Triangle(double b, double h)   implements Shape { }

// Elsewhere:
// public class Hexagon implements Shape { }   // COMPILE ERROR — not in the permits list
Why this is more than access control

final says "nobody may extend me". sealed says "only these may". Because the list is closed and known at compile time, the compiler can verify a switch covers every case — so you get exhaustiveness checking, and no default branch. Add a fourth shape and every switch that forgot it becomes a compile error, not a 3am bug.

Sealed + records + switch — the modern combination
double area(Shape s) {
    return switch (s) {                      // no `default` needed — the compiler knows the full list
        case Circle c   -> Math.PI * c.radius() * c.radius();
        case Square q   -> q.side() * q.side();
        case Triangle t -> 0.5 * t.b() * t.h();
    };
}
// Add `record Hexagon(...) implements Shape` to the permits list
// and THIS METHOD STOPS COMPILING until you handle it. That's the payoff.

The three choices for every subtype

Every permitted subclass must pick one, so the hierarchy can't leak open again:

Modifier Means
final The line stops here (records are automatically final)
sealed Continue with another closed list
non-sealed Deliberately reopen — anyone may extend this branch
All three
public sealed class Vehicle permits Car, Truck, Toy { }

public final class Car extends Vehicle { }                       // closed
public sealed class Truck extends Vehicle permits Pickup { }     // narrows further
public non-sealed class Toy extends Vehicle { }                  // reopened — anyone can extend Toy
public final class Pickup extends Truck { }
Where the subtypes must live

Permitted subclasses must be in the same module (or the same package if you're not using modules). You can omit permits entirely if all subtypes are in the same source file — the compiler infers the list.

09. Pattern matching

Pattern matching removes the "test, then cast, then assign" ritual that Java made you type for twenty-five years.

Pattern matching for instanceof (Java 16+)

The cast disappears
// Before — say "String" three times
if (o instanceof String) {
    String s = (String) o;
    if (s.length() > 5) { ... }
}

// After — bind it right in the test
if (o instanceof String s && s.length() > 5) { ... }
// `s` is in scope wherever the check is known to be true. Note && works because
// the compiler knows the left side passed.
Flow scoping — the binding reaches further than you'd expect
// The guard clause style works too — s is in scope AFTER the if, because
// the method already returned if the test failed.
void f(Object o) {
    if (!(o instanceof String s)) return;
    System.out.println(s.length());     // fine — we can only be here if o IS a String
}

// It makes equals() genuinely pleasant:
@Override public boolean equals(Object o) {
    return o instanceof Point p && p.x == x && p.y == y;
}

Pattern matching for switch (Java 21)

Switching on type
String describe(Object o) {
    return switch (o) {
        case null        -> "nothing";              // switch can handle null explicitly now!
        case Integer i   -> "int: " + i;
        case String s    -> "string of length " + s.length();
        case int[] arr   -> "int array of " + arr.length;
        default          -> "something else";
    };
}
Order matters — and the compiler enforces it

Cases are tried top to bottom, so a broader pattern before a narrower one makes the narrower unreachable. Unlike the old switch, this is a compile error ("label is dominated by a preceding case label"), not a silent bug.

Guards with when
String classify(Object o) {
    return switch (o) {
        case Integer i when i < 0   -> "negative";     // guard: type AND condition
        case Integer i when i == 0  -> "zero";
        case Integer i              -> "positive";      // the catch-all for Integer
        case String s when s.isBlank() -> "blank string";
        case String s               -> "string: " + s;
        default                     -> "other";
    };
}
Record patterns (Java 21) — destructuring
record Point(int x, int y) { }
record Line(Point start, Point end) { }

String describe(Object o) {
    return switch (o) {
        // pull the components straight out — no accessor calls
        case Point(int x, int y) when x == y -> "diagonal point at " + x;
        case Point(int x, int y)             -> "point " + x + "," + y;

        // and it NESTS
        case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
            "line from " + x1 + "," + y1 + " to " + x2 + "," + y2;

        default -> "unknown";
    };
}
The null rule changed

Old switch threw NullPointerException on a null selector. A switch with patterns still throws NPE unless you write case null. Adding case null, default -> ... lets one branch handle both.

Switch expressions (Java 14+) — the foundation for all of the above

Arrow labels: no fall-through, and it returns a value
// Old statement switch — break-or-bug, and `result` must be declared outside
int numLetters;
switch (day) {
    case MONDAY:
    case FRIDAY:
        numLetters = 6;
        break;            // forget this and you fall through. Silently.
    default:
        numLetters = 0;
}

// New expression switch — no break, no fall-through, assigns directly
int numLetters = switch (day) {
    case MONDAY, FRIDAY -> 6;         // multiple labels, comma-separated
    case TUESDAY        -> 7;
    default             -> 0;
};

// Need multiple statements? Use a block and `yield` to produce the value.
int n = switch (day) {
    case MONDAY -> {
        log.info("start of week");
        yield 6;
    }
    default -> 0;
};
Expression switch is exhaustive by construction

Because it must produce a value, the compiler requires every input be covered — via default, or by covering every constant of an enum or every permitted subtype of a sealed type. That's why sealed + switch is such a strong pairing.

10. Text blocks (Java 15+)

A text block is a multi-line string literal. It exists so that embedded JSON, SQL and HTML stop looking like a ransom note of \n and \".

Before and after
// Before
String json = "{\n" +
              "  \"name\": \"Ann\",\n" +
              "  \"age\": 30\n" +
              "}";

// After — what you see is what you get
String json = """
    {
      "name": "Ann",
      "age": 30
    }""";
Incidental whitespace is stripped automatically

Java finds the least-indented non-blank line (the closing """ counts too) and removes that much indentation from every line. So you can indent the block to match your code without that indentation ending up in the string.

The closing delimiter controls the indentation
String a = """
        hello
        world""";          // closing quotes on the last line -> no trailing newline
// "hello\nworld"

String b = """
        hello
        world
        """;               // closing quotes on their OWN line -> trailing newline kept
// "hello\nworld\n"

String c = """
        hello
        world
    """;                   // closing quotes indented LESS -> 4 extra spaces on every line
// "    hello\n    world\n"
The two escapes that only exist here
// \  at end of line — join lines (suppress the newline). Good for long single-line text.
String s = """
    This is one very long line \
    that I wrote across two lines.""";
// "This is one very long line that I wrote across two lines."

// \s — a literal space that also protects trailing whitespace from being stripped
String t = """
    trailing   \s
    """;

// Quotes need no escaping at all:
String sql = """
    SELECT * FROM "users" WHERE name = 'Ann'
    """;
Text blocks are just String

No new type, no runtime cost — it's compile-time syntax. Constants are still interned, and .formatted(...) pairs nicely: """...%s...""".formatted(name). Note that the opening """ must be followed by a line break; text can't start on the same line.

11. The rest worth knowing

A grab bag of smaller things that show up constantly in modern code.

var (Java 10)

Inferred local types
var list = new ArrayList<String>();     // ArrayList<String>
var map  = new HashMap<String, List<Integer>>();   // saves a genuinely painful line
for (var entry : map.entrySet()) { ... }

// var is NOT dynamic typing — the type is fixed at compile time:
var s = "hello";
// s = 42;        // COMPILE ERROR — s is a String, permanently

// Where var is NOT allowed:
// var x;                  // no initialiser -> nothing to infer
// var y = null;           // null has no useful type
// var f = () -> 1;        // lambdas need a target type
// class C { var field; }  // locals only: no fields, no params, no return types
✓ Use var when
  • The type is obvious from the right-hand side: var user = new User();
  • The type is long and noisy: Map<String, List<Order>>
  • In a for loop over an obvious collection.
✗ Avoid var when
  • The right side is a method call: var x = process(); — what is x?
  • It hides an important distinction (e.g. List vs ArrayList).
  • Readers would have to hover in an IDE to follow the code.

Collection factories (Java 9)

Immutable collections in one line
List<String>        l = List.of("a", "b", "c");
Set<String>         s = Set.of("a", "b");
Map<String,Integer> m = Map.of("a", 1, "b", 2);
Map<String,Integer> big = Map.ofEntries(Map.entry("a", 1), Map.entry("b", 2));

// Genuinely IMMUTABLE — not a view, not a wrapper:
l.add("d");        // UnsupportedOperationException

// And they reject nulls outright:
List.of("a", null);   // NullPointerException

// Copy an existing one:
List<String> copy = List.copyOf(existingList);
List.of() vs Arrays.asList()

Arrays.asList returns a fixed-size view backed by the array: you can't add, but you can set, and writes pass through to the original array. List.of is fully immutable and rejects nulls. They are not interchangeable.

Useful String methods (Java 11+)

Small, constantly used
"  ".isBlank();              // true  — isEmpty() only checks length == 0
"  hi  ".strip();            // "hi"  — Unicode-aware; trim() only handles ASCII spaces
"ab".repeat(3);              // "ababab"
"a\nb".lines();              // Stream<String> of lines
"Hi %s".formatted("Ann");    // "Hi Ann"  — instance-method form of String.format

Where Java 21 goes next

Virtual threads & sequenced collections

Java 21's headline feature is virtual threads — millions of cheap threads, covered in the Concurrency notes. It also added sequenced collections: SequencedCollection gives any ordered collection getFirst(), getLast(), addFirst(), addLast() and reversed() — so list.getFirst() finally replaces list.get(0).

12. Gotchas — where Java surprises you

1. Streams do nothing without a terminal operation.

list.stream().map(this::save); saves nothing at all. No terminal op means the pipeline never runs — and there's no warning. If you only want side effects, use forEach, or better, a plain loop.

2. peek is for debugging, not for work.

The JDK explicitly says so. Worse, it can be skipped entirely: since Java 9 the stream may elide operations it can prove unnecessary, so .peek(this::save).count() may never call save.

3. Collectors.toList() and .toList() are different.

collect(Collectors.toList()) returns a mutable ArrayList (unspecified, but that's the implementation). stream.toList() (Java 16+) returns an unmodifiable list — and, unlike List.of, it allows nulls.

4. orElse always evaluates its argument.

Even when the Optional is present. It's an ordinary method argument, so it's computed before the call. Use orElseGet for anything with a cost or a side effect.

5. Optional is not Serializable.

Which is a deliberate signal that it was never designed to be a field or a parameter. It was designed for one thing: return types. Using it elsewhere works but fights the design.

6. Lambdas can't throw checked exceptions.

Function.apply declares none, so paths.stream().map(Files::readString) won't compile. Catch inside the lambda and wrap in an unchecked exception, or drop back to a for loop. There's no elegant built-in answer — this is a genuine rough edge.

7. groupingBy can't have null keys.

If the classifier returns null you get an NPE, even though HashMap happily stores a null key. Same for toMap with null values. Filter or default the nulls first.

8. Records aren't a substitute for every class.

A record's API is its state — the components are public forever. That's perfect for DTOs, value objects and API responses. It's wrong when you want to hide representation, validate mutable state over time, or evolve fields freely.

9. Parallel streams share one pool with the whole JVM.

parallelStream() uses the common ForkJoinPool. One long blocking task can starve every other parallel stream in the process — including your framework's. If you must, submit to your own ForkJoinPool.

13. Interview Q&A

Q: What is a functional interface?

An interface with exactly one abstract method, so a lambda or method reference can implement it. Default and static methods don't count, and neither do redeclared Object methods (which is why Comparator qualifies). @FunctionalInterface makes the compiler enforce it.

Q: Why must captured local variables be effectively final?

Locals live on the stack and vanish when the method returns, but the lambda can outlive it, so the value is copied into the lambda. If the original could still change you'd have two divergent copies. Fields don't have this restriction because they live on the heap and are accessed through this.

Q: Is a lambda just syntax sugar for an anonymous inner class?

No. Different this (enclosing instance vs the anonymous object), no separate .class file, no new scope for names, and it's compiled to invokedynamic — the implementation is decided at runtime rather than baked in.

Q: What is a Stream, and how is it different from a Collection?

A collection stores elements; a stream describes a computation over them. Streams have no storage, don't modify the source, are lazy (nothing runs until a terminal op), can be infinite, and are single-use.

Q: Intermediate vs terminal operations?

Intermediate ops (filter, map, sorted) return a Stream and are lazy. Terminal ops (collect, forEach, reduce, count) produce a result and trigger execution. Exactly one terminal op per stream, and it consumes it.

Q: map vs flatMap?

map is one-to-one. flatMap is one-to-many: the function returns a stream per element, and all of them are flattened into one. Use it when map would leave you with a stream of collections.

Q: orElse vs orElseGet?

orElse takes a value — always evaluated, even when present. orElseGet takes a Supplier — only invoked when empty. Identical results, very different cost and side effects.

Q: What does a record give you, and what are its limits?

Canonical constructor, accessors, equals, hashCode, toString — and it's implicitly final with final fields. It can't extend a class or add instance fields, and it's only shallowly immutable, so copy mutable components in the compact constructor.

Q: What problem do sealed types solve?

They close a hierarchy to a known list, which gives the compiler exhaustiveness checking in switch — no default needed, and adding a subtype turns every unhandled switch into a compile error. It's how you model a closed set of alternatives (a sum type) in Java.

Q: When should you use a parallel stream?

Rarely. Only when the data is large, the per-element work is CPU-bound and independent, the source splits cheaply (array/ArrayList), and you've measured a win. Never for I/O, and never with shared mutable state. It shares the common ForkJoinPool with the entire JVM.

14. Cheat sheet

  • Lambda: an instance of a functional interface (exactly one abstract method). Needs a target type. Captures must be effectively final.
  • The six: Supplier (→T) · Consumer (T→) · Function (T→R) · Predicate (T→boolean) · UnaryOperator (T→T) · BinaryOperator (T,T→T).
  • Method refs: Class::staticM · obj::instanceM (bound) · Class::instanceM (unbound — first arg is the receiver) · Class::new.
  • Stream: source → lazy intermediates → terminal. One element at a time, all the way through. Single-use. No terminal op = nothing happens.
  • map 1→1 · flatMap 1→many+flatten · reduce many→1 · collect many→container.
  • Collectors: toList/toSet/toMap · joining · groupingBy · partitioningBy · counting · summingInt.
  • Optional: return type only · orElseGet over orElse · map/flatMap/filter · never get(), never null.
  • record: final, final fields, auto ctor/accessors/equals/hashCode/toString. Accessor is x(). Compact ctor validates. Shallowly immutable — copy collections.
  • sealed: sealed … permits A, B; subtypes must be final/sealed/non-sealed → exhaustive switch, no default.
  • Patterns: o instanceof String s · case String s when … · case Point(int x, int y) · case null.
  • switch expression: -> = no fall-through, returns a value, yield from a block, must be exhaustive.
  • Text block: """ + newline · common indent stripped · closing quotes set the margin & trailing newline · \ joins lines · \s keeps a space.
  • Versions: 8 lambdas/streams · 10 var · 11 LTS · 16 records · 17 LTS sealed · 21 LTS patterns in switch + virtual threads.
Last reviewed · July 2026 · part of knowledge-base