Java 21 Migration Guide (17→21)

1. Introduction

Java 21 is the next Long-Term Support (LTS) release after Java 17, released in September 2023. If your organization runs on Java 17 — which most modern Java shops do — Java 21 is the natural next upgrade target. It is not a “maybe someday” upgrade; it is a “plan this for your next quarter” upgrade, because the features it brings are transformative.

Between Java 17 and Java 21, four major versions shipped (18, 19, 20, 21), each adding language features, API improvements, and runtime enhancements. Some of the biggest changes include virtual threads (which fundamentally change concurrency), pattern matching (which modernizes how you write conditional logic), record patterns (which make destructuring first-class), and sequenced collections (which fix a 25-year API gap).

LTS Support Timelines

Version Release Date Type Oracle Premier Support Until Extended Support Until
Java 17 September 2021 LTS September 2026 September 2029
Java 18 March 2022 Non-LTS September 2022 N/A
Java 19 September 2022 Non-LTS March 2023 N/A
Java 20 March 2023 Non-LTS September 2023 N/A
Java 21 September 2023 LTS September 2028 September 2031

Why migrate now? Java 17 premier support ends in September 2026. Planning your migration to Java 21 now gives you ample time to test, fix issues, and deploy before you are running on an end-of-life JDK. Additionally, frameworks like Spring Boot 3.2+ and Quarkus 3.x are optimized for Java 21 and unlock features like virtual thread support only on 21+.

2. New Language Features Summary (Java 18-21)

Here is a comprehensive table of every significant feature added between Java 18 and Java 21. Features marked Final are production-ready. Features marked Preview require --enable-preview to use.

Feature JEP Introduced Finalized Status in Java 21
Virtual Threads 444 Java 19 (preview) Java 21 Final
Pattern Matching for switch 441 Java 17 (preview) Java 21 Final
Record Patterns 440 Java 19 (preview) Java 21 Final
Sequenced Collections 431 Java 21 Java 21 Final
String Templates 430 Java 21 Preview
Unnamed Patterns and Variables 443 Java 21 Preview
Unnamed Classes and Instance Main Methods 445 Java 21 Preview
Structured Concurrency 462 Java 19 (incubator) Preview
Scoped Values 464 Java 20 (incubator) Preview
Foreign Function & Memory API 442 Java 14 (incubator) Third Preview
Vector API 448 Java 16 (incubator) Sixth Incubator
Generational ZGC 439 Java 21 Java 21 Final
Key Encapsulation Mechanism API 452 Java 21 Java 21 Final
Deprecate the Windows 32-bit x86 Port 449 Java 21 Java 21 Final
Prepare to Disallow Dynamic Loading of Agents 451 Java 21 Java 21 Final
UTF-8 by Default 400 Java 18 Java 18 Final
Simple Web Server 408 Java 18 Java 18 Final
Code Snippets in Java API Documentation 413 Java 18 Java 18 Final

3. Breaking Changes

Most Java version upgrades are backward-compatible, but there are changes between 17 and 21 that can break existing code or change behavior. Review these carefully before migrating.

UTF-8 by Default (JEP 400 — Java 18)

This is the most impactful breaking change. Before Java 18, the default charset was platform-dependent — on Windows it was typically Windows-1252, on macOS/Linux it was usually UTF-8. Starting with Java 18, the default is always UTF-8 regardless of platform.

What breaks: Code that reads or writes files without specifying a charset and relies on the platform default. On Windows servers that previously used Windows-1252, files will now be read as UTF-8, potentially corrupting non-ASCII characters.

// This code behaves DIFFERENTLY on Java 21 vs Java 17 (on Windows)
// Before Java 18: uses platform default (e.g., Windows-1252)
// Java 18+: uses UTF-8
String content = Files.readString(Path.of("data.txt")); // Now always UTF-8

// FIX: Be explicit about charset (you should have been doing this all along)
String content = Files.readString(Path.of("data.txt"), StandardCharsets.UTF_8);

// Or, if you truly need the old behavior:
String content = Files.readString(Path.of("data.txt"), Charset.forName("windows-1252"));

Finalization Deprecation (JEP 421 — Java 18)

The finalize() method is deprecated for removal. If your code overrides finalize() in any class, you will get deprecation warnings, and the feature will eventually be removed entirely.

// BAD: This pattern is deprecated for removal
public class ResourceHolder {
    @Override
    protected void finalize() throws Throwable {
        // cleanup resources
        super.finalize();
    }
}

// GOOD: Use try-with-resources and AutoCloseable instead
public class ResourceHolder implements AutoCloseable {
    @Override
    public void close() {
        // cleanup resources
    }
}

// Or use Cleaner for cases where try-with-resources is not practical
public class ResourceHolder {
    private static final Cleaner cleaner = Cleaner.create();

    ResourceHolder() {
        cleaner.register(this, () -> {
            // cleanup action
        });
    }
}

Dynamic Agent Loading Warning (JEP 451 — Java 21)

Java 21 issues warnings when agents are loaded dynamically into a running JVM (via the Attach API). This affects tools like Byte Buddy, Mockito (in certain test scenarios), application performance monitors (APMs), and profilers. In a future Java release, dynamic loading will be disallowed by default.

// WARNING: A Java agent has been loaded dynamically...
// WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading

// FIX: Add this JVM flag to suppress the warning (for now)
// java -XX:+EnableDynamicAgentLoading -jar myapp.jar

// Or, better: load agents at startup instead of dynamically
// java -javaagent:myagent.jar -jar myapp.jar

Pattern Matching Refinements

If you used pattern matching for switch while it was in preview (Java 17-20), the syntax has changed in the final version. Guarded patterns now use when instead of &&.

// PREVIEW syntax (Java 17-20) -- NO LONGER VALID
String result = switch (obj) {
    case String s && s.length() > 5 -> "long string";  // OLD syntax
    default -> "other";
};

// FINAL syntax (Java 21) -- USE THIS
String result = switch (obj) {
    case String s when s.length() > 5 -> "long string"; // NEW: 'when' keyword
    default -> "other";
};

Other Breaking Changes

Change Version Impact Fix
Thread.stop() throws UnsupportedOperationException Java 20 Code calling Thread.stop() will crash Use Thread.interrupt() instead
Locale data source changed to CLDR Java 18 Date/number formatting may differ slightly Test locale-sensitive output
Removed legacy SecurityManager features Java 18-21 Applications using SecurityManager see warnings Migrate away from SecurityManager
sun.misc.Unsafe memory access methods deprecated Java 21 Libraries using Unsafe directly will get warnings Use VarHandle or Foreign Memory API

4. Step-by-Step Migration Checklist

Follow these 10 steps to migrate from Java 17 to Java 21 with minimum risk.

Step 1: Upgrade Your JDK

Download and install Java 21 from one of these distributions:

Distribution License Best For
Oracle JDK 21 Oracle NFTC (free for production) Commercial support available
Eclipse Temurin (Adoptium) 21 GPLv2 + Classpath Exception Community, open-source projects
Amazon Corretto 21 GPLv2 + Classpath Exception AWS deployments
Azul Zulu 21 GPLv2 + Classpath Exception Enterprise support option
GraalVM for JDK 21 GraalVM Free Terms Native image, polyglot

Step 2: Update JAVA_HOME and PATH

# Linux/macOS
export JAVA_HOME=/path/to/jdk-21
export PATH=$JAVA_HOME/bin:$PATH

# Verify
java -version
# openjdk version "21.0.2" 2024-01-16 LTS

# Windows (PowerShell)
$env:JAVA_HOME = "C:\Program Files\Java\jdk-21"
$env:Path = "$env:JAVA_HOME\bin;$env:Path"

Step 3: Compile Your Project

Try compiling your project with Java 21. Do not change any code yet — just compile and note the warnings and errors.

# Maven
mvn clean compile -DskipTests

# Gradle
./gradlew compileJava

# Note ALL warnings -- especially:
# - Deprecation warnings (finalize, SecurityManager, etc.)
# - Access warnings (illegal reflective access)
# - Preview feature usage from Java 17

Step 4: Run Your Test Suite

Run all tests. Pay special attention to:

  • Character encoding tests — UTF-8 default may change behavior
  • Locale-dependent tests — Date/number formatting may differ
  • Reflection-heavy tests — Strong encapsulation continues to tighten
  • Mockito/Byte Buddy tests — May need library upgrades

Step 5: Fix Compilation Errors

Address any errors found in Steps 3 and 4. Common fixes are listed in the Common Migration Issues section below.

Step 6: Update Dependencies

Many libraries need minimum versions to work with Java 21. See the Framework Compatibility section for details.

Step 7: Update Build Plugins

Maven and Gradle plugins must support Java 21. See the Build Tool Updates section.

Step 8: Test in a Staging Environment

Deploy to a staging environment that mirrors production. Run integration tests, load tests, and smoke tests.

Step 9: Enable New Features Gradually

Do not adopt every new feature at once. Start with the safest changes:

  1. Pattern matching for switch (final, safe)
  2. Record patterns (final, safe)
  3. Sequenced collections (final, safe)
  4. Virtual threads (final, but test pinning first)
  5. Generational ZGC (final, but benchmark first)

Step 10: Deploy to Production

Roll out incrementally — canary deployment first, then gradual rollout. Monitor for performance regressions, increased error rates, or unexpected behavior.

5. Build Tool Updates

Your build tools need to know about Java 21. Here are the required configurations for Maven and Gradle.

Maven



    21
    21
    21




    
        
            org.apache.maven.plugins
            maven-compiler-plugin
            3.12.1
            
                21
                
                
            
        

        
        
            org.apache.maven.plugins
            maven-surefire-plugin
            3.2.3
            
                
                
            
        

        
        
            org.apache.maven.plugins
            maven-jar-plugin
            3.3.0
        
    

Gradle

// build.gradle (Groovy DSL)
plugins {
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

// Or set source/target compatibility directly
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21

// For preview features:
tasks.withType(JavaCompile).configureEach {
    options.compilerArgs += ['--enable-preview']
}

tasks.withType(Test).configureEach {
    jvmArgs += ['--enable-preview']
}

tasks.withType(JavaExec).configureEach {
    jvmArgs += ['--enable-preview']
}
// build.gradle.kts (Kotlin DSL)
plugins {
    java
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
    }
}

Minimum Build Tool Versions

Tool Minimum Version for Java 21 Recommended Version
Maven 3.9.0+ 3.9.6
maven-compiler-plugin 3.12.0+ 3.12.1
maven-surefire-plugin 3.2.0+ 3.2.3
Gradle 8.4+ 8.5
IntelliJ IDEA 2023.2+ 2023.3
Eclipse 2023-12+ 2024-03
VS Code (Java Extension Pack) 1.25+ Latest

6. Framework Compatibility

Major Java frameworks have been updated for Java 21. Here are the minimum versions you need.

Spring Boot

Spring Boot Version Java 21 Support Virtual Threads Notes
Spring Boot 3.0.x Compiles but not fully tested No Not recommended
Spring Boot 3.1.x Supported Experimental Minimum for Java 21
Spring Boot 3.2.x Fully supported Yes (one property) Recommended
Spring Boot 3.3.x+ Fully supported Yes Latest features


    org.springframework.boot
    spring-boot-starter-parent
    3.2.3



    21

Other Frameworks

Framework Minimum Version for Java 21 Virtual Thread Support
Quarkus 3.5+ Yes (with @RunOnVirtualThread)
Micronaut 4.2+ Yes (configurable)
Jakarta EE 10+ Container-dependent
Hibernate / JPA 6.4+ Works (uses JDBC)
Apache Kafka Client 3.6+ Compatible
Jackson (JSON) 2.16+ N/A (not thread-dependent)
Lombok 1.18.30+ N/A
Mockito 5.8+ N/A (test framework)
JUnit 5 5.10+ N/A (test framework)

Libraries That Need Special Attention

  • Byte Buddy (used by Mockito, Hibernate) — Upgrade to 1.14.10+ for Java 21 support
  • ASM (bytecode manipulation) — Upgrade to 9.6+ for Java 21 class file format
  • Guava — Version 33.0+ officially supports Java 21
  • Apache Commons — Most modules work unchanged; verify individually
  • Netty — Version 4.1.100+ for Java 21; virtual thread-aware in 4.2+

7. Common Migration Issues

Here are the most common issues developers encounter when migrating from Java 17 to 21, along with their solutions.

# Issue Symptom Solution
1 UTF-8 default charset Non-ASCII characters corrupted on Windows Explicitly specify charset in all I/O: Files.readString(path, StandardCharsets.UTF_8)
2 Dynamic agent loading warning Warning on startup about dynamically loaded agents Add -XX:+EnableDynamicAgentLoading JVM flag or load agents at startup
3 Mockito/Byte Buddy incompatibility IllegalArgumentException or ClassFormatError in tests Upgrade to Mockito 5.8+ and Byte Buddy 1.14.10+
4 Lombok not recognizing Java 21 features Compilation fails on records or sealed classes Upgrade to Lombok 1.18.30+
5 Illegal reflective access InaccessibleObjectException at runtime Add --add-opens flags or migrate to public APIs
6 ASM / bytecode library version UnsupportedClassVersionError Upgrade ASM to 9.6+, ensure all bytecode tools support class file version 65
7 Pattern matching switch syntax change case X s && guard no longer compiles Change to case X s when guard
8 Thread.stop() removed UnsupportedOperationException at runtime Use Thread.interrupt() for cooperative cancellation
9 SecurityManager deprecation Warnings or failures with custom SecurityManager Remove SecurityManager usage; use OS-level security instead
10 finalize() deprecation warnings Compiler warnings on classes overriding finalize() Migrate to AutoCloseable + try-with-resources or Cleaner
11 javax.* to jakarta.* namespace ClassNotFoundException for javax packages This is a Jakarta EE 10 / Spring Boot 3 change, not Java 21 itself. Update imports.
12 GC behavior change with Generational ZGC Different memory/GC characteristics Benchmark with -XX:+UseZGC -XX:+ZGenerational before switching in production

Fixing Illegal Reflective Access

If your application or its dependencies use reflection to access internal JDK APIs, you will see errors like this:

// Error:
// java.lang.reflect.InaccessibleObjectException:
// Unable to make field private final byte[] java.lang.String.value accessible:
// module java.base does not "opens java.lang" to unnamed module

// Fix: Add --add-opens to your JVM startup flags
// java --add-opens java.base/java.lang=ALL-UNNAMED -jar myapp.jar

// Common --add-opens needed for popular libraries:
// --add-opens java.base/java.lang=ALL-UNNAMED          (Byte Buddy, Mockito)
// --add-opens java.base/java.lang.reflect=ALL-UNNAMED   (Spring, Hibernate)
// --add-opens java.base/java.util=ALL-UNNAMED            (Various libraries)
// --add-opens java.base/java.io=ALL-UNNAMED              (Serialization libraries)
// --add-opens java.base/sun.nio.ch=ALL-UNNAMED           (Netty)

8. Performance Improvements

Java 21 includes significant performance improvements that your application gets for free just by upgrading the JDK.

Generational ZGC (JEP 439)

ZGC has been available since Java 15, but Java 21 adds generational support — meaning ZGC now separates objects into young and old generations, just like G1GC. This dramatically improves throughput for applications that create many short-lived objects (which is most Java applications).

Metric Non-Generational ZGC Generational ZGC (Java 21)
Max pause time < 1 ms < 1 ms
Throughput Good Significantly better (up to 2x for allocation-heavy apps)
Memory overhead Higher Lower (young gen collected more efficiently)
Short-lived object collection Treats all objects equally Collects young objects separately (faster)
# Enable Generational ZGC
java -XX:+UseZGC -XX:+ZGenerational -jar myapp.jar

# In Java 23+, ZGenerational is the default when using ZGC
# For Java 21, you must explicitly enable it

# Compare GC performance:
# Run your app with G1GC (default) and monitor
java -XX:+UseG1GC -Xlog:gc -jar myapp.jar

# Then run with Generational ZGC and compare
java -XX:+UseZGC -XX:+ZGenerational -Xlog:gc -jar myapp.jar

Virtual Threads Impact on Throughput

For I/O-bound applications (web servers, microservices, data pipelines), virtual threads can dramatically improve throughput without changing your code. Typical improvements:

Application Type Before (Platform Threads) After (Virtual Threads) Improvement
REST API with JDBC ~200 concurrent requests ~10,000+ concurrent requests 50x
Microservice calling 3 APIs Limited by thread pool Limited by API latency only 10-100x
Batch data processor 50 concurrent file reads 10,000+ concurrent file reads 200x
CPU-bound computation N cores utilized N cores utilized 0% (no benefit)

Other Runtime Improvements

  • JIT compiler improvements — The C2 compiler has better optimization heuristics in Java 21, improving steady-state performance by 5-15% for typical workloads
  • Startup time — CDS (Class Data Sharing) improvements reduce startup time
  • Memory footprint — String and object header compression improvements reduce memory usage
  • Cryptography — New Key Encapsulation Mechanism (KEM) API improves post-quantum cryptography readiness

9. Docker and CI/CD

Containerized applications need updated base images and build configurations for Java 21.

Updated Dockerfiles

# BEFORE: Java 17 Dockerfile
FROM eclipse-temurin:17-jre-alpine
COPY target/myapp.jar /app/myapp.jar
ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]

# AFTER: Java 21 Dockerfile
FROM eclipse-temurin:21-jre-alpine
COPY target/myapp.jar /app/myapp.jar
ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]

# RECOMMENDED: Multi-stage build with Java 21
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN apk add --no-cache maven && mvn clean package -DskipTests

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/myapp.jar ./myapp.jar

# Optimized JVM flags for containers
ENTRYPOINT ["java", \
    "-XX:+UseZGC", \
    "-XX:+ZGenerational", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:+UseContainerSupport", \
    "-jar", "myapp.jar"]

Docker Image Options

Image Size Best For
eclipse-temurin:21-jre-alpine ~80 MB Smallest size, production
eclipse-temurin:21-jre-jammy ~220 MB Ubuntu-based, more tools
eclipse-temurin:21-jdk-alpine ~200 MB Build stage, includes compiler
amazoncorretto:21-alpine ~190 MB AWS deployments
azul/zulu-openjdk-alpine:21 ~190 MB Azul support

GraalVM Native Image

If you are using GraalVM native images, Java 21 support requires GraalVM for JDK 21. The native image compilation benefits from the new language features but has some considerations:

# GraalVM native image with Java 21
# Install GraalVM for JDK 21
sdk install java 21.0.2-graalce

# Build native image (Spring Boot with Spring Native / GraalVM)
./mvnw -Pnative native:compile

# Or with Gradle
./gradlew nativeCompile

# Important: Virtual threads work in native images since GraalVM for JDK 21
# But some preview features may not be supported in native image mode

CI/CD Pipeline Updates

# GitHub Actions: .github/workflows/build.yml
name: Build and Test

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Build with Maven
        run: mvn clean verify

      - name: Build Docker image
        run: docker build -t myapp:latest .

10. Best Practices for Adopting Java 21

Here is how to adopt Java 21 features safely and effectively in a production codebase.

Adopt Final Features First

Focus on features that are final (non-preview) first. These are stable, well-tested, and will not change in future versions.

Priority Feature Risk Level Effort Benefit
1 Pattern matching for switch Low Low — refactor existing switch/if-else Cleaner, safer type checking
2 Record patterns Low Low — use in new code, refactor gradually Concise destructuring
3 Sequenced collections Low Low — use new methods on existing collections Cleaner first/last access
4 Virtual threads Medium Medium — test for pinning, update executors Massive throughput improvement
5 Generational ZGC Medium Low — just a JVM flag, but benchmark first Better GC performance

Using Preview Features Safely

Preview features (--enable-preview) are for experimentation and feedback. They may change or be removed in the next Java version. Use them with caution:

  • Do use in: Personal projects, prototypes, internal tools, test code
  • Do not use in: Public libraries, frameworks, production services that cannot easily upgrade JDK
  • Always document: If you use preview features, document them so the team knows what to update when upgrading

Incremental Migration Strategy

// Week 1-2: Upgrade JDK and build tools, fix compilation issues
// Week 3-4: Run full test suite, fix failures
// Week 5-6: Deploy to staging, run integration tests
// Week 7-8: Deploy to production (canary)
// Week 9+: Start adopting new language features in new code

// Example: Adopting pattern matching for switch gradually
// BEFORE (Java 17 style)
public String describe(Object obj) {
    if (obj instanceof String s) {
        return "String of length " + s.length();
    } else if (obj instanceof Integer i) {
        return "Integer: " + i;
    } else if (obj instanceof List list) {
        return "List with " + list.size() + " elements";
    } else {
        return "Unknown: " + obj.getClass().getSimpleName();
    }
}

// AFTER (Java 21 style -- pattern matching for switch)
public String describe(Object obj) {
    return switch (obj) {
        case String s       -> "String of length " + s.length();
        case Integer i      -> "Integer: " + i;
        case List list   -> "List with " + list.size() + " elements";
        case null           -> "null";
        default             -> "Unknown: " + obj.getClass().getSimpleName();
    };
}

Sequenced Collections Example

// BEFORE (Java 17): Getting first/last elements was awkward
List names = List.of("Alice", "Bob", "Charlie");

// First element
String first = names.get(0);                    // works but ugly for other collections
String first2 = names.iterator().next();         // generic but verbose

// Last element
String last = names.get(names.size() - 1);       // error-prone
// For LinkedList: ((LinkedList)names).getLast(); // casting!

// AFTER (Java 21): Sequenced collections make it clean
SequencedCollection names = List.of("Alice", "Bob", "Charlie");

String first = names.getFirst();    // "Alice" -- works on ALL SequencedCollections
String last  = names.getLast();     // "Charlie" -- clean, universal

// Reverse view (no copying!)
SequencedCollection reversed = names.reversed();
// reversed: ["Charlie", "Bob", "Alice"]

// Works on Sets too (LinkedHashSet, TreeSet)
SequencedSet orderedSet = new LinkedHashSet<>(List.of("X", "Y", "Z"));
orderedSet.getFirst();  // "X"
orderedSet.getLast();   // "Z"

// Works on Maps (LinkedHashMap, TreeMap)
SequencedMap map = new LinkedHashMap<>();
map.put("first", 1);
map.put("second", 2);
map.put("third", 3);

map.firstEntry();  // "first"=1
map.lastEntry();   // "third"=3
map.reversed();    // reversed view of the map

Migration Summary

Migrating from Java 17 to Java 21 is one of the most rewarding JDK upgrades in Java’s history. The combination of virtual threads, pattern matching, record patterns, sequenced collections, and Generational ZGC gives you a genuinely better language, library, and runtime with minimal breaking changes. Start planning your migration today — the longer you wait, the more you miss out on, and the closer you get to Java 17’s end of support.

What You Get What It Costs
Virtual threads (massive concurrency) Test for thread pinning
Pattern matching for switch (cleaner code) Update preview syntax if used
Record patterns (destructuring) Minimal — new feature
Sequenced collections (cleaner APIs) None — additive API
Generational ZGC (better GC) Benchmark before switching
5-15% general performance improvement Update JDK and test
UTF-8 consistency across platforms Fix charset-dependent code



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *