Part 15 of 15

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

ReleaseLTS?Key additions
Java 8YesLambdas, Streams, Optional, Date/Time API
Java 11Yesvar in lambda params, HTTP Client, removed Java EE/CORBA modules
Java 17YesSealed classes, records, pattern matching for instanceof, removed RMI activation
Java 21YesVirtual 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

LibraryMinimum version for Java 21
Spring Boot3.2+
Spring Framework6.1+
Hibernate6.4+
Jackson2.15+
Guava32+
Mockito5+
Byte Buddy1.14+
ASM9.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):

DistributionProviderNotes
Eclipse TemurinEclipse/AdoptiumRecommended default
Oracle JDKOracleFree for development; commercial license for production
Amazon CorrettoAWSOptimized for AWS, free
Microsoft Build of OpenJDKMicrosoftWindows/Azure optimized
Azul ZuluAzulCommercial support available
GraalVMOracleAdds 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 moduleReplacement
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.corbaNo replacement; remove or rewrite
java.transactionjakarta.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

RemovedReplacement
Thread.destroy(), Thread.stop(Throwable)Cooperative cancellation with Thread.interrupt()
Runtime.runFinalizersOnExit()Remove; finalizers are deprecated
sun.misc.BASE64Encoder/Decoderjava.util.Base64
com.sun.net.ssl.*javax.net.ssl.*

APIs Removed in Java 17

RemovedReplacement
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:

GCUse case
G1GCGeneral purpose, default since Java 9
ZGCLow-latency requirements (<10ms pause targets)
Generational ZGCUltra-low latency, Java 21+
ShenandoahLow latency, available in some distributions
Serial/ParallelBatch 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:

  1. Start with leaf modules (no internal dependents)
  2. Compile each module with Java 21, fix issues, commit
  3. Run the module’s tests under Java 21
  4. 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

StepAction
1Audit dependencies with jdeps and jdeprscan
2Install Java 21 JDK (Temurin recommended)
3Update Maven/Gradle compiler settings
4Fix JPMS --add-opens and removed module issues
5Replace removed APIs (Thread.stop, JAXB, PermGen flags)
6Update GC flags; consider Generational ZGC
7Migrate thread pools to virtual threads
8Adopt pattern matching and records where appropriate
9Run test suite; fix failures
10Validate 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.