OpenJDK 8 community support ends in November 2026. If you are reading this, you have about seven months.
That is not a lot of time for enterprise teams. Dependency audits, framework upgrades, testing, rollouts across environments. These things do not happen in a weekend. The teams that start now will finish comfortably. The teams that wait until September will be rushing through it during Q4 code freezes.
Java 21 is the target. It is the current LTS, the ecosystem has fully caught up, and it brings genuinely useful features like virtual threads and pattern matching that you will actually use day-to-day. Java 25 lands in September 2026, but it will take the library and framework ecosystem time to stabilize around it. Java 21 is the safe, smart move right now.
If you have already done a Java 8 to 17 migration, some of this will be familiar. We covered that path in detail in our Java 8 to 17 migration guide. This guide picks up where that one left off and covers the full 8-to-21 jump, including everything that changed in Java 18 through 21.
Why Java 21 and Why Now
Three things are converging that make this the right time.
The support cliff is real. OpenJDK 8 loses community updates in November 2026. Oracle’s extended support continues (for paying customers), but the broader ecosystem of libraries, frameworks, and tools is moving on. Spring Boot 3.x requires Java 17 minimum. Jakarta EE 10 requires Java 11 minimum. The dependency graph is pulling you forward whether you like it or not.
The performance gains are substantial. We consistently see 15-40% throughput improvement with zero code changes when moving from Java 8 to 21. Better garbage collectors, a smarter JIT compiler, and proper container awareness add up. For teams running on Kubernetes, the memory savings alone can cut infrastructure costs.
Virtual threads change the game for I/O-heavy services. If you have REST APIs, database-heavy services, or anything that spends time waiting on network calls, virtual threads let you handle orders of magnitude more concurrent requests without the complexity of reactive programming. This is the single biggest practical improvement since lambdas in Java 8.
What Changed: Java 9 Through 21
Here is a condensed rundown of the features that actually matter for a migration. This is not an exhaustive list of every JEP. It is what you will encounter during the work and what you will want to adopt afterward.
Java 9-11: The Foundation Shift
- Module system (JPMS) — affects classpath, reflection, and internal API access
- JAXB, JAX-WS, Corba removed — must add as external dependencies
varfor local variables — optional but cleans up verbose code- New HTTP client — replaces the old
HttpURLConnection - String enhancements —
isBlank(),lines(),strip(),repeat()
Java 12-17: Language Modernization
- Switch expressions — return values from switch, use arrow syntax
- Text blocks — multi-line strings with
""" - Records — immutable data classes without the boilerplate
- Sealed classes — restrict which classes can extend a type
- Pattern matching for
instanceof— eliminates explicit casts - Helpful NullPointerExceptions — tells you exactly which variable was null
- ZGC and Shenandoah GC — sub-millisecond pause times
Java 18-21: The Payoff
This is what you get beyond Java 17.
- Virtual threads (Java 21) — lightweight threads managed by the JVM, not the OS
- Record patterns (Java 21) — deconstruct records directly in pattern matching
- Pattern matching for switch (Java 21) — full type-based switch with guards
- Sequenced collections (Java 21) —
SequencedCollection,SequencedSet,SequencedMapwithgetFirst(),getLast(),reversed() - UTF-8 by default (Java 18) — no more charset surprises across platforms
- Simple web server (Java 18) —
jwebservercommand for quick local serving - Deprecation of finalization (Java 18) — start moving to
Cleaneror try-with-resources
Before/After: Code That Gets Better
Here is what real migration work looks like. These are not contrived examples. They are patterns we see in every enterprise codebase.
Replacing Verbose DTOs with Records
// Java 8: 40 lines for a simple data carrier
public class CustomerResponse {
private final String id;
private final String name;
private final String email;
public CustomerResponse(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
@Override
public boolean equals(Object o) { /* 10 lines */ }
@Override
public int hashCode() { return Objects.hash(id, name, email); }
@Override
public String toString() { /* boilerplate */ }
}
// Java 21: One line. Same behavior. Immutable by default.
public record CustomerResponse(String id, String name, String email) {}
Pattern Matching Eliminates Casting Chains
// Java 8: instanceof + cast + nested ifs
if (event instanceof OrderEvent) {
OrderEvent orderEvent = (OrderEvent) event;
if (orderEvent.getType().equals("CREATED")) {
processNewOrder(orderEvent);
} else if (orderEvent.getType().equals("CANCELLED")) {
processCancellation(orderEvent);
}
} else if (event instanceof PaymentEvent) {
PaymentEvent paymentEvent = (PaymentEvent) event;
processPayment(paymentEvent);
}
// Java 21: Pattern matching with switch and guards
switch (event) {
case OrderEvent o when o.type().equals("CREATED") -> processNewOrder(o);
case OrderEvent o when o.type().equals("CANCELLED") -> processCancellation(o);
case PaymentEvent p -> processPayment(p);
default -> log.warn("Unknown event type: {}", event.getClass());
}
Virtual Threads for I/O-Bound Work
// Java 8: Thread pool tuning, capacity planning, reactive complexity
ExecutorService executor = Executors.newFixedThreadPool(200);
List<Future<Response>> futures = requests.stream()
.map(req -> executor.submit(() -> httpClient.send(req)))
.collect(Collectors.toList());
// Java 21: Virtual threads. No pool sizing. No reactive frameworks.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Response>> futures = requests.stream()
.map(req -> executor.submit(() -> httpClient.send(req)))
.toList();
}
The virtual thread version handles 100,000 concurrent requests as easily as 200. No thread pool sizing debates. No reactive rewrite. Your existing blocking code just works, but at scale.
Sequenced Collections: Finally, a Clean First/Last API
// Java 8: Getting the last element of a List, LinkedHashSet, or TreeMap
// Each collection had a different approach
String last = list.get(list.size() - 1); // List
String lastSet = null;
for (String s : linkedHashSet) { lastSet = s; } // LinkedHashSet: iterate all
Map.Entry<K,V> lastEntry = treeMap.lastEntry(); // TreeMap only
// Java 21: Uniform API across all ordered collections
String last = list.getLast();
String lastSet = linkedHashSet.getLast();
Map.Entry<K,V> lastEntry = sequencedMap.lastEntry();
// And reversed views
List<String> reversed = list.reversed();
The Migration Process: Step by Step
This is the approach we use on real projects. It is not theoretical. It has been tested across monoliths, microservices, and everything in between.
Step 1: Audit Dependencies (Week 1)
Before you change a single line of code, figure out what you are working with.
# Generate your full dependency tree
mvn dependency:tree -DoutputFile=dependencies.txt
# Check for JDK internal API usage
jdeps --jdk-internals --multi-release 21 your-application.jar
Build a spreadsheet. Every dependency. Current version. Latest version. Java 21 support status. It is boring work, but it is the work that prevents surprises in week 4.
Libraries that commonly need updating:
| Library | Minimum Version for Java 21 |
|---|---|
| Lombok | 1.18.30+ |
| Mockito | 5.x |
| ByteBuddy | 1.14.x+ |
| ASM | 9.6+ |
| Spring Boot | 3.1+ (requires Java 17 minimum) |
| Hibernate | 6.x (for Jakarta EE) |
| Jackson | 2.15+ |
| Logback | 1.4+ |
Step 2: Fix the Build Configuration (Week 1-2)
Update your build tools first. You need Maven 3.9+ or Gradle 8.x+.
For Maven:
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
</properties>
For Gradle:
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
Update your CI/CD pipeline and Docker base images at the same time:
# Before
FROM eclipse-temurin:8-jre-alpine
# After
FROM eclipse-temurin:21-jre-alpine
Step 3: Handle the Breaking Changes (Week 2-4)
Here is a priority-ordered list of what will actually break.
JAXB and Jakarta EE dependencies. If you use XML binding, SOAP services, or anything from the old javax.* namespace, add the Jakarta equivalents:
<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>4.0.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>4.0.5</version>
<scope>runtime</scope>
</dependency>
Illegal reflective access. Java 17+ enforces strong encapsulation. The --illegal-access flag no longer exists. If your code or libraries access JDK internals, you have two options:
# Option A: Add specific --add-opens flags (short-term fix)
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
# Option B: Update the library or refactor the code (right answer)
Option A gets you running. Option B is where you want to end up. Track every --add-opens flag and create tickets to eliminate them.
SecurityManager removal. If your code uses SecurityManager, it is deprecated for removal in Java 17 and will be gone entirely in a future release. Move to OS-level security, container policies, or IAM controls.
URLClassLoader casting. The system class loader is no longer a URLClassLoader. Any code doing (URLClassLoader) ClassLoader.getSystemClassLoader() will throw a ClassCastException.
Step 4: Run OpenRewrite (Week 2)
This is the biggest time-saver in the entire process. OpenRewrite has a composite recipe that handles the mechanical changes across all intermediate versions automatically.
For Maven:
<plugin>
<groupId>org.openrewrite.maven</groupId>
<artifactId>rewrite-maven-plugin</artifactId>
<version>5.46.0</version>
<configuration>
<activeRecipes>
<recipe>org.openrewrite.java.migrate.UpgradeToJava21</recipe>
</activeRecipes>
</configuration>
<dependencies>
<dependency>
<groupId>org.openrewrite.recipe</groupId>
<artifactId>rewrite-migrate-java</artifactId>
<version>3.3.0</version>
</dependency>
</dependencies>
</plugin>
Then run it:
mvn rewrite:run
OpenRewrite will handle deprecated API replacements, import changes, build file updates, and plugin version bumps. Review the diff carefully. It is good at mechanical changes but does not understand your business logic.
For a deeper look at what OpenRewrite can do for your migration, see our guide on using OpenRewrite for Spring Boot migrations.
Step 5: Upgrade Spring Boot (If Applicable) (Week 3-5)
If you are on Spring Boot 2.x, this is part of the migration. Spring Boot 3.x requires Java 17+ and uses Jakarta EE 10 (jakarta.* namespace instead of javax.*).
This is the most labor-intensive part for many teams. The javax to jakarta namespace change touches every import, every annotation, every configuration class. OpenRewrite handles most of it, but custom configurations and third-party integrations need manual review.
// Before (javax)
import javax.persistence.Entity;
import javax.servlet.http.HttpServletRequest;
// After (jakarta)
import jakarta.persistence.Entity;
import jakarta.servlet.http.HttpServletRequest;
Step 6: Test Methodically (Week 4-6)
Do not just run the unit tests and call it done. Follow this sequence:
- Compile. Fix every compiler error. No warnings suppressed.
- Unit tests. Fix failures. Expect 5-15% to need updates, mostly around reflection and mocking.
- Integration tests. Run against real databases, message brokers, external services.
- Performance tests. Compare throughput, latency, and memory against your Java 8 baseline.
- Soak tests. Run under production-like load for 24-48 hours. Watch for memory leaks and GC behavior changes.
# Compare GC behavior between versions
# Java 8 baseline
java -XX:+UseG1GC -Xlog:gc -jar app.jar
# Java 21 with G1GC (default)
java -Xlog:gc -jar app.jar
# Java 21 with ZGC (if latency matters)
java -XX:+UseZGC -XX:+ZGenerational -Xlog:gc -jar app.jar
Step 7: Roll Out (Week 5-8)
Deploy to a single non-production environment first. Then canary. Then wider.
Keep the Java 8 build around for a sprint or two. If something goes wrong in production, you want to roll back to the exact artifact you had before, not scramble to revert code changes.
Common Pitfalls
These are the issues that slow down real migrations. We see them on almost every project.
Pinned library versions with no Java 21 support. Teams often pin dependency versions years ago and never revisit them. Some of those libraries have been abandoned. Finding replacements mid-migration is painful. Do the audit first.
Byte code manipulation libraries. Anything that generates or modifies bytecode at runtime (CGLIB, Javassist, older ASM versions) tends to break across major Java versions. Update these before you update Java.
Custom class loaders. If your application has custom class loading logic, especially if it touches URLClassLoader, expect it to need rework.
--add-opens proliferation. It is tempting to solve every strong encapsulation error with a JVM flag. Each one is technical debt. Track them, limit them, and schedule work to eliminate them.
The javax to jakarta namespace. This one catches teams off guard because the changes are everywhere. If you are upgrading Spring Boot to 3.x at the same time, plan extra time for this. It touches more files than people expect.
GC tuning assumptions. Java 8 GC tuning flags may not apply or may behave differently. Do not blindly copy your old JVM flags. Start with defaults and tune from there based on observed behavior.
Tooling That Actually Helps
jdeps — built into the JDK. Finds internal API usage before you hit runtime errors.
jdeps --jdk-internals --multi-release 21 \
--class-path 'libs/*' \
your-application.jar
OpenRewrite — automates the mechanical code changes. Worth the setup time even for small projects.
jlink — builds a custom runtime image with only the modules your application needs. Smaller Docker images, faster startup.
jlink --add-modules java.base,java.sql,java.logging \
--output custom-runtime \
--strip-debug \
--no-man-pages
JDK Flight Recorder (JFR) — free in Java 21, built into the JDK. Use it for profiling during your performance testing phase.
java -XX:StartFlightRecording=duration=60s,filename=recording.jfr \
-jar app.jar
Planning the Work for Your Team
If you are an engineering leader reading this, here is how to frame this for your team and your stakeholders.
This is not optional work. Community support for OpenJDK 8 ends November 2026. Running unsupported Java in production is a security risk and a compliance issue.
The scope is predictable. For most applications, the work breaks down into dependency upgrades (30% of the effort), breaking change fixes (30%), Spring Boot / framework upgrades (20%), and testing (20%).
It pays for itself. The performance improvements from Java 21 typically reduce infrastructure costs by 15-30%. Virtual threads can eliminate the need for reactive frameworks, reducing code complexity. And your developers will be happier writing modern Java. Retention matters.
Start with one service. Pick a small, well-tested service. Run the migration as a proof of concept. Document the issues you hit. Use those learnings to estimate the rest of the portfolio.
What Comes After 21
Java 25 LTS is scheduled for September 2026. It brings additional improvements, but the ecosystem will need time to stabilize. Our recommendation: get to Java 21 now, then plan a 21-to-25 upgrade in early 2027 when libraries and frameworks have full support. We wrote about what to expect in our Java 25 enterprise migration guide.
Frequently Asked Questions
Can I skip Java 17 and go straight from Java 8 to Java 21?
Yes. There is no requirement to release on intermediate versions. You will need to address all breaking changes from Java 9 through 21 in one pass, but tools like OpenRewrite handle this with a single composite recipe. Most teams skip intermediate LTS releases and go directly to 21.
How long does a Java 8 to 21 migration take?
Small services (under 50k lines, few dependencies) typically take 1-3 weeks. Mid-size enterprise applications take 4-10 weeks including dependency audits, code changes, and testing. Large monoliths with legacy code can take 3-6 months. OpenRewrite automates the mechanical changes and significantly shortens the timeline.
What are the biggest breaking changes between Java 8 and Java 21?
The most impactful changes are: (1) Removal of JAXB, JAX-WS, and Corba from the JDK. (2) Strong encapsulation of internal JDK APIs like sun.misc.Unsafe. (3) The --illegal-access flag no longer works in Java 17+. (4) Removal of Nashorn JavaScript engine. (5) SecurityManager deprecated for removal. Most codebases hit the first two.
Will I get performance improvements just from upgrading to Java 21?
Yes. Most applications see 15-40% better throughput with no code changes, from GC improvements, JIT compiler optimizations, and better container awareness. If you adopt virtual threads for I/O-heavy workloads, the gains can be much larger. Memory footprint typically drops 20-40%, which cuts costs directly in containerized environments.
What is the best Java 21 distribution for enterprises?
The most popular choices are Eclipse Temurin (Adoptium), Amazon Corretto, Azul Zulu, and Oracle JDK. All are built from the same OpenJDK source. Temurin and Corretto are free with no license restrictions. Oracle JDK requires a commercial license for production use. Pick whichever your team has experience with.
Need Help with Your Java 21 Migration?
We have done this work across dozens of enterprise applications. If your team needs hands-on help with the migration, a second set of eyes on your plan, or someone to pair with your developers through the tricky parts, get in touch.
Related Articles
- The Complete Guide to Migrating from Java 8 to Java 17 — Detailed coverage of the Java 9-17 breaking changes and migration patterns.
- Java 25 Enterprise Migration Guide — Plan your next LTS jump after you land on Java 21.
- Simplify Spring Boot Version Migration with OpenRewrite — Automate the framework upgrade that often accompanies a Java version migration.
Java Modernization Readiness Assessment
15 questions your team should answer before starting a migration. Takes 10 minutes. Could save you months.