Java 21 (released September 2023) is a Long-Term Support release and the most significant Java version since Java 8. It finalises Virtual Threads, finalises pattern matching for switch — which lets that matching exploit the sealed hierarchies you could already declare in Java 17 — adds sequenced collections, and delivers record patterns: all features that fundamentally change how you write concurrent and domain-modelling code. If your team is still on Java 11 or 17, this guide gives you a practical migration path with before/after code examples and the gotchas that will catch you out.
Should you migrate to Java 21?
Java 21 is an LTS release with Oracle Extended Support until September 2031. Java 11's Premier Support ended in September 2023 — Extended Support, which you pay for, runs until January 2032 — and Java 17's Oracle Premier Support runs until September 2026. So Java 11 is not the cliff edge it is often described as: you are off free updates, not off support. It is still worth planning the move. If you are on Java 17, you have plenty of time, but Java 21's virtual threads alone justify the upgrade for any I/O-heavy service.
// Current LTS status (as of 2026)
Java 11 — Oracle Premier ended Sept 2023; Extended (paid) to Jan 2032
Java 17 — LTS. Oracle Premier to Sept 2026, Oracle Extended to Sept 2029
Java 21 — LTS, support until 2031. Current recommended baseline.
Java 25 — LTS, releasing September 2025. Follow after stabilisation.Step 1 — Update your build
<!-- Maven pom.xml -->
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<!-- Spring Boot parent — use 3.2+ for full Java 21 support -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>// Gradle build.gradle.kts
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
// If you use toolchains (recommended):
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}Virtual Threads (Project Loom) — the biggest change
Virtual threads are lightweight threads managed by the JVM rather than the OS. A single OS thread can multiplex thousands of virtual threads, making blocking I/O as scalable as reactive programming — without the complexity of reactive code. For Spring Boot web applications and SQS consumers, this is a game-changer.
// Before Java 21 — traditional thread pool (default Tomcat)
// Tomcat defaults to 200 threads, which caps how many requests are
// processed concurrently — it is not a hard limit on connections.
// Requests arriving beyond that wait in the accept queue (acceptCount)
// until a worker frees up, rather than being rejected outright.
// Each thread blocks while waiting for DB / HTTP / SQS responses.On Java 21 with Spring Boot 3.2 or later, one property switches the whole thing over:
# application.yml
spring:
threads:
virtual:
enabled: trueThat single property switches Tomcat to use virtual threads for every request. You can now handle thousands of concurrent blocking requests with a fraction of the memory overhead.
For non-web workloads, you can use virtual threads explicitly:
// Create virtual threads directly
Thread vThread = Thread.ofVirtual().start(() -> {
// blocking I/O here is fine — JVM unmounts the virtual thread
// from the carrier thread while waiting
String result = httpClient.get(url);
process(result);
});
// ExecutorService backed by virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> fetch(url)))
.toList();
// All fetches run concurrently with minimal memory overhead
}Virtual thread gotchas
- Do not block inside a
synchronizedblock. A virtual thread cannot unmount while it is insidesynchronized, so if it blocks in there — a database call, an HTTP call — it holds its carrier OS thread for the entire wait and other virtual threads queue behind it. Note the precise shape of this: an uncontendedsynchronizedblock that does no blocking costs you nothing, so there is no reason to rewrite every critical section in the codebase. Swap inReentrantLockwhere you block while holding the lock. This is a Java 21 problem specifically — JEP 491 in JDK 24 lets virtual threads unmount insidesynchronized, so the hazard disappears on JDK 24 and later, including the Java 25 LTS. - Thread locals scale differently. Thread-local variables work on virtual threads, but if you create millions of virtual threads, thread-local memory adds up. Java 21 ships
ScopedValue(JEP 446) as the intended replacement, but it is a preview API — it will not compile without--enable-preview --release 21, it needs--enable-previewat run time as well, and the API may still change. Treat it as something to experiment with, not something to ship on Java 21. - CPU-bound work doesn't benefit. Virtual threads help with blocking I/O. CPU-intensive workloads should still use a fixed-size thread pool sized to available processors.
// Replace synchronized with ReentrantLock where you block while holding it
public class Counter {
private final Object monitor = new Object();
private final ReentrantLock lock = new ReentrantLock();
private int counter;
// Before — pins the carrier thread if anything in here blocks
void incrementSynchronized() {
synchronized (monitor) {
counter++;
}
}
// After (virtual-thread friendly)
void incrementLocked() {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
}Records — replace POJOs and DTOs
Records (finalized in Java 16) eliminate boilerplate for immutable data carriers. They auto-generate constructor, getters, equals(), hashCode(), and toString().
// Before — traditional DTO (50+ lines with Lombok)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrderEvent {
private String orderId;
private String customerId;
private BigDecimal total;
private String status;
}
// After — record (1 line)
public record OrderEvent(String orderId, String customerId, BigDecimal total, String status) {}
// Usage is identical
OrderEvent event = new OrderEvent("ord-123", "cust-456", new BigDecimal("99.99"), "PENDING");
System.out.println(event.orderId()); // ord-123
System.out.println(event); // OrderEvent[orderId=ord-123, ...]
// Records work with Jackson (add @JsonProperty if needed)
// Records work with JPA projections (interface-based projections preferred for JPA entities)
// Records work with Spring @RequestBody and @ResponseBodyRecords are ideal for: DTOs, API request/response bodies, SQS message payloads, value objects, and configuration properties. They are not suitable for JPA entities (which need mutability and a no-arg constructor).
Sealed Classes — model closed hierarchies
Sealed classes restrict which classes can extend them, making your type hierarchy exhaustive and enabling the compiler to enforce completeness in switch expressions.
// Define a sealed hierarchy for payment methods
public sealed interface PaymentMethod
permits CreditCard, BankTransfer, UpiPayment {}
public record CreditCard(String last4, String network) implements PaymentMethod {}
public record BankTransfer(String ifscCode, String accountNumber) implements PaymentMethod {}
public record UpiPayment(String upiId) implements PaymentMethod {}
// Pattern matching switch — compiler forces you to handle all cases
public String processPayment(PaymentMethod method) {
return switch (method) {
case CreditCard cc -> chargeCard(cc.last4(), cc.network());
case BankTransfer bt -> initiateBankTransfer(bt.ifscCode(), bt.accountNumber());
case UpiPayment upi -> sendUpiRequest(upi.upiId());
// No default needed — sealed class guarantees exhaustiveness
// If you add a new PaymentMethod, the compiler will flag this switch
};
}Pattern Matching for switch
Java 21 finalises pattern matching for switch expressions, allowing you to match on type, deconstruct records, and add guard conditions (when clauses):
// Before Java 21 — instanceof chains
Object obj = getResponse();
if (obj instanceof String s) {
return s.toUpperCase();
} else if (obj instanceof Integer i) {
return String.valueOf(i * 2);
} else if (obj instanceof List<?> list) {
return "List of " + list.size();
} else {
return "unknown";
}
// Java 21 — pattern matching switch
String result = switch (getResponse()) {
case String s -> s.toUpperCase();
case Integer i when i > 0 -> String.valueOf(i * 2); // guard condition
case Integer i -> "negative: " + i;
case List<?> list when list.isEmpty() -> "empty list";
case List<?> list -> "List of " + list.size();
case null -> "null response";
default -> "unknown";
};Record Patterns — deconstruct in instanceof
public record Point(int x, int y) {}
public record Circle(Point centre, double radius) {}
// Before
if (shape instanceof Circle c) {
Point p = c.centre();
System.out.println("Centre: " + p.x() + ", " + p.y());
}
// Java 21 — record pattern (deconstruct directly)
if (shape instanceof Circle(Point(int x, int y), double r)) {
System.out.println("Centre: " + x + ", " + y + " radius: " + r);
}Sequenced Collections
Java 21 adds SequencedCollection, SequencedSet, and SequencedMap interfaces, giving a unified API for accessing the first and last elements of ordered collections — something that previously required different idioms for List, Deque, and LinkedHashSet.
List<String> names = List.of("Alice", "Bob", "Charlie");
// Before Java 21
String first = names.get(0);
String last = names.get(names.size() - 1);
List<String> reversed = new ArrayList<>(names);
Collections.reverse(reversed);
// Java 21 — SequencedCollection methods
String firstItem = names.getFirst(); // "Alice"
String lastItem = names.getLast(); // "Charlie"
List<String> rev = names.reversed(); // ["Charlie", "Bob", "Alice"]
// Works on LinkedHashMap too
LinkedHashMap<String, Integer> scores = new LinkedHashMap<>();
scores.put("Alice", 95); scores.put("Bob", 87);
Map.Entry<String, Integer> firstEntry = scores.firstEntry(); // Alice=95
Map.Entry<String, Integer> lastEntry = scores.lastEntry(); // Bob=87Removed and deprecated APIs to watch out for
- Security Manager deprecated for removal (JEP 411, JDK 17): It is worth being precise here, because this one is widely misreported. JEP 411 is "Deprecate the Security Manager for Removal" and it shipped in JDK 17, not 21, and the Security Manager is not actually gone in 21 — the class is still there and code referencing it still compiles, with removal warnings. What changed is the default: if your application or a library installs a
SecurityManager, it will throwUnsupportedOperationExceptionon Java 21 unless you start the JVM with-Djava.security.manager=allow. Check dependencies, especially older JDBC drivers and legacy security frameworks. Treat that flag as a stay of execution rather than a fix: JEP 486 in JDK 24 permanently disables the Security Manager. - Finalization deprecated (JEP 421): If you use
finalize()methods, migrate toCleaneror try-with-resources. Finalization will be removed in a future version. - Thread.stop(), suspend(), resume() fail at run time: These have been deprecated since Java 1.2 — the JDK annotates them
@Deprecated(since="1.2", forRemoval=true)— but they are not removed. All three are still declared onThreadin Java 21, and code calling them still compiles: you get a removal warning, not an error. They throwUnsupportedOperationExceptionwhen invoked. That is the awkward part of this one — a removed method would fail your build, whereas these fail in production. Grep for them rather than trusting the compiler. Use interruption andExecutorServiceshutdown instead. - sun.misc.Unsafe changes: Several
Unsafemethods are deprecated for removal. Libraries like older versions of Netty, Kryo, and some ORMs use them. Update dependencies before migrating.
Spring Boot compatibility matrix
Spring Boot Version │ Java 21 │ Virtual Threads │ Notes
────────────────────┼─────────┼─────────────────┼────────────────────────────
2.7.x │ ✗ │ ✗ │ Max Java 17, EOL Nov 2023
3.0.x │ ✓ │ No auto-config │ EOL Feb 2024
3.1.x │ ✓ │ No auto-config │ EOL Nov 2024
3.2.x │ ✓ │ ✓ (GA) │ LTS-aligned, recommended
3.3.x │ ✓ │ ✓ (GA) │ Current stable (2024)
3.4.x │ ✓ │ ✓ (GA) │ Supports Java 23+For virtual thread support with spring.threads.virtual.enabled=true, use Spring Boot 3.2 or later. Read that column carefully: virtual threads are final in Java 21 (JEP 444 — no "(Preview)" in the title), so what 3.0 and 3.1 are missing is Spring's auto-configuration, not the JDK feature. On those versions you can still use virtual threads by hand —Executors.newVirtualThreadPerTaskExecutor() works on any Java 21 JVM with no flags. The preview status people remember belongs to Java 19 and 20, not to 21.
Migration checklist
- Update JDK in your Docker base image and CI pipeline to Java 21
- Update
pom.xml/build.gradlecompiler source/target to 21 - Upgrade Spring Boot to 3.2+ (requires Jakarta EE 10 — rename
javax.*imports tojakarta.*) - Run
mvn dependency:treeand check for libraries using removed APIs (SecurityManager, Unsafe) - Enable virtual threads with
spring.threads.virtual.enabled=true - Replace
synchronizedblocks that perform blocking I/O withReentrantLock - Convert DTOs and value objects to records
- Replace
instanceofchains with pattern matching switch - Run your full test suite — pay attention to concurrency tests
- Load test your service and compare thread count, memory, and latency
Frequently Asked Questions
Can I use virtual threads with Spring WebFlux / reactive stack?
You can, but it's not the intended use case. Virtual threads are designed to make blocking (imperative) code as scalable as reactive code. If you are already using WebFlux, you get the same scalability without the virtual thread overhead. The main benefit of virtual threads is letting you write simpler blocking code without sacrificing throughput. Many teams are migrating away from WebFlux back to Spring MVC + virtual threads for exactly this reason.
Do records work with Spring Data JPA?
Records cannot be JPA entities because JPA requires a no-arg constructor and mutable state. However, records work perfectly as JPA projections (for read-only query results) and as Spring Data repository interface-based projections. For your domain entities, keep using classes. For DTOs and API responses derived from entity queries, records are ideal.
What is the javax to jakarta package rename?
Spring Boot 3.x moves to Jakarta EE 10, which renamed all javax.* packages to jakarta.*. This affects Servlet (javax.servlet →jakarta.servlet), JPA (javax.persistence →jakarta.persistence), Bean Validation, JAX-RS, and others. It's a global find-and-replace in your codebase. Your IDE can do it automatically. Third-party libraries must also support Jakarta EE 10 — check each library's release notes.
Are Lombok and MapStruct compatible with Java 21?
Yes, as of Lombok 1.18.30+ and MapStruct 1.5.5+, both are fully compatible with Java 21. However, with records you may find that Lombok's @Value and @Data can often be replaced with plain records, reducing your Lombok dependency. MapStruct supports mapping between records and regular classes without special configuration.
How much performance improvement should I expect from virtual threads?
There is no honest multiple to quote here, and you should be suspicious of articles that give you one. The gain depends entirely on how thread-bound your service was to begin with. If your ceiling was "every one of the 200 Tomcat threads is parked waiting on I/O", virtual threads lift that ceiling and the improvement can be large. If your database is already saturated, they will not help at all — you will simply reach the same bottleneck sooner. What you can predict is the thread count: it drops from thousands to tens, with the floor set by availableProcessors(), since carrier threads default to one per core. CPU-bound services see minimal improvement. Measure your own service under its own load rather than budgeting for someone else's number.