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).
| 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+.
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 |
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.
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"));
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
});
}
}
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
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";
};
| 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 |
Follow these 10 steps to migrate from Java 17 to Java 21 with minimum risk.
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 |
# 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"
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
Run all tests. Pay special attention to:
Address any errors found in Steps 3 and 4. Common fixes are listed in the Common Migration Issues section below.
Many libraries need minimum versions to work with Java 21. See the Framework Compatibility section for details.
Maven and Gradle plugins must support Java 21. See the Build Tool Updates section.
Deploy to a staging environment that mirrors production. Run integration tests, load tests, and smoke tests.
Do not adopt every new feature at once. Start with the safest changes:
Roll out incrementally — canary deployment first, then gradual rollout. Monitor for performance regressions, increased error rates, or unexpected behavior.
Your build tools need to know about Java 21. Here are the required configurations for Maven and Gradle.
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
// 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))
}
}
| 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 |
Major Java frameworks have been updated for Java 21. Here are the minimum versions you need.
| 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
| 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) |
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 |
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)
Java 21 includes significant performance improvements that your application gets for free just by upgrading the JDK.
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
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) |
Containerized applications need updated base images and build configurations for Java 21.
# 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"]
| 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 |
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
# 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 .
Here is how to adopt Java 21 features safely and effectively in a production codebase.
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 |
Preview features (--enable-preview) are for experimentation and feedback. They may change or be removed in the next Java version. Use them with caution:
// 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();
};
}
// BEFORE (Java 17): Getting first/last elements was awkward Listnames = 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
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 |