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
|
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.
// 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."
(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
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.
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.
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!)
}
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.
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.
@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
@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.
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) |
T → R |
Transform |
Predicate<T> |
test(T) |
T → boolean |
Ask a yes/no question |
UnaryOperator<T> |
apply(T) |
T → T |
Transform to the same type |
BinaryOperator<T> |
apply(T,T) |
T,T → T |
Combine two into one |
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> |
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.
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.
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<>() |
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.
// 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
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.
// 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
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
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.
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.
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
// 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.
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.
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
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 |
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: 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>>.
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 |
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();
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
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
toMap traps
1. Duplicate keys throw IllegalStateException. Pass a merge
function to decide: toMap(k, v, (a, b) -> a).
2. Null values throw NPE — toMap uses
Map.merge internally, which forbids nulls, even for a HashMap that
would otherwise allow them.
// 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
long count = hugeList.parallelStream()
.filter(x -> expensive(x))
.count();
// Splits across the common ForkJoinPool (CPU cores - 1 threads, shared JVM-wide)
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.
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.
// 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
// --- 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.
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".
// 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
orElseGetwhen the default costs anything. - Use
orElseThrowwhen absence really is an error.
✗ Don't
-
Use it as a field — it isn't
Serializableand 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.
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 + "]"; }
}
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
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 |
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]
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);.
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.
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
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.
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 |
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 { }
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+)
// 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.
// 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)
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";
};
}
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.
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 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";
};
}
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
// 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;
};
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
String json = "{\n" +
" \"name\": \"Ann\",\n" +
" \"age\": 30\n" +
"}";
// After — what you see is what you get
String json = """
{
"name": "Ann",
"age": 30
}""";
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.
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"
// \ 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'
""";
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)
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
forloop over an obvious collection.
✗ Avoid var when
-
The right side is a method call:
var x = process();— what isx? - It hides an important distinction (e.g.
ListvsArrayList). - Readers would have to hover in an IDE to follow the code.
Collection factories (Java 9)
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+)
" ".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
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 ·
orElseGetoverorElse·map/flatMap/filter· neverget(), nevernull. -
record: final, final fields, auto
ctor/accessors/
equals/hashCode/toString. Accessor isx(). Compact ctor validates. Shallowly immutable — copy collections. -
sealed:
sealed … permits A, B; subtypes must befinal/sealed/non-sealed→ exhaustive switch, nodefault. -
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,yieldfrom a block, must be exhaustive. -
Text block:
"""+ newline · common indent stripped · closing quotes set the margin & trailing newline ·\joins lines ·\skeeps a space. -
Versions: 8 lambdas/streams · 10
var· 11 LTS · 16 records · 17 LTS sealed · 21 LTS patterns in switch + virtual threads.