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
synchronizedblocks: a virtual thread inside asynchronizedblock pins its carrier OS thread for the duration — the virtual thread cannot unmount. UseReentrantLockinstead ofsynchronizedin 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
instanceofto 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.