What Java 21 Changes for Production Java Developers — Virtual Threads, Records, Sealed Classes, and Pattern Matching

by Eric Hanson, Backend Developer at Clean Systems Consulting

Virtual threads — the biggest concurrency change since ExecutorService

Virtual threads are not a performance optimization for CPU-bound code. They are a scalability improvement for I/O-bound code — specifically, code that blocks on network calls, database queries, or file I/O.

The problem they solve: in a traditional thread-per-request model, each blocked thread holds an OS thread, a megabyte of stack, and an OS scheduler entry. At 500 concurrent requests, 500 OS threads sit blocked on database queries. Scaling to 5,000 concurrent requests requires 5,000 OS threads — expensive, limited by OS constraints, and inefficient because most threads are idle most of the time.

Virtual threads decouple Java threads from OS threads. A virtual thread that blocks on I/O is unmounted from its carrier OS thread — the carrier thread serves another virtual thread. Millions of virtual threads can exist with a handful of carrier OS threads.

The code change is minimal:

// Before — fixed-size thread pool limits concurrency
ExecutorService executor = Executors.newFixedThreadPool(200);

// After — one virtual thread per task, scales to millions
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

For Spring Boot, enabling virtual threads for request handling is a single property:

spring.threads.virtual.enabled=true

This is production-ready in Java 21. The measurable benefit is in services that spend significant time blocking on I/O. A service where each request makes 3–5 database queries and 2–3 external HTTP calls will handle more concurrent requests with the same hardware.

What virtual threads don't fix:

  • CPU-bound work: computation saturates CPU regardless of how many virtual threads exist
  • synchronized blocks: a virtual thread inside a synchronized block pins its carrier OS thread for the duration — the virtual thread cannot unmount. Use ReentrantLock instead of synchronized in code that runs on virtual threads and performs I/O inside the lock
  • Connection pools: database connections are still finite. 10,000 virtual threads waiting for one of 20 database connections are still waiting — virtual threads don't multiply connection pool capacity

Records — value carriers without boilerplate

Records, finalized in Java 16 and fully available in Java 21, generate the boilerplate that value-carrying classes always needed: constructor, accessors, equals, hashCode, toString.

// Before — 40 lines for a simple data carrier
public final class OrderSummary {
    private final String orderId;
    private final long total;
    private final String status;

    public OrderSummary(String orderId, long total, String status) {
        this.orderId = orderId;
        this.total   = total;
        this.status  = status;
    }

    public String orderId() { return orderId; }
    public long   total()   { return total; }
    public String status()  { return status; }

    @Override public boolean equals(Object o) { ... }
    @Override public int hashCode() { ... }
    @Override public String toString() { ... }
}

// After — 4 lines
public record OrderSummary(String orderId, long total, String status) {}

The compact constructor validates and normalizes without repeating parameter names:

public record OrderSummary(String orderId, long total, String status) {
    public OrderSummary {
        Objects.requireNonNull(orderId, "orderId required");
        if (total < 0) throw new IllegalArgumentException("total must not be negative");
        status = status.toLowerCase(); // normalize before assignment
    }
}

Records are immutable by construction — all components are final. They implement equals and hashCode based on all components. They work as map keys, in sets, and in pattern matching.

Records work naturally with Java 21's pattern matching:

Object result = getResult();
if (result instanceof OrderSummary(var orderId, var total, var status)) {
    // orderId, total, status are bound here — no explicit accessor calls
    System.out.printf("Order %s: %d (%s)%n", orderId, total, status);
}

Record deconstruction patterns extract components directly in the pattern. This eliminates the instanceof check followed by a cast followed by accessor calls.

Sealed classes — closed hierarchies with exhaustive matching

Sealed classes declare exactly which classes can extend them. Combined with pattern matching in switch, they enable exhaustive matching — the compiler verifies that all cases are handled:

public sealed interface PaymentResult
    permits PaymentResult.Success, PaymentResult.Declined, PaymentResult.Error {

    record Success(String transactionId, long amount) implements PaymentResult {}
    record Declined(String reason, String declineCode) implements PaymentResult {}
    record Error(Exception cause) implements PaymentResult {}
}

Pattern matching in switch over a sealed type:

String message = switch (result) {
    case PaymentResult.Success s  -> "Charged " + s.amount() + ", tx: " + s.transactionId();
    case PaymentResult.Declined d -> "Declined: " + d.reason() + " (" + d.declineCode() + ")";
    case PaymentResult.Error e    -> "Error: " + e.cause().getMessage();
};

The compiler verifies exhaustiveness — if PaymentResult adds a new permits type and the switch doesn't handle it, the code doesn't compile. This is the compile-time safety that the visitor pattern tried to achieve with runtime dispatch, without the visitor pattern's ceremony.

This combination — sealed interfaces + records + pattern matching — replaces several patterns that existed to work around Java's previous limitations:

  • Visitor pattern for type-based dispatch on a closed hierarchy
  • Algebraic data types emulated with abstract classes and subclass checks
  • Result types that used instanceof to distinguish success from failure
// Old approach — abstract class with instanceof
abstract class PaymentResult {}
class Success extends PaymentResult { ... }
class Declined extends PaymentResult { ... }

if (result instanceof Success s) {
    // handle success
} else if (result instanceof Declined d) {
    // handle declined
}
// No compiler warning if Error is added and this branch is forgotten

// New approach — sealed + switch, compiler catches missing cases

Pattern matching in switch — guards and nesting

Java 21's switch expressions handle patterns, guards, and nested matching:

String describe(Object obj) {
    return switch (obj) {
        case Integer i when i < 0     -> "negative integer: " + i;
        case Integer i when i == 0    -> "zero";
        case Integer i                -> "positive integer: " + i;
        case String s when s.isEmpty() -> "empty string";
        case String s                  -> "string: " + s;
        case null                      -> "null";
        default                        -> "other: " + obj.getClass().getSimpleName();
    };
}

when guards attach conditions to patterns — the pattern matches only if the guard is true. Cases are evaluated in order; the first matching case wins.

Nested record deconstruction:

record Point(int x, int y) {}
record Line(Point start, Point end) {}

String describeOriginLine(Object obj) {
    return switch (obj) {
        case Line(Point(0, 0), Point p) -> "starts at origin, ends at " + p;
        case Line(Point p, Point(0, 0)) -> "starts at " + p + ", ends at origin";
        case Line l                      -> "line from " + l.start() + " to " + l.end();
        default                          -> "not a line";
    };
}

Nested patterns deconstruct nested records inline. The compiler type-checks the entire pattern at compile time.

Sequenced collections — the overlooked addition

SequencedCollection, SequencedSet, and SequencedMap are new interfaces in Java 21 that formalize the concept of a collection with a defined encounter order. They add getFirst(), getLast(), addFirst(), addLast(), removeFirst(), removeLast(), and reversed() to collections that have always supported these operations but through inconsistent APIs:

// Before Java 21 — different APIs for the same concept
list.get(0);                  // first element of List
deque.peekFirst();            // first element of Deque
linkedHashSet.iterator().next(); // first element of LinkedHashSet (awkward)

// Java 21 — unified API
list.getFirst();              // List implements SequencedCollection
deque.getFirst();             // Deque implements SequencedCollection
linkedHashSet.getFirst();     // LinkedHashSet implements SequencedSet

reversed() returns a view of the collection in reverse order without copying:

List<Order> orders = getOrders(); // sorted ascending by date
List<Order> recent = orders.reversed(); // most recent first — no copy
recent.getFirst(); // most recent order

This is a small addition but removes a category of utility methods that every codebase accumulated to paper over the inconsistency.

Structured concurrency — still preview in Java 21

StructuredTaskScope is a preview feature in Java 21 (finalized in Java 24). It provides structured concurrency — subtasks are scoped to a parent task, and the parent cannot complete until all subtasks complete or are cancelled:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User>      user    = scope.fork(() -> fetchUser(userId));
    Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));

    scope.join();           // waits for both
    scope.throwIfFailed();  // throws if either failed

    return new Dashboard(user.get(), orders.get());
}

ShutdownOnFailure cancels all subtasks if any fail. ShutdownOnSuccess cancels remaining subtasks when the first succeeds — useful for speculative execution.

Because it's a preview feature in Java 21, using it requires --enable-preview. For production use today, the API is available but may change. Teams running Java 24+ can use it without preview flags.

What to adopt now and what to wait on

Adopt now: virtual threads for I/O-bound services (single property change in Spring Boot), records for all new value-carrying types, sealed classes with pattern matching for closed type hierarchies and result types, SequencedCollection APIs in new code.

Evaluate: converting existing synchronized blocks to ReentrantLock before enabling virtual threads — necessary only in code that performs I/O inside synchronized blocks.

Wait on (if on Java 21): structured concurrency — preview API, use CompletableFuture or your existing concurrency approach until stable.

Java 21's features aren't incremental improvements on Java 8. Virtual threads change the concurrency model. Sealed classes with pattern matching change how type hierarchies are designed. Records change how value types are expressed. Together they reduce the gap between what Java makes easy and what makes for well-designed production code.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Architecture Decisions Matter More Than Frameworks

Why do some apps crash after a minor update while others scale effortlessly? Often, it’s not the fancy framework—they’re just tools. The real magic (or disaster) starts with architecture.

Read more

10 Warning Signs Your Software Project Will Fail

Some software projects fail quietly—long before they hit production. Knowing the warning signs early can save time, money, and headaches.

Read more

How to Build a Portfolio That Actually Shows Growth

Your portfolio shouldn’t just list projects; it should tell a story of improvement. Here’s how to showcase progress in a way that makes your skills undeniable.

Read more

Feeling Stuck After 3 Years? How to Know if You’re Improving

You’ve been coding for a few years, but it feels… flat. No big jumps, no clear progress—just work on repeat.

Read more