Java 21 to Java 25 LTS: Every Feature You Actually Need to Know
Records, pattern-matching switch, virtual threads, sequenced collections, scoped values and structured concurrency from Java 21 to Java 25 LTS, each compiled and run on JDK 21, 25, 26 and 27: what is really final, when virtual threads stopped pinning, and what 26 and 27 mean for the road to Java 29.
Java 21 was the biggest LTS since Java 8, and Java 25 (LTS, September 2025) finalizes most of the preview features that shipped alongside it. If you last touched modern Java during the Java 17 era, the jump to 25 is not just a syntax refresh: it changes how you write concurrent code, pattern-match data, and model domain objects. This post walks through the six features that matter most for day-to-day Java work, with code that was compiled and run on JDK 21, 25, 26 and 27.
It also answers a question the feature lists skip: which of these are actually final? Scoped values are. Structured concurrency, which this page used to call final in 25, is still a preview API in 25, 26 and 27, and section 6 shows what that means for your build. The two releases after 25 get their own section too, so you know what an LTS baseline will and will not include by the time Java 29 arrives. If you are planning the migration itself rather than learning the features, the companion Java 21 to Java 25 Upgrade Guide (with AI Prompts) is the checklist.
Versions. JDK 21.0.12.1 (Amazon Corretto), 25.0.4.1 (Temurin, LTS), 26.0.2.1 and 27 (Corretto; Java 27 reached GA on 15 September 2026), and Corretto 23.0.2 and 24.0.2 from container images wherever the question was “which release changed this?”. Every code block below links to the file it came from in asmhatre/javademos and every output block to the transcript it was copied from. Corrected 21 September 2026: this page called structured concurrency final in Java 25 (it is the fifth preview), its example used a method that does not exist on 26 or 27, its FAQ listed Thread.stop and Object.finalize as removed in 25 (Thread.stop went in 26; finalize still runs), and its virtual-thread prompt still told the AI that synchronized blocks pin carriers, which stopped being true in Java 24.
Which Java release is which?
Java ships a new release every six months, in March and September. Most of them are supported for only six months. Every second one is a long-term-support (LTS) release, and that is the one companies actually run in production: 8, 11, 17, 21 and 25 so far, with the next LTS planned for Java 29 in September 2027 (Oracle’s Java SE Support Roadmap, read on 2026-09-21). That is why this article compares 21 with 25 and treats 22 to 24 as the road between them.
The picture is the whole argument for the “LTS to LTS” upgrade: a feature that starts as a preview in 22 or 23 and is finalised in 24 or 25 arrives in your codebase in one step, already stable. The same picture explains why 26 is awkward to run: its six-month premier-support window ends this month, and 27 has until March 2027. Non-LTS releases are for learning what is coming, and a good way to do that is the run-everything article on Java 27 (link below).
The lane table from 25 to 29 with the support windows: chapter 12.
1. Records — Data Classes, Finally
A record is a class whose only job is to carry data. You list the fields once, in the header, and the compiler writes the constructor, the accessors, equals, hashCode and toString for you. Records are immutable by default, which makes them ideal for DTOs and value objects. The compact constructor is the place for validation: it runs before the fields are assigned, so you never repeat the assignments. The record below is exactly the compact form; without the compact constructor it is a one-line declaration. The code is in HubExamples.java.
public record Customer(Long id, String email, String country) {
public Customer {
Objects.requireNonNull(email, "email must not be null");
if (!email.contains("@")) throw new IllegalArgumentException("Invalid email");
}
}
This is what it printed on JDK 25 (the same lines print on 21, 26 and 27, see transcript 80):
CHECK ok : record accessor: c.email()
CHECK ok : record toString: Customer[id=1, [email protected], country=IN]
CHECK ok : compact constructor rejects an email without @: Invalid email
Records work well as DTOs and projections. They cannot be JPA @Entity classes: JPA needs a no-arg constructor, mutable fields and a non-final class. The FAQ below has the details.
Switch expressions can now destructure records and match by type, which eliminates most instanceof chains. Combined with a sealed interface (an interface that names every class allowed to implement it), the compiler enforces exhaustiveness: if you add a fourth shape and forget to handle it, the code stops compiling instead of failing at run time. A when clause, the guard, refines a match without nesting.
sealed interface Shape permits Circle, Square, Triangle {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
record Triangle(double base, double height) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Square(double side) -> side * side;
case Triangle(double b, double h) -> 0.5 * b * h;
};
}
static String classify(Shape s) {
return switch (s) {
case Circle c when c.r() > 100 -> "large circle";
case Circle c -> "small circle";
case Square sq -> "square";
case Triangle t -> "triangle";
};
Full source: HubExamples.java. Output on JDK 25, identical on the other three JDKs:
CHECK ok : area(Square(3)) == 9.0
CHECK ok : area(Triangle(4, 5)) == 10.0
CHECK ok : guard pattern: Circle(101) is a large circle
CHECK ok : guard pattern: Circle(1) is a small circle
Primitive patterns, the next step, are still a preview in 27 (fifth preview): chapter 5.
Virtual threads are cheap, JVM-managed threads backed by a small carrier-thread pool. You can spawn millions where platform threads would exhaust memory at a few thousand. The API is deliberately identical to Thread, so there are no reactive callbacks to learn: you write the blocking code you always wrote and the JVM parks the virtual thread while it waits.
Ten thousand tasks that each block for 100 ms would take a thousand seconds one after another. The run finishes in seconds (the exact figure depends on the machine, so the transcript asserts it rather than printing it). Source: HubExamples.java; output on JDK 25:
CHECK ok : all 10,000 virtual-thread tasks completed before close() returned
CHECK ok : and it took seconds, not the 1,000 s a serial loop needs (exact time is machine dependent, not printed)
CHECK ok : Thread.ofVirtual().name(..).start(..) gives a virtual thread
There is one caveat that changed while nobody was watching. A virtual thread is mounted on a carrier thread while it runs. If it blocks while it is holding a synchronized monitor, older JVMs could not unmount it, so the carrier was pinned for as long as the thread waited. That advice, “avoid synchronized around blocking calls”, is still repeated everywhere. It stopped being true in Java 24 (JEP 491). The test below gives a single carrier thread to four virtual threads that each sleep 200 ms inside a synchronized block: pinned, they queue up and take about 800 ms; unpinned, they overlap and take about 200 ms.
The diagram is the run in transcript 87, drawn to scale. Java 21 and 23 take the top row, 24, 25, 26 and 27 take the bottom row. The two lines that matter, from the PinningDemo.java run:
$ java -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 PinningDemo (JDK 23, Corretto image)
4 virtual threads, 1 carrier, sleep(200) inside synchronized: SERIALISED (pinned): about 4 x 200 ms
$ java -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 PinningDemo (JDK 24, Corretto image)
4 virtual threads, 1 carrier, sleep(200) inside synchronized: OVERLAPPED (not pinned): about 200 ms
The old rule of thumb is off by one release. The rule of thumb in older articles, including this site’s upgrade guide until today, was that carriers pin on synchronized “on Java 21 to 24”. The measurement says 21 to 23. Native frames on the stack (a JNI or foreign call that calls back into Java) can still pin a carrier, so the audit prompt below still asks about them.
Java 21 added a uniform API for collections that have a defined encounter order. getFirst(), getLast(), addFirst(), addLast() and reversed() now work consistently on List, LinkedHashSet and LinkedHashMap. The thing worth knowing is that reversed() returns a view, not a copy: change the list afterwards and the view changes with it.
List<String> names = new ArrayList<>(List.of("alex", "brian", "chris"));
String first = names.getFirst(); // alex
String last = names.getLast(); // chris
List<String> backwards = names.reversed(); // [chris, brian, alex]
String snapshot = backwards.toString(); // written down before the list changes
names.add("dana"); // reversed() is a view, so it sees this
LinkedHashSet<Integer> nums = new LinkedHashSet<>(List.of(3, 1, 4, 1, 5));
int firstNum = nums.getFirst(); // 3
int lastNum = nums.getLast(); // 5
CHECK ok : List.getFirst() / getLast()
CHECK ok : List.reversed(): [chris, brian, alex]
CHECK ok : reversed() is a view, not a copy: it sees the element added afterwards
CHECK ok : LinkedHashSet.getFirst() == 3, getLast() == 5
Scoped values are an immutable, structured replacement for ThreadLocal. They are safer (read-only in inner scopes), automatically cleaned up when the scope exits, and far cheaper on virtual threads, where a single request may hop across many of them. You bind a value for the duration of a lambda, and every method called from inside it, however deep, can read it without it being passed as a parameter.
static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
public static void main(String[] args) {
System.out.println("java.version = " + System.getProperty("java.version"));
ScopedValue.where(CURRENT_USER, "ankur").run(() -> {
Check.that(CURRENT_USER.get().equals("ankur"), "inside the scope: CURRENT_USER.get() == ankur");
callDeepMethod();
});
try {
CURRENT_USER.get();
Check.that(false, "outside the scope get() should throw");
} catch (NoSuchElementException expected) {
Check.that(true, "outside the scope: CURRENT_USER.get() throws NoSuchElementException");
}
}
static void callDeepMethod() {
Check.that(CURRENT_USER.get().equals("ankur"), "a method that was never passed the value still sees it");
}
This is one of the two features from this article that changed status between the releases it covers, so the transcript (transcript 81) shows both sides: JDK 21 refuses to compile the file without --enable-preview because ScopedValue is a preview API there, and JDK 25 (and 26 and 27) compiles and runs it with no flags. Source: ScopedValueDemo.java.
$ javac ScopedValueDemo.java (JDK 21, no flag)
<repo>/lanes/src/ScopedValueDemo.java:9: error: ScopedValue is a preview API and is disabled by default.
3 errors
exit=1
$ java ScopedValueDemo (JDK 25)
CHECK ok : inside the scope: CURRENT_USER.get() == ankur
CHECK ok : a method that was never passed the value still sees it
CHECK ok : outside the scope: CURRENT_USER.get() throws NoSuchElementException
6. Structured Concurrency (JEP 505, Still a Preview in Java 25)
Structured concurrency treats a group of concurrent subtasks as a single unit of work. When the scope closes, every forked task that has not finished is cancelled. That removes the classic bug where one call fails and its siblings keep running in the background, unowned. It reads like a sequential call stack: you open a scope, fork, join, and the scope’s closing brace is the guarantee.
The code runs on Java 25, but only with --enable-preview. Without it javac refuses every mention of StructuredTaskScope, exactly as it refused ScopedValue on 21. JEP 505 in Java 25 is the fifth preview, the sixth is JEP 525 in 26 and the seventh is JEP 533 in 27. Source: StructuredHub.java; transcript: transcript 82.
$ javac StructuredHub.java (JDK 25, no flag)
<repo>/lanes/src/StructuredHub.java:1: error: StructuredTaskScope is a preview API and is disabled by default.
5 errors
exit=1
$ java --enable-preview StructuredHub (JDK 25)
CHECK ok : fan-out: both results joined: Dashboard[user=user-7, order=order-for-7]
CHECK ok : anySuccessfulResultOrThrow(): the first result wins and the slow subtask is cancelled
Preview APIs are not stable APIs. A preview feature can change or vanish in the next release, and this one did. The factory method the code above calls, Joiner.anySuccessfulResultOrThrow(), was renamed to anySuccessfulOrThrow() in Java 26, and in 27 Joiner gained a third type parameter for the exception join() throws. If you use StructuredTaskScope in a build that targets 25, keep --enable-preview and expect to touch the code again.
The rename is visible in the JDK itself. This is javap on the Joiner class of each release (transcript 89), reduced to the one method, followed by the same example compiled on 26 with its old name and then run under its new one:
== JDK 25
public static <T> StructuredTaskScope$Joiner<T, T> anySuccessfulResultOrThrow();
== JDK 26
public static <T> StructuredTaskScope$Joiner<T, T> anySuccessfulOrThrow();
== JDK 27
public static <T, R_X extends Throwable> StructuredTaskScope$Joiner<T, T, R_X> anySuccessfulOrThrow(Function<Throwable, R_X>);
public static <T> StructuredTaskScope$Joiner<T, T, ExecutionException> anySuccessfulOrThrow();
$ javac --enable-preview --release 26 StructuredHub.java (JDK 26)
symbol: method <String>anySuccessfulResultOrThrow()
exit=1
$ java --enable-preview StructuredHubNew (JDK 26)
CHECK ok : anySuccessfulOrThrow(): the first result wins
You do not have to run a non-LTS release to know what a jump from 25 to 29 will look like, because 26 and 27 are the two rehearsals. The table takes the six features above, and the other things from the 21 to 25 list that moved, and says what each release does. Every cell is a run: chapter 14 maps each row to its transcript.
Two rows deserve a sentence. The G1 and object-header rows are the changes an ordinary service will notice without touching any code: memory use, pause times and heap sizing all move, and the article on Java 27 Is Out: Every JEP, Plus the Java 26 Changes You Skipped measures them in a one-CPU container. The reflection row is the one that turns into a red build later. Java 26 only warns when code writes to a final field through reflection, but the warning is a promise that a future release will refuse, and mocking, dependency-injection and serialisation libraries are the usual offenders.
Should you stop at 25? For most teams, yes, and skip the non-LTS releases until 29. The exceptions are teams that want the 27 changes early, such as G1 as the default and compact object headers, and teams that need a security or performance fix that will not be backported. Whatever you choose, run your test suite on the newest release from time to time: the surprises are cheaper to find in the two-year run-up than in the last month before an LTS switch.
The full 25 to 27 upgrade checklist, ordered by what stops a service from starting: chapter 13.
Records generate a canonical constructor and public accessor methods at compile time. The compact constructor form lets you add validation without repeating the field assignments.
Pattern matching deconstructs a sealed hierarchy exhaustively: the compiler rejects the switch if any permitted subtype is missing. Guard clauses (when) let you add fine-grained conditions without nesting.
Virtual threads are mounted onto a small pool of carrier threads. When a virtual thread calls a blocking operation (sleep, a socket read, a JDBC call), it is unmounted from its carrier so the carrier can run another virtual thread. The virtual thread resumes on any available carrier when the blocking call returns. Since Java 24 that also holds inside a synchronized block.
Sequenced collections unify first/last access across all ordered JDK types through three new interfaces: SequencedCollection, SequencedSet, and SequencedMap.
Scoped values bind data to a dynamic scope (a call tree) rather than a thread, so they propagate naturally across the fork boundaries of structured concurrency.
Structured concurrency enforces a strict ownership relationship: the scope that forked a task is responsible for its lifetime. This makes concurrent code auditable the same way a sequential call stack is. It is still a preview API.
AI Prompts You Can Use
Copy these into Claude or ChatGPT and attach your file to apply each feature to your own code. Each prompt is also a file in the repository, so you can copy it from there or diff it against a newer version.
Prompt 1 — Convert POJOs to Records
What it does: Reviews a Java class and converts it to a record if it is a pure data carrier. Moves constructor validation into a compact constructor. Flags classes that cannot be converted (mutable state, inheritance, JPA @Entity) and explains the reason for each.
When to use it: During a modernization pass on DTO, request/response, and value-object classes. File: lanes/prompts/features-1-records.txt.
Review the attached Java class and convert it to a Java 21 record if it is a pure data carrier. Preserve all validation from the old constructor inside a compact constructor. Flag fields that prevent conversion (e.g. mutable state, inheritance) and explain why.
Prompt 2 — Modernize instanceof Chains
What it does: Rewrites every if (x instanceof Type) { Type t = (Type) x; ... } pattern to pattern matching. Introduces a sealed interface if the types form a closed hierarchy. Ensures the resulting switch is exhaustive.
When to use it: Any file with long if-instanceof chains, visitor implementations, or type-dispatch logic. File: lanes/prompts/features-2-pattern-switch.txt.
Refactor this Java code to use Java 21 pattern matching for switch. Introduce a sealed interface if the types form a closed hierarchy. Ensure the switch is exhaustive and remove all redundant casts and intermediate variables.
Prompt 3 — Migrate to Virtual Threads
What it does: Finds every Executors.newFixedThreadPool and newCachedThreadPool in a project and replaces them with newVirtualThreadPerTaskExecutor() where the tasks are I/O-bound. Lists cases where platform threads should be kept and justifies each retention.
When to use it: When starting a migration to virtual threads: get the full picture before changing any code. The prompt now knows that synchronized only pins on 21 to 23. File: lanes/prompts/features-3-virtual-threads.txt.
Find every usage of Executors.newFixedThreadPool and Executors.newCachedThreadPool in this project. Replace with Executors.newVirtualThreadPerTaskExecutor() where the tasks are I/O-bound. List any cases where platform threads should be kept (CPU-bound work, native frames on the stack, and on Java 21 to 23 also synchronized blocks that block, which pin the carrier thread until Java 24) and justify each decision.
Prompt 4 — Adopt Sequenced Collections
What it does: Replaces verbose first/last access patterns (list.get(0), list.get(list.size()-1), Collections.reverse(list)) with the new Java 21 sequenced API. Keeps behaviour identical.
When to use it: Part of a Java 21+ modernization pass: quick wins in readability with zero behavioural risk. File: lanes/prompts/features-4-sequenced-collections.txt.
Rewrite this code to use Java 21 sequenced collection APIs (getFirst, getLast, reversed) instead of list.get(0), list.get(list.size()-1), and manual reversal. Keep behaviour identical.
Prompt 5 — Replace ThreadLocal with ScopedValue
What it does: Identifies ThreadLocal usages in a file and proposes a migration to ScopedValue (final in Java 25). Shows before/after code and explains why ScopedValue is safer under virtual threads and structured concurrency.
When to use it: After migrating to virtual threads, when you need to pass request-scoped data (user ID, trace ID, tenant) without threading it through every method signature. File: lanes/prompts/features-5-scoped-values.txt.
Identify ThreadLocal usages in this file. Propose a migration to Java 25 ScopedValue, showing before/after code and explaining why ScopedValue is safer under virtual threads and structured concurrency.
Prompt 6 — Structured Concurrency Refactor
What it does: Converts a CompletableFuture.allOf fan-out pipeline to a StructuredTaskScope. Explains how error propagation and automatic cancellation differ from the CompletableFuture approach. It now tells the model that the API is a preview and that the factory-method names differ between 25 and 26.
When to use it: When you have parallel fan-out logic that needs proper cancellation on failure, a common pattern in microservice aggregators, and you accept a preview API. File: lanes/prompts/features-6-structured-concurrency.txt.
Refactor this CompletableFuture.allOf pipeline to use StructuredTaskScope. Note that StructuredTaskScope is still a preview API in Java 25, 26 and 27, so the build needs --enable-preview and the code must use the factory-method names of the JDK it targets (Joiner.anySuccessfulResultOrThrow() on 25, anySuccessfulOrThrow() on 26 and 27). Preserve error propagation semantics and show what happens on cancellation. Explain the difference from the original CompletableFuture approach.
Yes for almost all application code. The changes that bite are these, each checked on a JDK: you can no longer install a Security Manager (System.setSecurityManager throws on 25, and already did on 21 by default); Thread.suspend and resume still compile on 21 and do not on 23 or later; Thread.stop compiles on 25 but throws, and is gone in 26; Object.finalize still runs unless you pass --finalization=disabled; and sun.misc.Unsafe memory methods and JNI loading print run-time warnings from Java 24. There is also the migration from javax.* to jakarta.* if you have not done it for Java EE dependencies. Transcripts: 83, 84, 85, 86.
Do virtual threads replace reactive frameworks?
For most I/O-bound services, yes: you can write straight-line blocking code and get throughput comparable to reactive approaches. Reactive still has advantages for backpressure-heavy streaming pipelines (processing millions of events with fine-grained flow control) or when you need the functional composition style of Project Reactor or RxJava.
Can I use records with JPA/Hibernate?
Records work well as DTOs and projections (read-only query results). They cannot be JPA @Entity classes because JPA requires a no-arg constructor, mutable fields, and non-final classes. See the Hibernate Tutorials series for projection patterns.
Is structured concurrency safe to use in production on 25?
It runs, but it is a preview API: your build needs --enable-preview, your class files are tied to that exact release, and the API has already changed twice between 25 and 27. Use it in code you are willing to touch again, and prefer a plain ExecutorService with virtual threads where the cancellation guarantee is not essential.
Conclusion
Java 25 LTS is where Loom and Amber land their finished pieces: records, pattern matching, virtual threads and scoped values change how you model data and run code, and the synchronized-pinning problem that made early adopters nervous is gone. Structured concurrency is the piece that has not landed yet, and it is still moving. Together they make idiomatic Java read closer to Kotlin or Scala without sacrificing the ecosystem. The six prompts above give you a practical route to applying each feature to an existing codebase today.
No Comments yet!