Java 21 is the current long-term support release as of late 2024, and it represents something unusual: a Java that looks substantively different from Java 8. Not just faster or more convenient — different in how you model domains and reason about concurrency.
This article focuses on the features that arrived between Java 17 and Java 21 (covered separately) and the cohesive picture they form together.
Pattern Matching for Switch (Finalized Java 21)
Pattern matching for instanceof arrived in Java 16. Java 21 extended pattern matching to switch — and this is where the power becomes apparent.
// Java 16: pattern variable in if-else chains
double area;
if (shape instanceof Circle c) {
area = Math.PI * c.radius() * c.radius();
} else if (shape instanceof Rectangle r) {
area = r.width() * r.height();
} else if (shape instanceof Triangle t) {
area = 0.5 * t.base() * t.height();
} else {
throw new IllegalArgumentException("Unknown shape");
}
// Java 21: pattern matching in switch
double area = 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();
};
The last form eliminates the else and the default when shape is of a sealed type where all cases are covered. The compiler enforces exhaustiveness.
Guarded patterns add conditions to cases:
String description = switch (order) {
case Order o when o.total().isGreaterThan(Money.of(1000)) -> "High value order";
case Order o when o.status() == URGENT -> "Urgent order";
case Order o -> "Standard order";
};
Null handling in switch — previously a NullPointerException if the switched value was null:
String status = switch (value) {
case null -> "missing";
case "OK" -> "success";
case String s -> "unknown: " + s;
};
Record Patterns (Finalized Java 21)
Record patterns allow destructuring in pattern matching. Instead of accessing record components through accessor methods, you can destructure them directly in the pattern:
record Point(double x, double y) {}
record Segment(Point start, Point end) {}
// Without record patterns
if (shape instanceof Segment s) {
Point start = s.start();
double x = start.x();
}
// With record patterns — nested destructuring
if (shape instanceof Segment(Point(double x, double y), Point end)) {
// x and y are directly available
System.out.println("Starts at: " + x + ", " + y);
}
In switch:
double length = switch (shape) {
case Segment(Point(var x1, var y1), Point(var x2, var y2)) ->
Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
case Circle c -> 0; // Points on circumference
};
This is most useful for processing algebraic data types — sealed interfaces with record implementations:
sealed interface Event permits UserCreated, OrderPlaced, PaymentFailed {}
record UserCreated(UserId id, String email) implements Event {}
record OrderPlaced(OrderId id, UserId userId, Money total) implements Event {}
record PaymentFailed(OrderId orderId, String reason) implements Event {}
String summary = switch (event) {
case UserCreated(var id, var email) -> "New user: " + email;
case OrderPlaced(var id, var userId, var total) -> "Order " + id + ": " + total;
case PaymentFailed(var orderId, var reason) -> "Payment failed: " + reason;
};
The code reads like the data model. No getters, no intermediate variables, no casting.
Sequenced Collections (Java 21)
A small but useful addition: the SequencedCollection, SequencedSet, and SequencedMap interfaces that provide a consistent API for ordered collections.
Before Java 21, accessing the first or last element was inconsistent across collection types:
// Different APIs for different collections
list.get(0) // ArrayList
deque.getFirst() // Deque
sortedSet.first() // SortedSet
linkedHashMap.entrySet().iterator().next() // LinkedHashMap
With SequencedCollection:
// Consistent API
collection.getFirst() // First element
collection.getLast() // Last element
collection.addFirst(e) // Add at beginning
collection.addLast(e) // Add at end
collection.reversed() // Reversed view
List, Deque, LinkedHashSet, SortedSet, LinkedHashMap all implement the appropriate sequenced interface in Java 21.
Virtual Threads (Finalized Java 21)
Covered in depth in the virtual threads article. The brief summary: virtual threads let you write blocking I/O code that scales to millions of concurrent operations. They’re JVM-scheduled, not OS-scheduled, and allow the carrier thread to be freed when the virtual thread blocks.
The impact on Spring Boot applications:
# One property enables virtual threads for the entire web server
spring.threads.virtual.enabled=true
With this enabled, every HTTP request in Spring MVC runs on its own virtual thread. A service that previously needed careful thread pool tuning to handle 500 concurrent requests can handle thousands with the same code.
The Cohesive Picture
These features don’t exist in isolation. Records + sealed classes + pattern matching form a coherent model for expressing domain logic:
// A complete domain event hierarchy — modern Java style
public sealed interface PaymentEvent
permits PaymentEvent.Initiated, PaymentEvent.Completed,
PaymentEvent.Failed, PaymentEvent.Refunded {
record Initiated(
PaymentId id,
OrderId orderId,
Money amount,
PaymentMethod method,
Instant initiatedAt
) implements PaymentEvent {}
record Completed(
PaymentId id,
String transactionReference,
Instant completedAt
) implements PaymentEvent {}
record Failed(
PaymentId id,
FailureCode code,
String message,
boolean retryable
) implements PaymentEvent {}
record Refunded(
PaymentId id,
PaymentId originalPaymentId,
Money refundAmount,
String reason,
Instant refundedAt
) implements PaymentEvent {}
}
// Processing events — exhaustive, readable, no casting
String description = switch (event) {
case PaymentEvent.Initiated(var id, var orderId, var amount, var method, var at) ->
"Payment of %s for order %s via %s initiated".formatted(amount, orderId, method);
case PaymentEvent.Completed(var id, var ref, var at) ->
"Payment %s completed, reference: %s".formatted(id, ref);
case PaymentEvent.Failed(var id, var code, var msg, var retryable) ->
retryable ? "Payment failed (retryable): " + msg : "Payment failed (final): " + msg;
case PaymentEvent.Refunded(var id, var orig, var amount, var reason, var at) ->
"Refund of %s processed: %s".formatted(amount, reason);
};
This code is precise, readable, and compiler-enforced. If a new event type is added to the sealed hierarchy, every switch site that handles all cases produces a compile error until the new case is handled.
Migration Reality
Java 21 has excellent LTS support and is widely available on all major cloud providers and JVM runtimes. The migration from Java 17 to 21 is generally straightforward for most applications.
The bigger migration question is from Java 8 or 11 — still common in enterprise Java. The barriers are usually:
- Third-party library compatibility (most major libraries have supported Java 11+ for years and Java 17+ since 2022)
- Jakarta EE namespace migration (Java EE → Jakarta EE in Spring Boot 3)
- Build tooling (Gradle 7+/Maven 3.8+ required for Java 17+)
- Testing of actual runtime behavior (the compiler changes catch most issues)
The argument for migrating: not just the features described here, but the performance improvements. Java 21 with ZGC or G1 is substantially faster and more memory-efficient than Java 8. Virtual threads change the concurrency model. Pattern matching reduces code complexity. The total package is a significantly better platform.