Java 21 LTS: Complete Guide to Virtual Threads, Records, Pattern Matching & Migration
Java 21 is the most significant LTS release since Java 8. Virtual Threads from Project Loom fundamentally change how you write concurrent I/O code. Pattern matching for switch eliminates the verbose instanceof chains that have plagued Java for decades. Record Patterns bring destructuring to the language. If you're evaluating an LTS upgrade — or need to justify the migration to your team — this guide covers everything from the headline features to the practical migration checklist.
Table of Contents
- Java 21 LTS: Why It Matters
- Virtual Threads: Project Loom in Production
- Pattern Matching for Switch (JEP 441)
- Record Patterns and Deconstruction (JEP 440)
- Sequenced Collections (JEP 431)
- Structured Concurrency & Scoped Values (Preview)
- Java 21 Migration Guide: From 11, 17, and 19/20
- Spring Boot 3.x and Java 21: What Changes
Java 21 LTS: Why It Matters
Java 21 was released in September 2023 and is the current Long-Term Support release, succeeding Java 17 LTS (September 2021). Oracle provides standard support through September 2028 and extended support through September 2031. For enterprise teams, LTS releases are the safe upgrade path — non-LTS releases (18, 19, 20, 22, 23) receive only six months of support each.
What makes Java 21 more significant than previous LTS releases is that it delivers features that change how you architect concurrent applications — not just syntax improvements or library additions. Virtual Threads represent a fundamental change in the concurrency model that was impossible to achieve without JVM-level changes.
| JEP | Feature | Status | Impact |
|---|---|---|---|
| 444 | Virtual Threads | Final | Very High |
| 441 | Pattern Matching for switch | Final | High |
| 440 | Record Patterns | Final | Medium |
| 431 | Sequenced Collections | Final | Medium |
| 453 | Structured Concurrency | Preview | High (future) |
| 446 | Scoped Values | Preview | Medium |
| 430 | String Templates | Preview | Low-Medium |
| 445 | Unnamed Classes & Instance Main Methods | Preview | Low |
Virtual Threads: Project Loom in Production
Virtual Threads are the headline feature of Java 21. They are lightweight threads managed by the JVM rather than the operating system. A platform thread (what Java threads have always been) maps 1:1 to an OS thread and consumes approximately 1MB of stack space. Creating 10,000 platform threads exhausts memory on most JVMs. Virtual threads are heap-allocated, consume around 200 bytes when parked, and can be created in the millions.
The critical behavior change is what happens during I/O blocking. When a platform thread calls a blocking I/O operation (database query, HTTP call, file read), the OS thread is blocked — it cannot do anything else until the I/O completes. This is why thread pools exist: to limit the number of simultaneously blocked threads. With virtual threads, when a blocking call is made, the JVM unmounts the virtual thread from its carrier thread (an OS thread in a ForkJoinPool), saves the virtual thread's stack to the heap, and uses the carrier thread for other virtual threads. When the I/O completes, the virtual thread is rescheduled onto a carrier thread to continue execution.
// Before Java 21: Thread pool limits throughput
ExecutorService executor = Executors.newFixedThreadPool(200);
// 200 platform threads = max 200 concurrent blocking calls
// Java 21: Virtual thread per task
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// Millions of virtual threads, each blocking independently
// Or directly:
Thread vt = Thread.ofVirtual()
.name("order-processor-", 0)
.start(() -> {
// Blocking I/O here parks the virtual thread, not the carrier thread
OrderResult result = orderRepository.findById(orderId); // DB call
paymentClient.charge(result); // HTTP call
});
For Spring Boot applications, enabling virtual threads requires a single property in Java 21 + Spring Boot 3.2+:
spring:
threads:
virtual:
enabled: true
This configures Tomcat and Spring's async task executor to use virtual threads. Every incoming HTTP request gets its own virtual thread, and blocking I/O within that request (database queries, downstream HTTP calls) parks the virtual thread without consuming a platform thread. A standard Spring Boot application with a 200-thread Tomcat pool handling 100ms database queries can serve approximately 2,000 concurrent requests before queueing. With virtual threads, the same application can handle tens of thousands of concurrent requests because each one parks independently during its database query.
There are three important caveats:
Pinning. A virtual thread is pinned to its carrier thread (and thus blocks the carrier) during synchronized blocks and synchronized methods that perform blocking operations. If your code calls a blocking method inside a synchronized block, you lose the virtual thread benefit for that code path. The fix is to replace synchronized with ReentrantLock. The JVM provides a diagnostic flag to identify pinning: -Djdk.tracePinnedThreads=full.
CPU-bound work. Virtual threads do not help CPU-bound tasks. If your service runs heavy computations, virtual threads provide no benefit — you still need the same number of platform threads to run CPU work in parallel. Virtual threads shine for I/O-bound workloads.
ThreadLocal usage at scale. With millions of virtual threads, ThreadLocal variables that hold large objects can cause memory pressure because each virtual thread gets its own copy. Consider scoped values (JEP 446, preview in Java 21) as a more efficient alternative.
Pattern Matching for Switch (JEP 441)
Pattern matching for switch is final in Java 21 after several preview rounds. It extends the switch expression and statement to match on type patterns, allowing you to replace long instanceof-and-cast chains with concise, exhaustive switch expressions:
// Before Java 21 (verbose instanceof chains)
Object processShape(Shape shape) {
if (shape instanceof Circle c) {
return Math.PI * c.radius() * c.radius();
} else if (shape instanceof Rectangle r) {
return r.width() * r.height();
} else if (shape instanceof Triangle t) {
return 0.5 * t.base() * t.height();
} else {
throw new IllegalArgumentException("Unknown shape: " + shape);
}
}
// Java 21: Pattern matching switch
double processShape(Shape shape) {
return 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();
};
}
When Shape is a sealed interface, the switch is exhaustive — the compiler knows all possible subtypes and enforces that you handle all of them. Adding a new Hexagon implementation to the sealed interface will cause a compile error on every switch that doesn't handle it, giving you refactoring safety that doesn't exist with if-else chains.
Guard patterns (using when) let you add conditions to patterns:
String describeNumber(Number n) {
return switch (n) {
case Integer i when i < 0 -> "negative int: " + i;
case Integer i when i == 0 -> "zero";
case Integer i -> "positive int: " + i;
case Double d when d.isNaN() -> "NaN";
case Double d -> "double: " + d;
case null -> "null";
default -> "other number: " + n;
};
}
The null case in switch is also new in Java 21 — previously, a null value would throw NullPointerException. Now you can handle it explicitly. The default case handles types not covered by the type patterns.
Record Patterns and Deconstruction (JEP 440)
Record Patterns extend pattern matching to destructure records. Instead of extracting record components manually after matching, you can destructure them inline:
record Point(int x, int y) {}
record Line(Point start, Point end) {}
// Before: manual extraction
void processLine(Object obj) {
if (obj instanceof Line l) {
Point start = l.start();
Point end = l.end();
System.out.printf("From (%d,%d) to (%d,%d)%n", start.x(), start.y(), end.x(), end.y());
}
}
// Java 21: Record pattern deconstruction (nested)
void processLine(Object obj) {
if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
System.out.printf("From (%d,%d) to (%d,%d)%n", x1, y1, x2, y2);
}
}
// Combined with switch for maximum expressiveness
String describe(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == 0 && y == 0 -> "origin";
case Point(int x, int y) when x == 0 -> "on Y-axis at " + y;
case Point(int x, int y) when y == 0 -> "on X-axis at " + x;
case Point(int x, int y) -> "point at (" + x + "," + y + ")";
case Line(Point s, Point e) -> "line from " + s + " to " + e;
default -> "unknown: " + obj;
};
}
Record Patterns are particularly powerful for processing JSON or API response hierarchies that you've mapped to sealed record trees, and for implementing visitor-pattern-equivalent logic without the boilerplate.
Sequenced Collections (JEP 431)
Java 21 introduces three new interfaces — SequencedCollection, SequencedSet, and SequencedMap — that add first/last element access and reverse view to ordered collections. Before Java 21, accessing the first or last element of a List required awkward size-based indexing, and there was no standard way to traverse a LinkedHashSet in reverse.
// Before Java 21: awkward first/last access
List<String> list = List.of("a", "b", "c");
String first = list.get(0); // first
String last = list.get(list.size() - 1); // last — error-prone
// Java 21: SequencedCollection
SequencedCollection<String> seq = new ArrayList<>(List.of("a", "b", "c"));
String first = seq.getFirst(); // "a"
String last = seq.getLast(); // "c"
seq.addFirst("z"); // insert at front
seq.addLast("z"); // insert at back
SequencedCollection<String> rev = seq.reversed(); // reverse view (not a copy)
// SequencedMap for LinkedHashMap
SequencedMap<String, Integer> map = new LinkedHashMap<>();
map.put("first", 1);
map.put("second", 2);
Map.Entry<String, Integer> firstEntry = map.firstEntry(); // {first=1}
Map.Entry<String, Integer> lastEntry = map.lastEntry(); // {second=2}
All standard JDK ordered collection implementations (ArrayList, LinkedList, ArrayDeque, LinkedHashSet, LinkedHashMap, TreeSet, TreeMap) now implement the appropriate sequenced interface.
Structured Concurrency & Scoped Values (Preview)
Structured Concurrency (JEP 453, preview in Java 21, finalized in Java 25) treats a group of related tasks as a single unit of work. If any subtask fails, all sibling subtasks are automatically cancelled. If the parent scope exits, all tasks must have completed. This eliminates the common bug where a CompletableFuture chain spawns a task, the calling thread receives an exception and exits, but the spawned task continues running indefinitely as an orphaned thread:
// Java 21 Structured Concurrency (preview, enable with --enable-preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> userFuture = scope.fork(() -> userService.findById(userId));
Future<Account> accountFuture = scope.fork(() -> accountService.findByUser(userId));
scope.join(); // Wait for both to complete
scope.throwIfFailed(); // Propagate any exception; cancels the other task automatically
User user = userFuture.resultNow();
Account account = accountFuture.resultNow();
return new UserProfile(user, account);
}
Scoped Values (JEP 446) are an efficient replacement for ThreadLocal in virtual-thread-heavy applications. A ScopedValue binds a value to a dynamic scope — it is visible to the current thread and any threads started within the scope, but it is immutable (no set() method) and automatically unbound when the scope exits:
// Java 21 Scoped Values (preview)
static final ScopedValue<RequestContext> REQUEST_CONTEXT = ScopedValue.newInstance();
void handleRequest(Request request) {
ScopedValue.where(REQUEST_CONTEXT, new RequestContext(request.userId(), request.traceId()))
.run(() -> {
// Any code in this scope can read REQUEST_CONTEXT
orderService.processOrder(request.order()); // internally calls REQUEST_CONTEXT.get()
});
// REQUEST_CONTEXT is unbound here; no cleanup needed
}
Java 21 Migration Guide: From 11, 17, and 19/20
Migrating to Java 21 requires different effort depending on your current version. The most common enterprise starting points are Java 11 LTS (still very widely deployed) and Java 17 LTS.
From Java 17 to Java 21. This is the straightforward path. Most Java 17 code compiles and runs unmodified on Java 21. Key changes to check: sun.misc.Unsafe usages that were deprecated in 17 may now produce warnings or fail; reflection-based access to JDK internal APIs that was allowed in 17 with --add-opens flags may require updates; and some third-party libraries that used internal APIs directly (Lombok, ByteBuddy-based mocking frameworks) need version updates to their Java 21-compatible releases.
From Java 11 to Java 21. More significant. Strong encapsulation of JDK internals became default in Java 17, so libraries that relied on --illegal-access=permit will fail. The key migration steps are: update all third-party dependencies to Java 21-compatible versions (run mvn dependency:analyze and check each library's release notes); add required --add-opens flags where necessary for framework internals; test with JVM flags -XX:+EnableDynamicAgentLoading if using Java agents; and run the full test suite with --enable-preview if you want to use preview features.
# Maven: set Java 21 compiler target
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
# Gradle: set Java 21
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
# Docker: use Java 21 base image
FROM eclipse-temurin:21-jre-jammy
The most common migration issue in real projects is libraries using Thread.stop() or Thread.resume() (removed in Java 21), and libraries accessing sun.misc.Unsafe.allocateInstance(). Run jdeprscan --release 21 your-app.jar to identify usages of deprecated and removed APIs before attempting the migration.
Spring Boot 3.x and Java 21: What Changes
Spring Boot 3.x (3.0+) requires Java 17 as the minimum. Spring Boot 3.2+ has first-class Java 21 support including the spring.threads.virtual.enabled=true property that enables virtual threads for Tomcat, Jetty, and Undertow; the async task executor; and the scheduled task executor.
Spring Boot 3.2 also adds GraalVM native image improvements that are more compatible with Java 21's module system, and improved support for virtual thread monitoring via Spring Boot Actuator's /actuator/threaddump endpoint (which now shows virtual thread states).
For database access, make sure your JDBC driver and connection pool are virtual-thread-compatible. HikariCP 5.1.0+ is virtual-thread friendly — it does not use synchronized blocks for connection checkout, avoiding thread pinning. Older versions of HikariCP and some JDBC drivers use synchronized internally, which pins virtual threads and eliminates the throughput benefit. Always test with -Djdk.tracePinnedThreads=full after enabling virtual threads to identify pinning in your specific dependency set.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices