Migrating to Java 21: From Java 8, 11, and 17 — Step by Step
Why Migrate Now?
Java 21 is the current Long-Term Support (LTS) release, and it is the most feature-rich LTS since Java 8. LTS releases receive security patches and bug fixes for years. Java 11, the previous widely-used LTS, reached its extended support window end depending on your vendor. Java 8 mainstream support ended in 2019.
More concretely, Java 21 brings:
- Virtual Threads — drop-in replacement for platform threads, enabling massive concurrency without reactive rewrites
- Pattern Matching for switch and records — eliminating entire categories of verbose, error-prone instanceof/cast chains
- Sequenced Collections — a unified API for ordered collection types
- Generational ZGC — sub-millisecond GC pauses at any heap size
These are not incremental improvements. They change how you write concurrent code and handle complex data shapes. The sooner your codebase runs on Java 21, the sooner you can use them.
Overview: LTS Versions and What Changed
| Release | LTS? | Key additions |
|---|---|---|
| Java 8 | Yes | Lambdas, Streams, Optional, Date/Time API |
| Java 11 | Yes | var in lambda params, HTTP Client, removed Java EE/CORBA modules |
| Java 17 | Yes | Sealed classes, records, pattern matching for instanceof, removed RMI activation |
| Java 21 | Yes | Virtual Threads, pattern matching for switch, record patterns, Sequenced Collections, Generational ZGC |
Each LTS-to-LTS jump removed APIs and changed default behaviors. The jump from Java 8 to Java 21 crosses all of them.
Migration Path
Recommended upgrade path:
Java 8 → Java 11 → Java 17 → Java 21
Do not try to jump directly from Java 8 to Java 21 in a single step. Each intermediate LTS resolves a subset of compatibility issues in isolation, making debugging easier. If you are already on Java 11 or 17, migrate to the next LTS step, validate, then continue.
Exception: if your codebase has comprehensive test coverage and few external dependencies, a direct jump to Java 21 is feasible and sometimes faster. Assess per project.
Step 1: Audit Dependencies
Before touching your JDK, check whether your dependencies support Java 21.
# Maven: check for issues
mvn dependency:tree -Dincludes=:* | grep -i "illegal\|unsupported\|warning"
# Gradle: dependency insight
./gradlew dependencies --configuration compileClasspath
Tools That Help
jdeps — ships with the JDK, shows module dependencies and illegal accesses:
jdeps --multi-release 21 \
--ignore-missing-deps \
--print-module-deps \
target/myapp.jar
jdeprscan — finds uses of deprecated APIs scheduled for removal:
jdeprscan --release 21 target/myapp.jar
Eclipse Migration Toolkit and OpenRewrite can apply automated refactoring for common Java 8–17 patterns.
Critical Libraries to Check
| Library | Minimum version for Java 21 |
|---|---|
| Spring Boot | 3.2+ |
| Spring Framework | 6.1+ |
| Hibernate | 6.4+ |
| Jackson | 2.15+ |
| Guava | 32+ |
| Mockito | 5+ |
| Byte Buddy | 1.14+ |
| ASM | 9.5+ |
| ByteCode manipulation libs (cglib, javassist) | See note |
Libraries that do bytecode manipulation (cglib, Javassist, ByteBuddy, ASM) must support Java 21’s class file version (65). Older versions will fail with UnsupportedClassVersionError or IllegalAccessError.
Step 2: Install the Java 21 JDK
JDK Distributions
Multiple vendors offer Java 21 builds. All pass the TCK (Technology Compatibility Kit):
| Distribution | Provider | Notes |
|---|---|---|
| Eclipse Temurin | Eclipse/Adoptium | Recommended default |
| Oracle JDK | Oracle | Free for development; commercial license for production |
| Amazon Corretto | AWS | Optimized for AWS, free |
| Microsoft Build of OpenJDK | Microsoft | Windows/Azure optimized |
| Azul Zulu | Azul | Commercial support available |
| GraalVM | Oracle | Adds native-image, polyglot |
Use SDKMAN to manage multiple JDK versions:
sdk install java 21.0.3-tem # Eclipse Temurin 21
sdk use java 21.0.3-tem # Switch for current shell
sdk default java 21.0.3-tem # Set as default
Verify Installation
java -version
# openjdk version "21.0.3" 2024-04-16
# OpenJDK Runtime Environment Temurin-21.0.3+9 (build 21.0.3+9)
# OpenJDK 64-Bit Server VM Temurin-21.0.3+9 (build 21.0.3+9, mixed mode, sharing)
Step 3: Update Build Tool Configuration
Maven
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<!-- Or use release flag (preferred — prevents cross-compilation issues) -->
<maven.compiler.release>21</maven.compiler.release>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version>
<configuration>
<release>21</release>
<!-- For preview features: -->
<!-- <compilerArgs><arg>--enable-preview</arg></compilerArgs> -->
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<!-- For preview features in tests: -->
<!-- <configuration><argLine>--enable-preview</argLine></configuration> -->
</plugin>
</plugins>
</build>
Gradle
// build.gradle.kts
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile> {
options.release = 21
// For preview features:
// options.compilerArgs.add("--enable-preview")
}
tasks.withType<Test> {
jvmArgs("--enable-preview") // only if using preview features
}
Step 4: Handle the Module System (JPMS)
The Java Platform Module System (introduced in Java 9) is the most common source of migration pain. Even if you do not use modules yourself, your code may access internal JDK APIs that are now encapsulated.
Illegal Reflective Access
In Java 8, libraries could use setAccessible(true) to access private fields and methods in JDK classes. Java 9–16 warned about this. Java 17+ enforces strong encapsulation by default — illegal access throws InaccessibleObjectException.
Symptom:
java.lang.reflect.InaccessibleObjectException: Unable to make field private final
java.util.HashMap java.util.Collections$UnmodifiableMap.m accessible:
module java.base does not "opens java.util" to unnamed module
Immediate fix (buys time, not a solution):
java --add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
MyApp
Root fix: upgrade the offending library to a version that does not require illegal reflective access.
Add --add-opens to your JVM launch arguments in the short term:
<!-- Maven Surefire -->
<configuration>
<argLine>
--add-opens java.base/java.util=ALL-UNNAMED
--add-opens java.base/java.lang=ALL-UNNAMED
</argLine>
</configuration>
// Gradle
tasks.withType<Test> {
jvmArgs(
"--add-opens", "java.base/java.util=ALL-UNNAMED",
"--add-opens", "java.base/java.lang=ALL-UNNAMED"
)
}
Track each --add-opens as a technical debt item to eliminate via library upgrades.
Removed Modules
Java 11 removed the Java EE and CORBA modules that were deprecated in Java 9:
| Removed module | Replacement |
|---|---|
java.xml.bind (JAXB) | jakarta.xml.bind:jakarta.xml.bind-api + org.glassfish.jaxb:jaxb-runtime |
java.xml.ws (JAX-WS) | jakarta.xml.ws:jakarta.xml.ws-api |
java.activation (JAF) | jakarta.activation:jakarta.activation-api |
java.corba | No replacement; remove or rewrite |
java.transaction | jakarta.transaction:jakarta.transaction-api |
Maven dependency for JAXB:
<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>4.0.4</version>
<scope>runtime</scope>
</dependency>
Step 5: Fix Removed and Changed APIs
APIs Removed in Java 11
| Removed | Replacement |
|---|---|
Thread.destroy(), Thread.stop(Throwable) | Cooperative cancellation with Thread.interrupt() |
Runtime.runFinalizersOnExit() | Remove; finalizers are deprecated |
sun.misc.BASE64Encoder/Decoder | java.util.Base64 |
com.sun.net.ssl.* | javax.net.ssl.* |
APIs Removed in Java 17
| Removed | Replacement |
|---|---|
RMI Activation (java.rmi.activation) | gRPC, REST, or modern RPC |
Applet API (java.applet.Applet) | Remove entirely |
SecurityManager (deprecated for removal) | OS-level sandboxing |
APIs Removed or Changed in Java 21
Thread.stop(), Thread.suspend(), Thread.resume() — Removed. These were already deprecated since Java 1.2 and threw UnsupportedOperationException since Java 20.
// Before (Java 8–19)
thread.stop(); // throws UnsupportedOperationException since 20, removed in 21
// After
thread.interrupt(); // signal; check Thread.interrupted() in the thread
Finalize deprecation: Object.finalize() is deprecated for removal. Replace with Cleaner or try-with-resources.
// Before: unreliable, causes GC overhead
class OldResource {
@Override protected void finalize() { close(); }
}
// After: deterministic cleanup
class NewResource implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
NewResource() {
this.cleanable = CLEANER.register(this, this::close);
}
@Override public void close() { ... }
}
Step 6: Address GC Changes
If You Are Using CMS (Concurrent Mark Sweep)
CMS was removed in Java 14. If your JVM flags include -XX:+UseConcMarkSweepGC, remove them. Choose a replacement:
| GC | Use case |
|---|---|
| G1GC | General purpose, default since Java 9 |
| ZGC | Low-latency requirements (<10ms pause targets) |
| Generational ZGC | Ultra-low latency, Java 21+ |
| Shenandoah | Low latency, available in some distributions |
| Serial/Parallel | Batch processing, throughput-critical with no latency requirements |
For most applications migrating from CMS, G1GC (already the default) is the right first choice. Profile under production load before switching to ZGC.
To enable Generational ZGC in Java 21:
java -XX:+UseZGC -XX:+ZGenerational ...
GC Flag Changes
Some GC flags changed between Java 8 and 21. Common flags to review:
# Check for deprecated/removed flags
java -XX:+PrintFlagsFinal -version 2>&1 | grep -i "deprecated\|removed"
# Flags removed since Java 8:
# -XX:+UseConcMarkSweepGC → use G1GC or ZGC
# -XX:+UseParNewGC → removed (use G1GC)
# -XX:+CMSIncrementalMode → removed
# -XX:MaxPermSize → removed (PermGen → Metaspace)
# -XX:PermSize → removed
PermGen is gone. In Java 8+, replace -XX:MaxPermSize with -XX:MaxMetaspaceSize:
# Before (Java 7 and earlier)
-XX:MaxPermSize=256m
# After (Java 8+)
-XX:MaxMetaspaceSize=256m
Step 7: Update Concurrent Code for Virtual Threads
This step is optional for a baseline migration but represents the largest long-term benefit.
Thread Pool Migration
You do not need to rewrite everything. Virtual Threads work as a drop-in replacement in most cases.
Fixed thread pool → virtual thread executor:
// Before
ExecutorService executor = Executors.newFixedThreadPool(200);
// After: one virtual thread per task, no pool needed
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Spring Boot 3.2+ enables virtual threads globally:
# application.yml
spring:
threads:
virtual:
enabled: true
This switches Tomcat and Spring’s async executor to virtual threads with one property change.
Thread.sleep() Is Now Safe Inside Transactions
With virtual threads, Thread.sleep() in a hot path no longer blocks a platform thread. This matters for:
- Rate limiting with
Thread.sleep() - Polling loops in service clients
- Retry logic with
Thread.sleep(backoff)
These patterns, previously acceptable only with async code, now work in plain synchronous virtual-thread code.
Pinning Issues
Virtual threads pin to their carrier platform thread when inside synchronized blocks or native calls. Pinning limits concurrency but does not cause deadlocks.
Check for pinning at runtime:
java -XX:+PrintVirtualThreads -Djdk.tracePinnedThreads=full MyApp
Fix pinning: replace synchronized with ReentrantLock:
// Before (pins virtual thread)
synchronized (lock) { ... }
// After (releases carrier while waiting)
ReentrantLock lock = new ReentrantLock();
lock.lock();
try { ... } finally { lock.unlock(); }
Step 8: Adopt New Language Features (Optional but Valuable)
You do not need to use Java 21 features immediately. But here are the most impactful, worth prioritizing:
Replace instanceof + Cast
// Before
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.radius() * c.radius();
}
// After
if (shape instanceof Circle c) {
return Math.PI * c.radius() * c.radius();
}
Replace Chained if-instanceof with Pattern Switch
// Before: Java 8 style
double area(Shape shape) {
if (shape instanceof Circle) {
return Math.PI * ((Circle)shape).radius() * ((Circle)shape).radius();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.width() * r.height();
} else if (shape instanceof Triangle) {
Triangle t = (Triangle) shape;
return 0.5 * t.base() * t.height();
}
throw new IllegalArgumentException("Unknown shape: " + shape);
}
// After: Java 21 pattern switch
double area(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();
};
}
Use Records for Data Carriers
// Before
public class Point {
private final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
@Override public boolean equals(Object o) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { ... }
}
// After
public record Point(int x, int y) {}
Step 9: Run Your Test Suite and Fix Issues
With the JDK upgraded and dependencies updated:
mvn clean test
# or
./gradlew test
Common Failures and Fixes
UnsupportedClassVersionError: a dependency was compiled for Java 22+. Downgrade that dependency or upgrade to a compatible version.
InaccessibleObjectException: illegal reflective access. Add --add-opens temporarily; upgrade library long-term.
NoSuchMethodError / NoSuchFieldError: a dependency calls a removed API. Upgrade the dependency.
ClassNotFoundException: likely a removed JDK module (JAXB, JAF). Add the Maven/Gradle dependency.
Test isolation failures: if Mockito or PowerMock versions are old, they may fail to instrument Java 21 classes. Upgrade to Mockito 5+.
Step 10: Performance Validation
Run a comparison load test before and after the migration:
# Before (Java 17)
java -jar app.jar &
wrk -t4 -c400 -d30s http://localhost:8080/api/endpoint
# After (Java 21 + virtual threads)
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar &
wrk -t4 -c400 -d30s http://localhost:8080/api/endpoint
Monitor:
- Throughput (RPS): virtual threads should improve this for I/O-bound workloads
- Latency (P99/P999): Generational ZGC should reduce GC-induced latency spikes
- Heap / GC overhead: run with
-Xlog:gc*and compare GC log statistics
Migration Checklist
[ ] Audit all dependencies for Java 21 compatibility
[ ] Upgrade Spring Boot to 3.2+, Hibernate to 6.4+, Mockito to 5+
[ ] Replace jdeps --add-opens flags with library upgrades where possible
[ ] Replace JAXB/JAF/JAX-WS with Jakarta EE equivalents
[ ] Remove -XX:MaxPermSize; add -XX:MaxMetaspaceSize if needed
[ ] Remove -XX:+UseConcMarkSweepGC; switch to G1GC or ZGC
[ ] Remove Thread.stop() calls; use Thread.interrupt()
[ ] Replace finalizers with Cleaner or AutoCloseable
[ ] Enable virtual threads (Executors.newVirtualThreadPerTaskExecutor or Spring property)
[ ] Run full test suite; fix failures
[ ] Run load test and compare throughput/latency metrics
[ ] Enable Generational ZGC in staging and production
Incremental Migration for Large Codebases
For a multi-module Maven/Gradle project, migrate one module at a time:
- Start with leaf modules (no internal dependents)
- Compile each module with Java 21, fix issues, commit
- Run the module’s tests under Java 21
- Move to the next module
This isolates failures and allows partial rollouts — you can ship some services on Java 21 while others remain on Java 17.
Summary
| Step | Action |
|---|---|
| 1 | Audit dependencies with jdeps and jdeprscan |
| 2 | Install Java 21 JDK (Temurin recommended) |
| 3 | Update Maven/Gradle compiler settings |
| 4 | Fix JPMS --add-opens and removed module issues |
| 5 | Replace removed APIs (Thread.stop, JAXB, PermGen flags) |
| 6 | Update GC flags; consider Generational ZGC |
| 7 | Migrate thread pools to virtual threads |
| 8 | Adopt pattern matching and records where appropriate |
| 9 | Run test suite; fix failures |
| 10 | Validate performance under load |
What’s Next
Article 16: Java 21 Production Checklist and Performance Best Practices covers what to configure, monitor, and tune once your application runs on Java 21 in production — GC settings, virtual thread limits, JVM flags, observability, and more.