Java 14 → 21: What Changed While You Were Away

You last wrote Java around version 8 or 11. The language between then and 21 quietly became a different language. Records replace 40-line POJOs. Sealed classes replace defensive instanceof chains. Pattern matching for switch collapses visitor patterns into eight lines. Virtual threads make blocking I/O free again. This file covers the changes that actually show up in production code and studies — not the academic ones.

Target Java 21 LTS. Java 25 LTS shipped in September 2025 but enterprise adoption still lags by 12–18 months; you will study on 21 codebases for the next two years. Mention 25 if asked, work on 21.

The production-relevance filter

Feature

Java Version

Production use in 2026

study relevance

Records

14 (16 final)

Everywhere. Replaced most DTOs.

High

Sealed classes

15 preview → 17

Domain modeling, ADTs

Medium-high

Pattern matching for instanceof

16

Everywhere

High

Pattern matching for switch

21

Growing fast

High

Text blocks

15

SQL, JSON, HTML literals

Medium

Switch expressions

14

Standard

Medium

var (local-variable type inference)

10

Standard, controversial

Medium

Virtual threads

21

Server frameworks adopting

High (topic)

Sequenced collections

21

Rarely, but expected

Low-medium

Enhanced NullPointerException

14

Automatic, no code change

Low

Everything below is worth knowing. Everything not on this list (like Foreign Function API, Vector API, Structured Concurrency preview) is worth knowing exists — nothing more, for now.


1. Records (Java 14 preview, 16 final)

A record is an immutable data carrier. The compiler generates the constructor, accessors, equals, hashCode, and toString for you.

Before (the 40-line POJO you wrote 20 times in college):

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 x() { return x; }
    public int y() { return y; }
    @Override public boolean equals(Object o) { /* ... */ }
    @Override public int hashCode() { /* ... */ }
    @Override public String toString() { /* ... */ }
}

Now:

public record Point(int x, int y) {}

That’s it. Accessors are p.x() and p.y() (no get prefix). You can add methods, static factories, and a compact constructor for validation:

public record Range(int lo, int hi) {
    public Range {                       // compact constructor
        if (lo > hi) throw new IllegalArgumentException();
    }
    public int span() { return hi - lo; }
    public static Range of(int a, int b) { return new Range(Math.min(a,b), Math.max(a,b)); }
}

When to use: DTOs, value objects, tuples, method return types with multiple values, cache keys. When not to use: Entities with mutable state, JPA entities (they need no-arg constructors and setters), anything with lifecycle.


2. Sealed classes (Java 17)

A sealed class or interface restricts which classes can extend or implement it. This gives you algebraic data types — the compiler knows the full set of subtypes and can enforce exhaustive switch.

public sealed interface Shape permits Circle, Square, Triangle {}
public record Circle(double r) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double b, double h) implements Shape {}

Combined with pattern matching for switch (below), you get:

double area = switch (shape) {
    case Circle c   -> Math.PI * c.r() * c.r();
    case Square s   -> s.side() * s.side();
    case Triangle t -> 0.5 * t.b() * t.h();
    // no default needed — compiler knows the set is closed
};

Add a new Shape subtype and the compiler flags every non-exhaustive switch. This is the closest Java has come to Kotlin/Scala/Rust-style ADTs.

Modifiers on the permitted types: must be one of final, sealed, or non-sealed. Records are implicitly final. non-sealed opens a branch back up to open extension.


3. Pattern matching for instanceof (Java 16)

The cast-after-check ritual is dead.

// Before
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// After
if (obj instanceof String s) {
    System.out.println(s.length());
}

The binding s is only in scope where the compiler can prove it is non-null and correctly typed. You can also negate:

if (!(obj instanceof String s)) return;
s.length();   // s is in scope after the early return

4. Pattern matching for switch (Java 21)

The headline Java 21 feature. Combines type patterns, record patterns, and guards.

String describe(Object o) {
    return switch (o) {
        case null           -> "null";
        case Integer i when i < 0 -> "negative int: " + i;
        case Integer i      -> "non-negative int: " + i;
        case String s       -> "string of length " + s.length();
        case int[] arr      -> "int array of size " + arr.length;
        default             -> "something else";
    };
}

Record patterns destructure records:

record Point(int x, int y) {}
record Line(Point from, Point to) {}

switch (line) {
    case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
        System.out.printf("(%d,%d) → (%d,%d)%n", x1, y1, x2, y2);
}

Exhaustiveness is compiler-enforced when the switched type is sealed.


5. Switch expressions (Java 14)

Switch that returns a value. Arrow form has no fall-through and no break.

int days = switch (month) {
    case JAN, MAR, MAY, JUL, AUG, OCT, DEC -> 31;
    case APR, JUN, SEP, NOV -> 30;
    case FEB -> 28;
};

Use yield inside a block if you need statements:

int n = switch (x) {
    case 1 -> 100;
    case 2 -> {
        System.out.println("two");
        yield 200;
    }
    default -> 0;
};

6. Text blocks (Java 15)

Multi-line string literals. Kills the "\n" + chain.

String json = """
    {
      "name": "raghul",
      "role": "ml-engineer"
    }
    """;

Indentation is normalized by the compiler based on the closing """. If the closing delimiter is at column 4, four spaces are stripped from each line. Use \ at line end to suppress the newline, and \s for a trailing space that would otherwise be stripped.

Where it matters: SQL queries, JSON test fixtures, HTML fragments, error messages with formatting.


7. var — local-variable type inference (Java 10)

var list = new ArrayList<String>();     // inferred: ArrayList<String>
var map  = Map.of("a", 1, "b", 2);      // inferred: Map<String,Integer>
for (var e : map.entrySet()) { ... }    // inferred: Map.Entry<String,Integer>

Rules: local variables only, must have an initializer, not for fields/parameters/return types.

⚠️ What most people get wrong

They var everything and destroy readability. var shines when the RHS makes the type obvious (var user = new User(...)) or when the type name is noisy (var it = map.entrySet().iterator()). It hurts when the RHS is a method call whose return type is ambiguous:

var result = process(input);  // reader has to jump to process() to know the type

Rule of thumb: if the reader would need to leave this line to know the type, don’t use var.


8. Virtual threads (Java 21)

Project Loom’s shipped form. A virtual thread is a lightweight thread scheduled by the JVM onto a small pool of OS carrier threads. You can have millions of them. Blocking I/O on a virtual thread does not block the underlying OS thread.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        }));
}   // executor auto-closes and waits for all tasks

Why this matters: the reactive/CompletableFuture complexity you learned exists mostly to avoid blocking OS threads. Virtual threads make plain blocking code performant again. Spring Boot 3.2+, Helidon, Quarkus, and Vert.x all support them.

When NOT to use them: CPU-bound tasks (use ForkJoinPool), tasks that pin the carrier via synchronized blocks or JNI (Java 24 fixes most synchronized pinning — mention it).

Deep dive is in Phase 05. For now: know they exist, know the executor pattern, know they replace 90% of async-callback code you’d otherwise write.


9. Sequenced collections (Java 21)

Three new interfaces — SequencedCollection, SequencedSet, SequencedMap — finally give ordered collections a uniform API.

List<Integer> list = new ArrayList<>(List.of(1,2,3));
list.addFirst(0);       // [0, 1, 2, 3]
list.getLast();          // 3
list.reversed();         // reversed view, no copy

LinkedHashMap<String,Integer> lhm = new LinkedHashMap<>();
lhm.putFirst("a", 1);    // now on the interface

Small, quality-of-life. Not study-central but you look sharp if you mention it when asked “how do I get the first/last of a LinkedHashSet?”.


10. Miscellaneous, worth 30 seconds each

  • Helpful NPE (14): NullPointerException messages now say which variable was null. a.b.c.d() NPE tells you it was c, not just “NPE at line 42”. Free improvement, no code change.

  • Stream.toList() (16): shortcut for .collect(Collectors.toList()) returning an unmodifiable list.

  • Files.mismatch(p1, p2) (12): byte-level file comparison, returns first differing byte offset.

  • String.formatted(...) (15): "hello %s".formatted(name) — nicer than String.format.

  • instanceof pattern in Objects.requireNonNull chains: not a language feature, but you’ll see it more.

  • Deprecated / removed: Nashorn (JS engine) gone in 15. Security Manager deprecated in 17, terminal in 24. Applets gone. finalize() deprecated for removal.


What you can safely ignore for now

  • Structured Concurrency (still preview through Java 23) — revisit when it goes final

  • Scoped Values (preview) — replacement for ThreadLocal in virtual-thread world; not yet final

  • Foreign Function & Memory API (21 final) — only relevant if you interop with native libs

  • Vector API (still incubator) — SIMD for numeric code; niche

  • Class-file API (24 final) — for bytecode tooling; niche

Mentioning them in an study: “I know they exist, I’ve read the JEPs, I haven’t used them in production” is the honest and correct answer.


Practice: hand-type these before moving on

Type each of these into an empty Scratch.java file, run them, delete, re-type from memory. No copy-paste.

  1. A record Point(int x, int y) with a compact constructor rejecting negatives

  2. A sealed interface Result<T> with Success<T> and Failure<T> records

  3. A switch expression on that Result<T> that returns a value or a fallback

  4. A method that reads a file into a List<String> using Files.readAllLines and prints with a text block header

  5. An Executors.newVirtualThreadPerTaskExecutor() block that submits 1,000 sleepers and waits

If any of these took more than 5 minutes or required a lookup, re-read the relevant section.


Return to README.md · Next: 02_type_system_and_control_flow.md