JDK 21 Features: What Actually Matters for Production Java Applications

JDK 21 features | code2java

Java 21 is the most significant Java release since Java 8. That is not a promotional claim — it is a structural one. Java 8 introduced streams and lambdas, which changed how Java code is written. Java 21 introduces virtual threads and pattern matching for switch, which change how Java applications are architected and how the language itself expresses intent. For teams running Java applications at any meaningful scale, understanding what these features actually do — not just what they are — determines whether you use them correctly.

Java 21 is an LTS (Long-Term Support) release, meaning Oracle and other JDK vendors provide extended maintenance and security updates. For enterprise teams that move slowly between Java versions, Java 21 is the target migration — moving from Java 11 or 17 to 21 is the path most large organizations are on or planning.

Virtual Threads (JEP 444): Rethinking I/O-Bound Concurrency

Virtual threads are the flagship feature of Java 21. They address a problem that has existed since Java 1.0: each Java thread maps to one OS thread, which consumes 512KB to 1MB of stack memory and requires OS-level scheduling. Under I/O-heavy workloads — where threads spend most of their time waiting for database responses or HTTP calls rather than computing — this mapping is wasteful. You are paying OS thread cost for threads that are parked.

Virtual threads are JVM-managed threads multiplexed onto a small pool of OS carrier threads. When a virtual thread blocks on I/O, the JVM saves its execution state as a continuation on the heap and releases the carrier thread to run another virtual thread. When the I/O completes, the virtual thread is rescheduled. The memory cost is hundreds of bytes per virtual thread. You can create millions. The programming model — crucially — stays synchronous. Your code writes blocking calls and the JVM handles the unmounting transparently.

// Java 21+ — virtual thread executor; one virtual thread per task
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.ArrayList;
import java.util.concurrent.Future;

public class VirtualThreadHttpExample {
    private static final Logger log = LoggerFactory.getLogger(VirtualThreadHttpExample.class);
    private static final HttpClient HTTP = HttpClient.newHttpClient();

    public static void main(String[] args) throws Exception {
        List<String> endpoints = List.of(
            "https://api.example.com/users/1",
            "https://api.example.com/users/2",
            "https://api.example.com/users/3"
        );

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = new ArrayList<>();
            for (String url : endpoints) {
                futures.add(executor.submit(() -> fetchData(url)));
            }
            for (Future<String> f : futures) {
                log.info("Response: {}", f.get());
            }
        }
    }

    private static String fetchData(String url) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create(url)).build();
        // Virtual thread parks during HTTP wait; carrier thread handles other work
        HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
        log.debug("Got {} bytes from {}", response.body().length(), url);
        return response.body();
    }
}Code language: Java (java)

Two things to watch: synchronized blocks containing I/O pin the virtual thread to its carrier — the carrier cannot be released until the lock is freed. Replace synchronized blocks containing I/O with ReentrantLock. And ThreadLocal-heavy libraries create one entry per virtual thread rather than per pool thread — at high concurrency this is one entry per concurrent request, not one per pool slot. Both are fixable but require auditing code that assumed platform thread semantics.

Sequenced Collections (JEP 431): The Interface Java Was Missing

Before Java 21, there was no common interface for collections that have a defined encounter order — a first element, a last element, and the ability to iterate in reverse. List had get(0) and get(size()-1). SortedSet had first() and last(). Deque had peekFirst() and peekLast(). These existed independently with no common abstraction, which made it impossible to write methods that work with any ordered collection.

Java 21 introduces three new interfaces: SequencedCollection, SequencedSet, and SequencedMap. All existing ordered collections — ArrayList, LinkedHashSet, LinkedHashMap, and others — implement these interfaces. You can now write code that works uniformly with any ordered collection.

// Java 21+ — SequencedCollection gives first/last access and reversed() to all ordered types
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.SequencedCollection;
import java.util.SequencedMap;

public class SequencedCollectionExample {

    public static void main(String[] args) {
        // ArrayList now implements SequencedCollection
        SequencedCollection<String> list = new ArrayList<>();
        list.addFirst("first");    // new in Java 21
        list.addLast("last");      // new in Java 21
        System.out.println(list.getFirst()); // "first"
        System.out.println(list.getLast());  // "last"

        // reversed() returns a view — no copying
        SequencedCollection<String> reversed = list.reversed();
        System.out.println(reversed.getFirst()); // "last"

        // LinkedHashMap now implements SequencedMap
        SequencedMap<String, Integer> map = new LinkedHashMap<>();
        map.put("a", 1);
        map.put("b", 2);
        map.put("c", 3);

        System.out.println(map.firstEntry()); // a=1
        System.out.println(map.lastEntry());  // c=3

        // Write generic code that works with any SequencedCollection
        printBoundaries(list);
        printBoundaries(new LinkedHashSet<>(list));
    }

    // Before Java 21: impossible without casting to specific type
    static void printBoundaries(SequencedCollection<String> col) {
        System.out.printf("first=%s, last=%s%n", col.getFirst(), col.getLast());
    }
}Code language: Java (java)

Pattern Matching for Switch (JEP 441): Eliminating the instanceof Cascade

Pattern matching for switch, finalized in Java 21, makes switch expressions capable of matching on types, not just values. Combined with guarded patterns and sealed classes, it produces code that is more readable, more complete, and checked by the compiler for exhaustiveness.

Before this feature, dispatching on type required a chain of instanceof checks and casts — verbose, error-prone, and the compiler had no way to tell if you had covered all cases. With pattern matching for switch, the compiler enforces that all possible types in a sealed hierarchy are handled.

// Java 21+ — pattern matching for switch with sealed classes
// Compiler enforces exhaustiveness when switching over sealed types

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

public class ShapeCalculator {

    // Before Java 21: if-instanceof chain; compiler cannot check completeness
    // Java 21: switch with type patterns; adding a new Shape causes compile error here
    public static double area(Shape shape) {
        return switch (shape) {
            case Circle c -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
            case Triangle t -> 0.5 * t.base() * t.height();
            // No default needed — compiler verifies all sealed subtypes are covered
        };
    }

    // Guarded patterns: add conditions to pattern cases
    public static String classify(Shape shape) {
        return switch (shape) {
            case Circle c when c.radius() > 100 -> "large circle";
            case Circle c -> "small circle";
            case Rectangle r when r.width() == r.height() -> "square";
            case Rectangle r -> "rectangle";
            case Triangle t -> "triangle";
        };
    }
}Code language: Java (java)

Record Patterns (JEP 440): Destructuring in Type Checks

Record patterns extend pattern matching to records, allowing you to destructure the record’s components in the same expression as the type check. Instead of checking if something is a Point and then calling p.x() and p.y(), you extract the components directly in the pattern.

// Java 21+ — record patterns for inline destructuring
record Point(int x, int y) {}
record Line(Point start, Point end) {}

public class RecordPatternExample {

    public static void describe(Object obj) {
        switch (obj) {
            // Destructure Point directly in the pattern
            case Point(int x, int y) when x == y ->
                System.out.println("Point on diagonal: " + x);

            case Point(int x, int y) ->
                System.out.printf("Point at (%d, %d)%n", x, y);

            // Nested destructuring: extract Point components from inside Line
            case Line(Point(int x1, int y1), Point(int x2, int y2)) ->
                System.out.printf("Line from (%d,%d) to (%d,%d)%n", x1, y1, x2, y2);

            default -> System.out.println("Unknown shape");
        }
    }

    public static void main(String[] args) {
        describe(new Point(3, 3));               // Point on diagonal: 3
        describe(new Point(2, 5));               // Point at (2, 5)
        describe(new Line(new Point(0,0), new Point(1,1))); // Line from (0,0) to (1,1)
    }
}Code language: Java (java)

Scoped Values (JEP 446 — Preview): Replacing ThreadLocal for Virtual Threads

ThreadLocal stores per-thread data. The pattern is widely used for passing request-scoped context — user ID, transaction ID, locale — through a call chain without adding parameters to every method. With virtual threads, ThreadLocal has a problem: virtual threads are created per task rather than pooled, so a ThreadLocal entry is created per request rather than per pool thread. At high concurrency this creates many more entries than intended.

ScopedValue provides a cleaner model: a value bound for the duration of a specific scope, readable by any method called within that scope, and automatically unbound when the scope exits. Unlike ThreadLocal, it is immutable within a scope and does not require explicit cleanup.

// Java 21+ (preview) — ScopedValue for request-scoped context
import jdk.incubator.concurrent.ScopedValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ScopedValueExample {
    private static final Logger log = LoggerFactory.getLogger(ScopedValueExample.class);

    // Declare as a static final — the ScopedValue itself is not the value, just the key
    private static final ScopedValue<String> REQUEST_USER = ScopedValue.newInstance();

    public static void handleRequest(String userId) {
        // Bind the value for this scope — all methods called within can read it
        ScopedValue.where(REQUEST_USER, userId).run(() -> {
            processOrder();
            sendNotification();
        });
        // REQUEST_USER is unbound here automatically — no cleanup needed
    }

    private static void processOrder() {
        // Read the bound value — always returns what was set in handleRequest
        log.info("Processing order for user: {}", REQUEST_USER.get());
    }

    private static void sendNotification() {
        log.info("Notifying user: {}", REQUEST_USER.get());
    }
}Code language: Java (java)

Interview Questions

What happens to a virtual thread when it executes a blocking HTTP call using HttpClient.send()?

The JVM detects the blocking network call and unmounts the virtual thread from its carrier OS thread. The virtual thread’s continuation — its stack state — is saved on the heap. The carrier thread immediately picks up another runnable virtual thread. When the HTTP response arrives and the blocking call can return, the original virtual thread is made runnable and scheduled onto the next available carrier. Execution resumes on the line after send() — synchronous from the code’s perspective, non-blocking from the carrier’s perspective.

How does the compiler behave when you add a new sealed subtype to a hierarchy used in a pattern-matching switch?

If the switch statement does not have a default case and does not cover the new subtype, the code fails to compile. The compiler enforces exhaustiveness for switches over sealed types — every permitted subtype must be handled by at least one case. This is the primary advantage of using sealed interfaces with pattern matching: adding a new type to the hierarchy automatically reveals all switch statements that need to be updated. Without sealed classes and pattern matching, missing cases in an instanceof chain compile silently and fail at runtime.

What issues arise when a library uses ThreadLocal to cache database connections and the application moves to virtual threads?

With a platform thread pool of, say, 200 threads, ThreadLocal caches at most 200 connection objects — one per pool thread, shared across requests. With virtual threads created per task, each request gets its own virtual thread and potentially its own ThreadLocal entry. At 10,000 concurrent requests, up to 10,000 connection entries might be created simultaneously, far exceeding connection pool capacity and consuming memory proportional to concurrency rather than pool size. The correct replacement is ScopedValue for passing immutable context, or ensuring that database connections are obtained from a proper connection pool (not cached in ThreadLocal) and closed within each request scope.

Summary

Java 21 is worth the migration primarily for two reasons. Virtual threads remove the OS thread-count ceiling for I/O-bound applications without requiring reactive programming — the code stays synchronous and the JVM handles the rest. Pattern matching for switch combined with sealed classes gives the compiler enough information to enforce exhaustiveness, turning missing-case bugs from runtime errors into compile errors. The other features — sequenced collections, record patterns, scoped values — are quality-of-life improvements that pay off over time. If you are on Java 11 or 17, Java 21 is the clear next target.

Leave a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Scroll to Top