Java 21 is a long-term support (LTS) release, which is why many teams treat it as the version to standardise on for the next few years. It is also the first LTS in which several features that started as previews are now final. If your services still run on Java 11 or 17, this guide focuses on the changes you will actually use in backend code, with small examples you can paste into a scratch project.
Before you upgrade: check that your framework, build plugins and bytecode tools (for example, mocking libraries or APM agents) officially support Java 21. Upgrading the JDK is usually easy; upgrading an outdated dependency chain is the real work.
Records: data carriers without the boilerplate
Records arrived earlier, but in Java 21 they combine with pattern matching in a way that makes them far more useful. A record is a transparent, immutable carrier for data. The compiler generates the constructor, accessors, equals, hashCode and toString.
public record OrderSummary(String orderId, String customerEmail, BigDecimal total) {
public OrderSummary {
Objects.requireNonNull(orderId, "orderId");
if (total.signum() < 0) {
throw new IllegalArgumentException("total must not be negative");
}
}
}
The compact constructor above is the idiomatic place for validation. Records work well for API responses, events, query projections and configuration objects. They are a poor fit for JPA entities, because entities are mutable and rely on proxies.
Pattern matching for switch
Pattern matching for switch is final in Java 21. Instead of a chain of instanceof checks and casts, you can match on type and bind a variable in one step:
String describe(Object value) {
return switch (value) {
case null -> "missing";
case Integer i when i > 1000 -> "large number";
case Integer i -> "number " + i;
case String s -> "text of length " + s.length();
default -> "unsupported: " + value.getClass().getSimpleName();
};
}
Two details are worth noticing. First, case null is now allowed, so you no longer need a separate null check before the switch. Second, the when clause (a guard) lets you refine a case without nesting another if.
Record patterns: deconstructing data
Record patterns let you take a record apart directly in a pattern. Combined with nested records, this reads almost like a description of the data you expect:
record Money(BigDecimal amount, String currency) {}
record Payment(String id, Money money) {}
String label(Object event) {
if (event instanceof Payment(String id, Money(var amount, var currency))) {
return id + ": " + amount + " " + currency;
}
return "unknown event";
}
This is especially handy in event-driven code where a consumer receives many message types and needs to pull out a few fields.
Sealed interfaces: closed hierarchies you can trust
A sealed interface lists exactly which types may implement it. When you switch over a sealed type, the compiler knows every possible case — so if you forget one, you get a compile error instead of a runtime surprise.
public sealed interface PaymentResult permits Approved, Declined, NeedsReview {}
public record Approved(String transactionId) implements PaymentResult {}
public record Declined(String reason) implements PaymentResult {}
public record NeedsReview(String ticketId) implements PaymentResult {}
String toMessage(PaymentResult result) {
return switch (result) {
case Approved a -> "Payment approved: " + a.transactionId();
case Declined d -> "Payment declined: " + d.reason();
case NeedsReview r -> "Under review: " + r.ticketId();
};
}
Notice that there is no default branch. That is intentional: when someone adds a new result type later, every switch that does not handle it will fail to compile. For domain results, errors and state machines, this is a genuine safety improvement.
Virtual threads: simpler concurrency for I/O-heavy services
Virtual threads are the headline feature of Java 21. They are lightweight threads managed by the JVM rather than the operating system. You can create a very large number of them, which makes the classic “one thread per request” style practical again for I/O-bound work such as calling databases and HTTP APIs.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Profile>> futures = userIds.stream()
.map(id -> executor.submit(() -> profileClient.fetch(id)))
.toList();
for (Future<Profile> f : futures) {
profiles.add(f.get());
}
}
The code looks blocking and sequential, which makes it easy to read and debug, yet each blocking call parks only a cheap virtual thread.
What virtual threads do not change
- They do not make CPU-bound work faster. If your code is busy calculating, the number of CPU cores is still the limit.
- They do not remove the need for limits. A database connection pool still has a fixed size; thousands of virtual threads can still overwhelm a downstream service.
- Code that holds a lock while doing blocking I/O inside
synchronizedblocks can reduce the benefit on Java 21, because the virtual thread may stay pinned to its carrier thread. PreferReentrantLockin hot paths and measure.
If you use Spring Boot 3.2 or newer, enabling virtual threads for request handling is a single configuration property (spring.threads.virtual.enabled=true). Treat it like any performance change: turn it on in a staging environment, run a load test and compare latency and error rates.
Sequenced collections
A smaller but pleasant addition: SequencedCollection, SequencedSet and SequencedMap give ordered collections a common API for the first and last elements.
List<String> steps = new ArrayList<>(List.of("build", "test", "deploy"));
String first = steps.getFirst(); // "build"
String last = steps.getLast(); // "deploy"
List<String> reversed = steps.reversed();
No more list.get(list.size() - 1).
A practical upgrade checklist
- Update your build to compile with
--release 21and run the full test suite. - Upgrade frameworks and plugins to versions that list Java 21 support.
- Replace simple DTO classes with records where immutability makes sense.
- Introduce sealed interfaces for result and error types in new code first.
- Trial virtual threads behind a flag and load-test before rolling out.
- Watch memory and GC metrics after the switch; defaults can differ between JDK versions.
Summary
| Feature | Where it helps | Status in Java 21 |
|---|---|---|
| Records + record patterns | DTOs, events, projections | Final |
| Pattern matching for switch | Type-based branching, parsers | Final |
| Sealed interfaces | Domain results, state machines | Final |
| Virtual threads | I/O-heavy request handling | Final |
| Sequenced collections | Ordered collections API | Final |
You do not need to rewrite existing services to benefit. Start with new code, keep changes small and measurable, and let the compiler’s new checks catch mistakes for you.
Comments