A practical tour of the most impactful Java 21+ features — virtual threads, sequenced collections, record patterns, and pattern matching for switch.
Java 21 was the most significant LTS release in a decade. Virtual threads alone rewrote the concurrency model for the JVM, but the release also delivered sequenced collections, record patterns, and a finalised pattern matching for switch — changes that have quietly reshaped how production Java is written. This post focuses on what’s actually useful in day-to-day backend development rather than the full JEP catalogue.
Virtual threads are the headline feature. They are lightweight threads managed by the JVM rather than the OS — you can create millions of them without exhausting OS resources.
The key insight: most thread time in a server is spent waiting on I/O. Platform threads block an OS thread during that wait. Virtual threads park themselves, freeing the carrier thread for other work. The result is throughput competitive with reactive frameworks, with blocking code.
// Before: bounded thread pool constrains throughput
ExecutorService pool = Executors.newFixedThreadPool(200);
// Java 21: unbounded virtual thread pool
ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
// Or per-request in a web handler
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = requests.stream()
.map(req -> executor.submit(() -> callDownstream(req)))
.toList();
return futures.stream()
.map(f -> {
try { return f.get(); }
catch (Exception e) { throw new RuntimeException(e); }
})
.toList();
}
Spring Boot 3.2 enables virtual threads with one property:
spring:
threads:
virtual:
enabled: true
With that set, Tomcat uses virtual threads for every request handler automatically.
What virtual threads don’t fix: CPU-bound work (no scheduler magic there), pinning (when a virtual thread calls synchronized or native code, it pins the carrier thread — prefer ReentrantLock in hot paths), and structured concurrency, which remains a preview feature for coordinating groups of virtual threads.
Java’s collection hierarchy had a gap: no uniform way to access the first and last element, or to reverse a collection. SequencedCollection, SequencedSet, and SequencedMap fill that gap.
SequencedCollection<String> list = new ArrayList<>(List.of("a", "b", "c"));
list.getFirst(); // "a"
list.getLast(); // "c"
list.addFirst("z");
list.addLast("z");
list.removeFirst();
list.removeLast();
SequencedCollection<String> reversed = list.reversed();
SequencedMap adds firstEntry(), lastEntry(), putFirst(), putLast(), and reversed(). LinkedHashMap now implements SequencedMap, making it the natural choice for ordered maps where insertion order matters.
This eliminates a category of boilerplate. Previously accessing the last element of a LinkedHashMap required an iterator walk to the end or a conversion to a list.
Pattern matching for instanceof arrived in Java 16. Java 21 extends it to records, letting you destructure them inline.
sealed interface MarketEvent permits PriceChanged, TradeMatched, MarketSuspended {}
record PriceChanged(String marketId, double bestBack, double bestLay) implements MarketEvent {}
record TradeMatched(String marketId, double price, double size) implements MarketEvent {}
record MarketSuspended(String marketId, String reason) implements MarketEvent {}
With record patterns you destructure directly in the condition:
void processEvent(MarketEvent event) {
if (event instanceof PriceChanged(var id, var back, var lay)) {
log.info("Market {} spread: {}", id, lay - back);
}
}
Record patterns compose — you can nest them for deeply structured data:
record Order(String id, Selection selection) {}
record Selection(String marketId, double price) {}
if (order instanceof Order(var id, Selection(var marketId, var price))) {
// id, marketId, and price all bound in scope
}
The combination of sealed types and switch pattern matching is one of the most ergonomic changes in recent Java. Exhaustiveness is compiler-checked for sealed hierarchies — you can’t miss a case.
String describe(MarketEvent event) {
return switch (event) {
case PriceChanged(var id, var back, var lay) ->
"Price move on %s: back %.2f lay %.2f".formatted(id, back, lay);
case TradeMatched(var id, var price, var size) ->
"Trade on %s: %.2f @ %.2f".formatted(id, size, price);
case MarketSuspended(var id, var reason) ->
"Suspended %s: %s".formatted(id, reason);
};
}
Guards add inline conditions:
String classify(MarketEvent event) {
return switch (event) {
case PriceChanged(var id, var back, var lay) when (lay - back) < 0.02 ->
"Tight market: " + id;
case PriceChanged(var id, var back, var lay) ->
"Normal spread: " + id;
case TradeMatched t when t.size() > 1000 ->
"Large trade on " + t.marketId();
case TradeMatched t ->
"Trade on " + t.marketId();
case MarketSuspended s ->
"Suspended: " + s.reason();
};
}
This replaces the chains of if/else instanceof that were common in event-processing code.
When you need to match a pattern but don’t use the bound variable, _ suppresses the warning cleanly:
// Before
try {
riskyOperation();
} catch (IOException ignored) {
fallback();
}
// Java 22+
try {
riskyOperation();
} catch (IOException _) {
fallback();
}
In switch patterns, _ matches any value in a nested position you don’t need:
case Order(var id, _) -> processId(id);
Stream.gather() is the extensibility point that Stream was always missing. Built-in collectors don’t cover every aggregation; previously you had to break out of the stream. Gatherers let you write custom intermediate operations that can maintain state.
// Sliding window over a stream — not possible with built-in operations
Stream.of(1.0, 2.0, 3.0, 4.0, 5.0)
.gather(Gatherers.windowSliding(3))
.map(window -> window.stream().mapToDouble(d -> d).average().orElse(0))
.forEach(System.out::println);
// 2.0, 3.0, 4.0
// Fixed-size batches
Stream.of(events)
.gather(Gatherers.windowFixed(100))
.forEach(batch -> processBatch(batch));
Built-in gatherers in Java 24: windowFixed, windowSliding, fold, scan, mapConcurrent.
mapConcurrent is particularly useful — it runs a mapping function concurrently up to a virtual thread limit, preserving encounter order:
List<ApiResponse> responses = ids.stream()
.gather(Gatherers.mapConcurrent(20, id -> apiClient.fetch(id)))
.toList();
Migrate to virtual threads first. The Spring Boot property is a one-liner and the throughput improvement on I/O-bound services is real. Monitor for pinning warnings (-Djdk.tracePinnedThreads=full) and replace any synchronized blocks on hot paths with ReentrantLock.
Use sealed types with switch exhaustiveness. The compiler-enforced exhaustiveness catches missed event types that would otherwise silently fall through to a default. This matters in event-driven systems where new event types are added regularly.
SequencedCollection cleans up a lot of boilerplate in code that accesses first/last elements — especially when processing ordered market data or maintaining insertion-ordered caches.
Hold on string templates. They appeared as a preview in Java 21, went to a second preview in Java 22, then were withdrawn in Java 23. A revised design is in progress. Avoid building production code around them.
Java 21 is worth being on. If you’re still on 11 or 17, virtual threads alone justify the upgrade. If you’re building Java systems on a current stack and want an extra pair of hands, get in touch.