Java 25 vs Java 21: The Upgrade Guide Nobody Has Written Yet
A practical, decision-driven comparison for enterprise teams on Java 21 — covering the three changes that actually matter for production.
Every four years or so, an enterprise Java team faces the same question: is it time to move to the next LTS? With Java 25 now available and Java 21’s free update window closing in September 2026, that question is becoming urgent for a lot of organisations. Fortunately, this is one of the most straightforward LTS transitions Java has ever asked you to make.
Between Java 21 and Java 25, there were four intermediate releases — JDK 22, 23, 24, and finally 25. Most teams skipped all of them entirely. So while individual features have been covered in isolation across many articles, there’s been no single guide that assembles the full picture from the perspective of a team currently running 21 in production. That’s exactly what this article is.
Rather than listing every JEP, we’ll focus on the changes that actually have a material effect on a running production system: concurrency behaviour, startup performance, and memory usage. Then we’ll walk through what a practical upgrade path actually looks like.
1. First: Understanding the Gap Between the Two Releases
Java 21 was released in September 2023 and quickly became the most widely adopted modern LTS. It delivered virtual threads as a stable feature, completed pattern matching, and brought records and sealed classes to production quality. For most teams, it represented Java finally feeling modern.
Java 25 arrived exactly two years later, in September 2025. Importantly, Oracle had already shifted to a two-year LTS cadence (down from three), which is why these two releases are closer together than Java 17 and Java 21 were. That shorter gap means less to absorb — but it also means more teams are still on 21 and asking whether they need to move.
| Release | Date | LTS Until | Key themes |
|---|---|---|---|
| Java 21 | Sept 2023 | Sept 2031 | Virtual threads, pattern matching, records/sealed stable |
| Java 22 | Mar 2024 | — | Unnamed variables, FFM API stable, stream gatherers |
| Java 23 | Sept 2024 | — | Markdown Javadoc, previews iterating |
| Java 24 | Mar 2025 | — | Virtual thread pinning fix, first Leyden AOT, 24 JEPs |
| Java 25 | Sept 2025 | Sept 2033 | Compact headers, Leyden method profiling, Shenandoah generational |
The practical implication: if you’re on Java 21 and upgrade directly to Java 25, you’re picking up all the JDK 22, 23, and 24 work in one shot. Crucially, that includes JEP 491 from JDK 24 — the virtual thread pinning fix — which many consider the most operationally significant improvement since virtual threads were introduced in the first place.
2. The Fix That Changes Everything: Virtual Thread Pinning
JEP 491 · JDK 24
When virtual threads landed in Java 21, they came with a well-known asterisk: blocking inside a synchronized block or method would “pin” the virtual thread to its carrier platform thread. Pinning meant the carrier couldn’t be reused for other virtual threads while it was waiting. In the worst case, all carrier threads could be pinned simultaneously, causing thread starvation or even deadlock.
The recommended workaround was to replace synchronized with ReentrantLock and similar constructs from java.util.concurrent.locks. Many popular frameworks — Spring, Hibernate, and others — scrambled to do exactly that ahead of Java 21’s release. However, that meant a lot of existing code still carried the risk.
“In JDK 21, when a virtual thread enters a synchronized block and blocks on I/O, the carrier platform thread is occupied the entire time. In JDK 24, the virtual thread unmounts from the carrier, which can then serve other virtual threads.”— Nicolai Parlog, Java Developer Advocate at Oracle, Inside Java Newscast #80
JEP 491, delivered in JDK 24, solves this at the JVM level. The monitor mechanism was updated to track virtual thread identity rather than platform thread identity, which means virtual threads can now unmount freely even while holding a monitor. The result is that synchronized is no longer a performance hazard for virtual thread applications.
2.1 What Does This Mean in Practice?
A real benchmark from Dan Vega’s pinning demo makes the difference viscerally clear. Running 5,000 virtual threads on a single-carrier-thread configuration with synchronized blocking:
That’s a 70x speedup from a JVM upgrade alone — no code changes involved. Of course, this is a synthetic benchmark designed to stress pinning specifically. But it illustrates what was at stake for any service that had synchronized I/O paths under high concurrency.
What still pins after JEP 491
Two scenarios still cause pinning in Java 25: (1) blocking insidenative codethat calls back into blocking Java, including class loaders; and (2) class initializer execution. These cases are rare in most web services, but if your application makes heavy use of JNI, it’s worth profiling. The old
-Djdk.tracePinnedThreadsflag is removed; use JFR’sjdk.VirtualThreadPinnedevent instead.
2.2 Diagnostics Changed Too
The -Djdk.tracePinnedThreads=full system property, which was the primary tool for detecting pinning in Java 21, is gone in JDK 24 and beyond. You now use JDK Flight Recorder to observe the jdk.VirtualThreadPinned event. The event itself has been enhanced to explain both why the thread was pinned and which carrier thread was involved — which is actually more useful than the old approach.
Check for remaining pinning with JFR
java -XX:StartFlightRecording=filename=recording.jfr,duration=60s -jar your-app.jar jfr print --events jdk.VirtualThreadPinned recording.jfr
Virtual Thread Throughput: Java 21 vs Java 25

3. Startup Time: Project Leyden’s First Real Payoff
JEP 483 (JDK 24) · JEP 514 + JEP 515 (JDK 25)
Java’s startup time has been a long-standing pain point, especially in the microservices and serverless era where applications may restart dozens of times a day. GraalVM’s Native Image addresses this aggressively, but at the cost of full JVM dynamism. Project Leyden is the OpenJDK community’s answer: ahead-of-time optimisations that preserve the full JVM while meaningfully cutting startup time.
Between JDK 24 and JDK 25, Leyden delivered three JEPs that together form a complete, usable workflow:
| JEP | JDK | What It Does | Impact |
|---|---|---|---|
| JEP 483 | 24 | Ahead-of-Time Class Loading & Linking — stores fully loaded/linked classes in an AOT cache | ~41% faster startup |
| JEP 514 | 25 | AOT Command-Line Ergonomics — collapses the training workflow from 3 commands to 2 | Usability improvement |
| JEP 515 | 25 | Ahead-of-Time Method Profiling — stores hot method profiles in the cache, so JIT starts warm | 15–25% faster warmup |
Together, the practical effect is striking. In a real-world test by Gholamzadreza’s Spring Boot 3.3+ project, startup time dropped from 4.9 seconds to 2.4 seconds using the AOT cache — a 51% reduction with three commands and no application code changes. InfoQ’s coverage of JEP 483 cited Spring PetClinic starting 40–41% faster on early builds.
3.1 How It Works (Without the Jargon)
Every time your Java application starts, the JVM reads, parses, verifies, loads, and links every class your code touches. For a typical Spring Boot application, that’s 15,000 to 25,000 classes. It’s the same work, done again and again, every single cold start.
The AOT cache captures all of that work during a training run and stores it in a compact file. On subsequent starts, the JVM picks up those already-linked classes directly. JEP 515 then goes a step further: it also records which methods were hot during the training run, so the JIT compiler knows immediately what to prioritise. The result is that your application reaches peak performance faster — which is the “warmup” improvement.
3.2 The Workflow on Java 25
Step 1 — Training run (records + builds the cache)
java -XX:AOTCacheOutput=app.aot -jar your-application.jar
Step 2 — Every production run after that
java -XX:AOTCache=app.aot -jar your-application.jar
That’s the entire workflow. In JDK 24, it required three separate commands (record, assemble, run). JEP 514 in JDK 25 collapsed training and assembly into one step. And crucially, unlike GraalVM Native Image, you don’t give anything up: full reflection, dynamic class loading, and all JVM features remain available.
Training run best practice
The AOT cache is only as good as the training run. Make sure your training run exercises the production code paths — ideally by sending representative traffic after startup. Avoid using test frameworks in training, as they load extra classes that inflate the cache. Also remember to regenerate the cache whenever you deploy a new application version; a stale cache won’t cause failures, but it won’t capture new hot paths either.
Spring Boot Startup Time: Java 21 vs Java 25 with AOT Cache

4. Memory: Compact Object Headers Give You a Free Heap Reduction
JEP 519 · JDK 25
We covered JEP 519 in depth in a dedicated article, but it deserves a prominent place in any upgrade guide. The short version: Java 25 ships with the ability to reduce every object’s header size from 12 bytes to 8 bytes. That’s a 33% reduction per object header, and it compounds dramatically at scale.
Unlike the Leyden AOT features, compact headers require a single JVM flag — no training run needed. The savings are invisible to application code and require no changes whatsoever to your codebase.
Enable Compact Object Headers
java -XX:+UseCompactObjectHeaders -jar your-application.jar
Compact headers require Compressed Class Pointers, which are enabled by default on 64-bit systems with heaps under 32 GB. If your heap exceeds 32 GB, compact headers are not available. Additionally, ZGC is not supported alongside compact headers in JDK 25 — you’d need to use G1 or Parallel GC. A future JDK release is expected to remove this limitation.
5. Garbage Collection: Two Notable GC Upgrades
Beyond compact headers, Java 25 brings two GC improvements that enterprise teams should be aware of — particularly if they already use or are considering low-latency collectors.
5.1 Generational Shenandoah (JEP 521) — Now Production-Ready
Shenandoah was always known for its ultra-low pause times, but its single-generation design meant it had to scan the entire heap to find garbage — losing efficiency compared to generational collectors like G1. The generational mode, which separates memory into young and old generations, was experimental in JDK 24. In Java 25, it’s promoted to a fully supported product feature.
If you’re currently using non-generational Shenandoah in production, this is a meaningful upgrade in sustainable throughput and memory utilisation. You can enable it without experimental flags:
Enable Generational Shenandoah
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar your-application.jar
5.2 ZGC Simplification: Non-Generational Mode Removed
If you’re using ZGC, there’s an important change to be aware of: Java 25 removes the non-generational mode of ZGC entirely. That means -XX:+UseZGC now always means generational ZGC. For most teams this is transparent — generational ZGC has been the default and recommended mode since JDK 21 via JEP 439. However, if you were explicitly running non-generational ZGC for some reason, you’ll need to retest your GC behaviour after the upgrade.
6. Language & API: What Graduated to Final in Java 25
Runtime performance aside, Java 25 also finalises several language features that were in preview during Java 21. These are stable enough to use in production code without any flags. Here’s a concise summary of what moved from “preview” to “final”:
| Feature | JEP | Status in Java 21 | Status in Java 25 |
|---|---|---|---|
| Scoped Values | JEP 506 | Preview | Final |
Unnamed Variables (_) | JEP 456 | Preview | Final |
| Flexible Constructor Bodies | JEP 513 | Not available | Final |
| Module Import Declarations | JEP 511 | Not available | Final |
| Instance Main Methods | JEP 512 | Not available | Final |
| Structured Concurrency | JEP 505 | Preview | Preview (5th) |
| Key Derivation Function API | JEP 518 | Not available | Final |
The most immediately useful of these for most teams is Scoped Values (JEP 506). They’re a cleaner, more efficient replacement for ThreadLocal — particularly in virtual-thread-heavy applications where ThreadLocal has known memory and performance issues. With Scoped Values now final, you can confidently migrate away from ThreadLocal for contextual data sharing in high-concurrency paths.
7. Breaking Changes and Removal Risks
Fortunately, the Java 21-to-25 migration has very few genuine breaking changes. Java’s commitment to backward compatibility means that the overwhelming majority of Java 21 applications will compile and run on Java 25 without modification. Nevertheless, there are a handful of things worth checking:
| Change | Risk Level | What to Do |
|---|---|---|
| 32-bit x86 port removed | Low | Check build agents and deployment images for any 32-bit JDK assumptions |
| Non-generational ZGC removed | Medium | If explicitly using non-generational ZGC, retest GC behaviour post-upgrade |
-Djdk.tracePinnedThreads removed | Low | Switch monitoring to jfr print --events jdk.VirtualThreadPinned |
| Security Manager fully removed | Medium | If still using java.security.manager, this path must be migrated before 25 |
| Preview API signature changes | Medium | StructuredTaskScope constructors changed to factory methods across previews; update call sites |
| Native code / fixed header layout | Opt-in | Compact headers only apply if you enable -XX:+UseCompactObjectHeaders; no risk unless opted in |
Security Manager: Act Now If You Haven’t
The Java Security Manager was deprecated in Java 17, deprecated-for-removal in Java 18, and its enforcement was disabled in Java 24. By Java 25, the supporting infrastructure is being stripped out. If your codebase or any of its dependencies still references
System.setSecurityManager()or similar APIs, this needs to be addressed before upgrading. Runjavac -Xlint:deprecationon your full build to surface any remaining references.
8. Support Timelines: Why “Later” Has a Deadline
One of the most concrete reasons to plan your upgrade now rather than later is the support window. Java 21 free updates from Oracle expire in September 2026. That’s roughly 18 months from today. Java 25 extends that window to September 2033 — giving you an extra two years of support runway.
LTS Support Windows: Java 21 vs Java 25

Furthermore, tooling support for Java 25 is already strong. Spring Boot 3.5+ supports it, Gradle 9.1+ supports it, and Maven’s compiler plugin 3.14.1+ supports it. The ecosystem matured quickly precisely because Java 25 is an LTS release — the kind that frameworks actively target.
9. Should You Upgrade? A Decision Matrix
Rather than a one-size-fits-all recommendation, here’s a straightforward way to think about timing based on your organisation’s profile:
Upgrade Decision Matrix: Java 21 → Java 25
| Team / Workload Profile | Verdict | Recommended Action & Reason |
|---|---|---|
| Heavy virtual thread usageLoom-based services, high-concurrency APIs | Upgrade now | Upgrade promptly.JEP 491 removes a latent scalability risk. The pinning issue may not be visible under normal load but can cause thread starvation or deadlocks under traffic spikes. |
| Containerised microservicesFrequent cold starts, Kubernetes, serverless | Upgrade now | Upgrade and enable Leyden AOT.The AOT cache (JEPs 483 + 515) can cut startup time by up to 51% with two commands. Directly lowers cloud compute costs and improves deployment velocity. |
| Heap-heavy workloadsSpring Boot, ORM-heavy apps, in-memory caches | Upgrade now | Upgrade and benchmark compact headers.A single JVM flag (-XX:+UseCompactObjectHeaders) saves 10–22% heap with zero code changes. Almost always worth taking. |
| Stable batch / data processingLong-running jobs, pipelines, ETL workloads | Plan for 2026 | Plan upgrade before Java 21 free-update expiry (Sept 2026).Lower urgency day-to-day, but compact headers and fewer GC cycles still provide meaningful efficiency gains. Don’t wait until the last minute. |
| Legacy enterprise monolithSlow release cycle, large codebase, many dependencies | Start now | Begin the upgrade process immediately.The migration itself is low-risk, but lead time matters. Allow 6–12 months for thorough testing across dependency chains before cutting over to production. |
| Greenfield new service or rewriteNew project, clean slate | Start on 25 | Start on Java 25 directly.No reason to begin on an older LTS. Java 25 has a longer support window (until 2033), better performance defaults, and a complete, stable feature set. |
10. Your Practical Upgrade Checklist
Finally, here’s a step-by-step path that should work for most teams moving from Java 21 to Java 25 on a production system:
- Update your build toolchain first. Upgrade to Gradle 9.1+ or Maven compiler plugin 3.14.1+. Update your Docker base images to a Java 25 distribution (Eclipse Temurin, Azul Zulu, Amazon Corretto all have builds).
- Compile and run tests on JDK 25 with no flags. The vast majority of Java 21 codebases compile clean. Use
javac -Xlint:deprecationto surface any deprecated API usage. Fix any Security Manager references. - Remove any
synchronized → ReentrantLockworkarounds you added for Java 21. With JEP 491 in place, you can now choose between synchronized and j.u.c.locks based on what best fits the problem — not based on pinning avoidance. - Enable compact object headers on a canary tier first. Add
-XX:+UseCompactObjectHeaders, run representative load, and compare heap metrics, GC frequency, and latency against your Java 21 baseline. If you use ZGC, plan to test on G1 or Parallel GC instead. - Run a Leyden AOT training session. Execute the two-command workflow (
-XX:AOTCacheOutputthen-XX:AOTCache), measure startup time and time-to-first-request against your baseline, and add cache file generation to your CI/CD pipeline as a build artifact. - Check for residual pinning with JFR. Even after JEP 491, native-code paths can still pin. Run
jfr print --events jdk.VirtualThreadPinned recording.jfron a production load test to confirm your services are pin-free. - Roll out gradually. Deploy Java 25 with compact headers and the AOT cache to one service tier at a time. Monitor RSS, GC pause times, throughput, and error rates. Keep the old JVM flags documented so you can disable features selectively if needed.
- Update CI/CD images and documentation. Update any CI container images, Kubernetes base images, and runbooks to reflect the new JVM flags. Document which flags are enabled in each environment.
11. What We’ve Learned
In this guide, we walked through the most important differences between Java 21 and Java 25 from the perspective of a team running in production. We started with the virtual thread pinning fix (JEP 491), delivered in JDK 24, which eliminates the biggest scalability limitation that shipped with virtual threads in Java 21 — allowing synchronized blocks to be used freely without the risk of carrier thread exhaustion. We then explored Project Leyden’s AOT cache (JEP 483 + 514 + 515), which can cut Spring Boot startup times by up to 51% and improve warmup performance by 15–25%, all with two commands and zero code changes.
Next, we covered Compact Object Headers (JEP 519), which shave 4 bytes off every object and translate into 10–22% less heap usage on object-heavy workloads, enabled by a single JVM flag. We also touched on GC improvements — Generational Shenandoah reaching production status and ZGC simplification — and reviewed which language features moved from preview to final, including Scoped Values and Module Import Declarations.
Finally, we covered the handful of genuine breaking changes (Security Manager removal, non-generational ZGC removal, diagnostic flag changes) and provided a practical eight-step upgrade checklist. The overall message: the Java 21→25 migration is low-risk and high-reward, and with Java 21 free updates expiring in September 2026, now is exactly the right time to start.



