From this month, Oracle releases its JDK 21 updates under a license that is not free in production, and teams that finished a Java 21 migration a year or two ago are asking whether 25 deserves another one. It does. For a team on 21 it is a small upgrade with runtime gains that need no code changes; for a team on 8, 11 or 17, the target is 25, not 21.
Java 21 vs 25 at a glance
Java 21 (September 2023) and Java 25 (September 2025) are consecutive long-term support releases. Between them sit three feature releases, 22, 23 and 24, and a move from 21 to 25 picks up everything they delivered. The table compares the Java 21 new features with the Java 25 features by area.
| Area | Java 21 | Java 25 |
|---|---|---|
| Concurrency | Virtual threads final; pinning inside synchronized blocks | Virtual threads no longer pin in synchronized; scoped values final; structured concurrency still in preview |
| Language | Record patterns and pattern matching for switch final | Unnamed variables, flexible constructor bodies, module imports, compact source files and instance main methods final |
| Libraries | Sequenced collections, Key Encapsulation Mechanism API | Stream gatherers, Class-File API, Foreign Function and Memory API, Key Derivation Function API, ML-KEM and ML-DSA |
| Memory and startup | Generational ZGC as an option | Compact object headers as a product feature, ahead-of-time cache with method profiles, generational Shenandoah |
| Garbage collectors | ZGC defaults to its non-generational mode | ZGC is generational only |
| Removed or restricted | Security Manager deprecated for removal; warnings for dynamically loaded agents | Security Manager permanently disabled; 32-bit x86 port removed; warnings for JNI and sun.misc.Unsafe memory access |
| Oracle JDK license | Updates under the paid OTN license from October 2026 | Free updates under Oracle's No-Fee Terms until September 2028 |
What changed between Java 21 and Java 25, release by release
The four releases after 21 delivered 66 JDK Enhancement Proposals (JEPs), 33 of them final, according to the OpenJDK project pages for JDK 22, 23, 24 and 25. JDK 24 alone delivered 24.
The count matters less than what finished. Most preview features of Java 21 went through two to four more rounds and became final in 25. Two did not: structured concurrency is in its fifth preview, and string templates were withdrawn after their second.
Preview features need --enable-preview at compile time and at run time, and their API can change between releases, as structured concurrency's did. Code built against a preview in 21 does not run on 25 unchanged, so the features to plan on are the final ones.
Java 21 new features: the baseline you already have
If you run 21 today, these are in production use already, or could be. They carry over to 25 unchanged.
Virtual threads (JEP 444) let a server handle each request on its own thread without a pool of a few hundred OS threads limiting throughput. One line switches an executor over:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
orders.forEach(order -> executor.submit(() -> billing.charge(order)));
}
Our virtual threads guide covers what changes in connection pools, locks and thread-local caches when you do this.
Record patterns and pattern matching for switch (JEP 440, JEP 441) let a switch take a sealed hierarchy apart, and the compiler checks that every case is covered:
sealed interface Payment permits Card, Transfer, Voucher {}
record Card(String last4, Money amount) implements Payment {}
record Transfer(String iban, Money amount) implements Payment {}
record Voucher(String code) implements Payment {}
Money fee(Payment p) {
return switch (p) {
case Card(var last4, var amount) -> amount.percent(1.4);
case Transfer(var iban, var amount) -> Money.of("0.20");
case Voucher v -> Money.ZERO;
};
}
Add a fourth payment type and the switch stops compiling until it handles it, which is the point.
Sequenced collections (JEP 431) give lists, deques, sorted sets and linked hash maps one interface for the first and last element and a reversed view: list.getFirst(), list.getLast(), list.reversed(). Before 21, the last element of a list was list.get(list.size() - 1).
Generational ZGC (JEP 439) shipped in 21 as an option behind -XX:+ZGenerational. It became ZGC's default mode in 23 (JEP 474) and its only mode in 24 (JEP 490), so on 25 the flag is obsolete and -XX:+UseZGC always means the generational collector.
Java 25 features for everyday code
These became final between 22 and 25. None of them forces a rewrite; each one shortens code as it is touched for other reasons.
Unnamed variables and patterns
Final since 22 (JEP 456). An underscore marks a variable or pattern component the code never reads, so the voucher case above no longer needs a name for a value it ignores, and neither does a caught exception that is only counted:
case Transfer(_, var amount) -> Money.of("0.20");
try { Integer.parseInt(raw); } catch (NumberFormatException _) { rejected++; }
Flexible constructor bodies
Final in 25 (JEP 513). Statements can now run before super(...) or this(...), so a constructor can validate its arguments before the superclass does any work with them:
class Invoice extends Document {
Invoice(String number, List<Line> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("empty invoice " + number);
super(number);
this.lines = List.copyOf(lines);
}
}
Before 25 the check had to live in a static helper called inside the super(...) argument list.
Stream gatherers
Final in 24 (JEP 485). A gatherer is a custom intermediate operation for a stream, the counterpart of a collector at the end of one. The built-in ones cover windows and running folds:
List<List<Reading>> batches = readings.stream()
.gather(Gatherers.windowFixed(500))
.toList();
Batching a stream for bulk inserts used to need a custom spliterator or a detour through a list.
Module imports and compact source files
import module java.base; imports every package a module exports in one line (JEP 511). Compact source files with instance main methods (JEP 512) let a single-file program skip the class declaration and public static void main(String[] args):
void main() throws IOException {
var rows = Files.readAllLines(Path.of("orders.csv"));
IO.println("Read " + rows.size() + " rows");
}
The JEP aims these at beginners. In a server codebase they are most useful for small scripts and one-off tools that used to be written in another language because Java needed too much ceremony. A compact source file imports the whole java.base module automatically, which is why the example needs no import lines.
Foreign Function and Memory API
Final since 22 (JEP 454). It calls native libraries and manages off-heap memory without JNI, with bounds and lifetime checks that JNI never had. For most business systems it matters indirectly: libraries that wrapped native code with JNI or sun.misc.Unsafe are moving to it, and Java 24 started warning about both of the old routes.
Structured concurrency in Java: from JEP 453 to JEP 505
Structured concurrency treats a group of related tasks running in different threads as one unit of work: they start together, the parent waits for all of them, and when one fails the others are cancelled. It is the piece that makes virtual threads easy to use safely for fan-out calls, and it is still a preview API in 25.
In Java 21 the first preview (JEP 453) used subclasses of StructuredTaskScope for each policy:
// Java 21, preview
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<Customer> customer = scope.fork(() -> crm.find(id));
Supplier<List<Order>> orders = scope.fork(() -> orderService.recent(id));
scope.join().throwIfFailed();
return new Summary(customer.get(), orders.get());
}
The fifth preview in Java 25 (JEP 505) replaced the public constructors with static factory methods. The zero-argument open() waits for all subtasks to succeed or for any one to fail, and other policies are passed in as a Joiner:
// Java 25, preview
try (var scope = StructuredTaskScope.open()) {
Subtask<Customer> customer = scope.fork(() -> crm.find(id));
Subtask<List<Order>> orders = scope.fork(() -> orderService.recent(id));
scope.join();
return new Summary(customer.get(), orders.get());
}
The two versions are not source compatible. The JEP page already lists a sixth preview, JEP 525, so we keep structured concurrency out of production code and use plain executors over virtual threads until the API is final.
Scoped values replace thread-local context
Scoped values are final in 25 (JEP 506), after four previews. A scoped value shares immutable data with the methods a thread calls and with child threads in a structured scope, and it is unbound again when the scope ends:
static final ScopedValue<Tenant> TENANT = ScopedValue.newInstance();
void handle(Request request) {
ScopedValue.where(TENANT, request.tenant())
.run(() -> invoices.process(request));
}
// anywhere below handle(), in the same thread or a forked subtask:
Tenant tenant = TENANT.get();
A ThreadLocal holding a tenant, a user or a correlation id can be set and changed anywhere and leaks if nobody removes it. With a million virtual threads, each carrying its own copy, that cost adds up. A move to virtual threads is the usual moment to replace request context in thread locals with scoped values.
Runtime gains you get without code changes
For a team already on 21, these Java 25 features are the main reason to move: they need a JVM option or nothing at all. Our Java end of life guide covers the measured results in more detail; the short version follows.
- Virtual threads without pinning. From 24, a virtual thread that blocks inside a
synchronizedblock releases its carrier thread (JEP 491). The JEP says this eliminates "nearly all cases" of pinning, the most common reason teams backed off virtual threads on 21. - Compact object headers. Headers shrink from 96 to 128 bits down to 64 bits (JEP 450), a product feature in 25 behind
-XX:+UseCompactObjectHeaders(JEP 519). In the JEP's measurements, SPECjbb2015 used 22% less heap and 8% less CPU time in one setting, and Amazon tested the feature in hundreds of production services, most of them on backports to 17 and 21. - Ahead-of-time cache. A training run records the classes an application loads and links, and later runs start from the cache (JEP 483). In the JEP's example, Spring PetClinic started in 4.486 seconds on JDK 23 and 2.604 seconds on JDK 24 with the cache, 42% faster. Java 25 creates the cache with one option,
-XX:AOTCacheOutput(JEP 514), and stores method profiles in it, so the JIT compiler starts from a warm profile (JEP 515). - Generational Shenandoah is a product feature in 25 (JEP 521), for teams that run Shenandoah rather than G1 or ZGC.
- JDK Flight Recorder gained method timing and tracing that can be switched on without code changes (JEP 520), and experimental CPU-time profiling on Linux (JEP 509).
The startup work matters most for services that scale out under load or restart often in containers. The memory work matters for any heap full of small objects, which describes most business systems.
What Java 25 removes or restricts
An upgrade from 21 also meets a short list of changes that cost work:
| Change | Release | Who it affects |
|---|---|---|
| Security Manager permanently disabled (JEP 486) | 24 | Applications and libraries that install a security manager; the call now fails |
| Non-generational ZGC removed (JEP 490) | 24 | Startup scripts that pass -XX:-ZGenerational |
Warnings on JNI use (JEP 472) and sun.misc.Unsafe memory access (JEP 498) | 24 | Libraries with native code or Unsafe; check that logs and tests tolerate the warnings, and plan library upgrades |
| Windows 32-bit x86 port removed (JEP 479), then the whole 32-bit x86 port (JEP 503) | 24, 25 | Anything still shipped as a 32-bit build |
| String templates withdrawn (JEP 465) | 23 | Code written against the preview in 21 or 22 |
| Warnings when agents are loaded into a running JVM (JEP 451) | 21 | Monitoring and mocking tools that attach at run time; load them with -javaagent instead |
In our upgrades, the JDK change itself is rarely what takes the time. The libraries and the framework do.
Java 17 vs 21, and Java 8 vs 17, for teams further behind
A team on 8, 11 or 17 moving to 25 gets every change above plus everything that came before 21. The language changes since Java 8 add up to a different way of writing Java:
| Since | Feature | JEP |
|---|---|---|
| 10 | var for local variables | JEP 286 |
| 14 | Switch expressions | JEP 361 |
| 14 | Helpful NullPointerExceptions that name the null variable | JEP 358 |
| 15 | Text blocks for multi-line strings | JEP 378 |
| 16 | Records | JEP 395 |
| 16 | Pattern matching for instanceof | JEP 394 |
| 17 | Sealed classes | JEP 409 |
| 17 | Strong encapsulation of JDK internals | JEP 403 |
| 21 | Virtual threads, record patterns, pattern matching for switch, sequenced collections | JEP 444, JEP 440, JEP 441, JEP 431 |
Java 8 vs 17 is the larger jump, and most of its cost is not in the table. Java 9 introduced modules, and Java 17 enforced strong encapsulation of JDK internals, so libraries that reached into sun.* classes through reflection fail at run time until they are upgraded. Java 11 removed the Java EE modules, JAXB and JAX-WS among them, from the JDK (JEP 320), so they come back as dependencies. Frameworks follow: Spring Framework 6 and Spring Boot 3 need Java 17, and moving to them means the javax to jakarta package rename.
Java 17 vs 21 is a much smaller step in effort: few removals, and the gains are virtual threads, the pattern matching features and generational ZGC. For a team on 17 today, though, stopping at 21 makes little sense: Oracle JDK 21 updates already need a paid license, free builds of 21 end earlier than those of 25, and 25 carries the same code changes plus four more releases of runtime work.
Java support roadmap: how long 8, 11, 17, 21 and 25 get updates
Every LTS release is available from several vendors, and each sets its own end dates. Some publish free builds; others sell support. The chart shows the longest each version stays covered, as of October 2026.
| Vendor | Java 8 | Java 11 | Java 17 | Java 21 | Java 25 |
|---|---|---|---|---|---|
| Oracle, extended support (paid) | Dec 2030 | Jan 2032 | Sep 2029 | Sep 2031 | Sep 2033 |
| Eclipse Temurin (free, "at least") | Dec 2030 | Oct 2027 | Oct 2027 | Dec 2029 | Sep 2031 |
| Amazon Corretto (free) | Dec 2030 | Jan 2032 | Oct 2029 | Oct 2030 | Oct 2032 |
| Microsoft Build of OpenJDK (free, earliest end) | no build | Sep 2027 | Sep 2027 | Sep 2028 | Sep 2030 |
| Red Hat, end of ELS-1 (paid) | Dec 2032 | Jan 2032 | Jan 2029 | Sep 2031 | Sep 2033 |
| Azul (paid) | Dec 2030 | Jan 2032 | Sep 2029 | Sep 2031 | Sep 2033 |
Two things stand out. Temurin and Microsoft commit to free builds of 11 and 17 only until 2027, so for teams on those builds the deadline is next year, not 2029. And Java 25 is the only LTS release whose free builds from Temurin, Corretto and Microsoft all run to 2030 or later.
Oracle's own JDK has a separate clock. From the October 2026 update, Oracle JDK 21 updates come under the Java SE OTN license, which needs a paid subscription for production use, and Oracle JDK 25 updates stay free until September 2028 (Oracle roadmap, updated 15 September 2026). How the subscription is priced, and the other ways out, are in our Java end of life dates post.
Is Java modernization worth it? When to upgrade
Most teams are already moving. In JetBrains' 2025 survey, 40% of Java developers used Java 21 regularly, up from 28% a year earlier, and Java 8 fell from 44% to 31%. JetBrains sells Java tools, and respondents could name several versions, so the shares overlap; the direction is the useful part.
Whether an upgrade pays for itself depends on where you start and what the system has to do next. This is the table we use at the start of an engagement:
| You run | Move to | Size of the job | What takes the time |
|---|---|---|---|
| Java 21 | 25, within a year | Small: mostly testing | Library updates for the JNI and Unsafe warnings, a test run, JVM option changes |
| Java 17 | 25, skipping 21 | Moderate | Spring Boot 3.x to 4 if you use Spring, library upgrades, tests on concurrency code |
| Java 11 | 25 | Larger: framework major versions | Spring 5 to 6 or 7 and the jakarta rename, abandoned libraries |
| Java 8 | 25, in planned steps | A project, priced per application | Removed Java EE modules, reflection into JDK internals, every framework major version at once |
Every row assumes a test suite that covers the flows the business relies on. Without one, writing it comes first, and it can take longer than the upgrade itself.
The framework sets most of the order. Spring Boot 4 requires at least Java 17 and is compatible up to Java 26, and Jakarta EE 11 also needs Java 17 or later. A system on Spring Boot 2.7 therefore moves Java and the framework together, and our Spring Boot 4 migration guide covers that order of work.
Our most recent example is an online store and back-office ERP, built up over fifteen years, that we moved from Java 17 and Spring 5.3 to Java 25 and Spring 6.2 in about four weeks, with no rewrite and no feature freeze. The work cut the dependency tree's critical and high-severity advisories from 47 to 0. Most of the time went into the framework and abandoned libraries, and the regression tests we added first caught two breaks before release (case study).
So for most teams the Java 21 vs 25 question is one of timing. When is the upgrade not worth it? When the application is due to be retired within a year and a supported OpenJDK build of its current version covers that year. Everything else is a question of order, not of whether.
If you want that order written down for your systems, our Application Modernization Sprint starts with a thirty-minute scoping call and a written inventory of versions, support dates and the upgrade path for each application, with a ballpark estimate.