Java 21 to Java 25 Upgrade Guide (with AI Prompts)
A step-by-step Java 21 to 25 upgrade guide: Maven and Gradle files, Lombok and Mockito versions, removed and warned APIs, a 12-item checklist and seven AI prompts. Every claim was run on JDK 21, 25, 26 and 27, and the old guide’s mistakes are corrected.
Java 25 shipped as an LTS release in September 2025. If you are already on Java 21 LTS, the jump is mostly smooth: several features that were previews in 21 are final now. But there are a handful of removals, build-tool bumps and warnings that will stop a build if you do not prepare for them. This guide walks through the upgrade end to end, in six stages, with a migration checklist and AI prompts to automate the tedious parts.
An upgrade fails in one of three places: the JDK that compiles your code, the JDK that runs it, or a library in between that has to read the class files. Every step below is aimed at one of them, and the ones that broke in the runs for this page (Lombok, Mockito, Gradle) all turned out to be the third kind. If you want the language features rather than the migration, the companion is Java 21 to Java 25 LTS: Every Feature You Actually Need to Know.
Versions. JDK 21.0.12.1 (Amazon Corretto), 25.0.4.1 (Temurin, LTS), 26.0.2.1 and 27 (Corretto), with Corretto 23 and 24 from container images where the question was “which release changed this?”. Maven 3.9.11 (compiler plugin 3.13.0), Gradle 8.14.3 and 9.1.0, Lombok 1.18.34, 1.18.40, 1.18.46, 1.18.48, Mockito 5.12.0 to 5.23.0. Every code block 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 said Spring Boot 3.4 was the first release with Java 25 support (its own documentation stops at Java 24; use 3.5.x or 4.x), that Lombok 1.18.34 and Mockito 5.12 were enough (both fail on 25), that Hibernate 7 needs Jakarta EE 10 (it needs EE 11) and drops a “CGLib enhancer” (I could not verify that, so it is gone), that structured concurrency is final in 25 (it is still a preview), that Thread.stop and finalization are removed (Thread.stop was removed in 26; finalization is still on), that synchronized blocks pin on Java 21 to 24 (21 to 23), that Java 25 “improves generational ZGC”, and that IntelliJ and GraalVM support Java 25 without a source for either.
The upgrade in one picture
Upgrading a Java project is really six small upgrades in a fixed order. You change the build first, because nothing else can be tested until the project compiles. Then you bring the libraries up, because they are what usually breaks. Then you clean out flags and APIs that no longer apply, and only at the end do you start writing new-style code. The order matters because each stage gives you a build you can commit: if stage four goes badly, stage three is a clean place to return to.
The sixth box is optional and is the reason this page grew a second half: Java 26 and 27 are already out, and a team that rehearses on them a little at a time has an easier jump to the next LTS, Java 29 in September 2027 (Oracle’s Java SE Support Roadmap, read on 2026-09-21).
A checklist ordered by what stops a service from starting: chapter 13.
Step 1 — Bump Your Build Files
The first thing to change is your compiler release target. For Maven, set all three properties, so the language level, the class-file version and the API you may call agree with each other. The Gradle equivalent is a toolchain: you name the Java version you want and Gradle finds a matching JDK.
Both files come from a working project, lanes/upgrade: pom.xml and build.gradle.kts.
<properties>
<!-- Maven (pom.xml): keep all three in step. source and target set the language level and the class-file version; release also limits the API you may call -->
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
Use maven.compiler.release (not just source and target). Here is what happens when the build JDK is not yet 25, and when it is (transcript 92):
$ mvn package (build JDK 21)
[INFO] Compiling 1 source file with javac [debug release 25] to target/classes
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile (default-compile) on project upgrade-demo: Fatal error compiling: error: release version 25 not supported -> [Help 1]
$ mvn package (build JDK 25)
[INFO] Compiling 1 source file with javac [debug release 25] to target/classes
[INFO] BUILD SUCCESS
$ javap -v -cp target/classes demo.App | grep 'major version'
major version: 69
$ java -cp target/classes demo.App (JDK 21: the class file is newer than the JVM)
java.lang.UnsupportedClassVersionError: demo/App has been compiled by a more recent version of the Java Runtime (class file version 69.0), this version of the Java Runtime only recognizes class file versions up to 65.0
The first failure is the everyday one: release can only name a release the compiling JDK knows, so the build JDK has to move to 25 before the setting can. The last line is the mirror image at run time: a class file compiled for Java 25 is version 69, and a Java 21 JVM (which understands up to 65) refuses to load it. A newer JDK can still compile down: in the same transcript (transcript 92), JDK 27 builds the project with release at 25.
$ mvn package (build JDK 27, still release 25)
[INFO] Compiling 1 source file with javac [debug release 25] to target/classes
[INFO] BUILD SUCCESS
The reason to prefer release over source and target is subtler than this page used to say. It does not reject internal APIs; it limits the compiler to the API of that release. The demonstration uses JDK 27’s compiler and one method, String.equalsFoldCase, that was added in Java 26.
Without release your build can compile against an API newer than the JVM you ship on, and the mistake surfaces as a NoSuchMethodError in production. The files are ReleaseFlag.java and the run is transcript 94:
$ javac --release 25 ReleaseFlag.java
<repo>/lanes/src/ReleaseFlag.java:8: error: cannot find symbol
symbol: method equalsFoldCase(String)
$ javac -source 25 -target 25 ReleaseFlag.java
warning: [options] location of system modules is not set in conjunction with -source 25
--release 25 is recommended instead of -source 25 -target 25 because it sets the location of system modules automatically
exit=0
$ java -cp rf-b ReleaseFlag (JDK 25)
Exception in thread "main" java.lang.NoSuchMethodError: 'boolean java.lang.String.equalsFoldCase(java.lang.String)'
Gradle has a second version question that has nothing to do with the toolchain block. The toolchain says which JDK compiles your code; a different JDK runs Gradle itself, and Gradle has to understand that JDK too. Gradle 8.14.3 was happy to compile with a Java 25 toolchain while itself running on 21, but failed as soon as Gradle was run on 25. Gradle 9.1.0 ran on 25 without complaint.
$ gradle run (Gradle 8.14.3 running on JDK 21, toolchain finds JDK 25)
running on Java 25
$ gradle run (Gradle 8.14.3 running on JDK 25)
FAILURE: Build failed with an exception.
* What went wrong:
25.0.4.1
$ gradle run (Gradle 9.1.0 running on JDK 25)
running on Java 25
The run is transcript 93. Gradle’s own Gradle compatibility table (read on 2026-09-21) agrees: Java 25 needs Gradle 9.1.0, Java 26 needs 9.4.0, and Java 27 is not listed for any Gradle release yet.
The build tool moves first. Because Gradle has no release that runs on Java 27, a Gradle project cannot yet do its own build on 27. The workable rehearsal, covered in Step 6, is a separate job that runs your tests on 27 and leaves the production build on 25.
A new Java release changes the class-file version, and anything that reads or rewrites class files has to learn it. That is a wider group than it sounds: annotation processors such as Lombok run inside the compiler, Mockito generates classes while your tests run, and build tools compile their own scripts. Each of them carries its own list of Java versions it knows, and a release from before Java 25 existed is likely to fail on it.
The diagram is the working model for this step. When a library breaks on a new Java, a common cause is that one of the tools in the right-hand column did not yet know the class-file version. The table below therefore gives, for each one, the first version that is known to work, and how that was established. Where a row says “run”, the result comes from a transcript in the companion repository; where it says “page”, it comes from the project’s own documentation, read on 2026-09-21, and I did not run it.
Dependency
What Java 25 needs
How this was established
Spring Boot
3.5.x or 4.x. 3.4.x is documented up to Java 24; 3.5.x up to Java 25; 4.1.1 up to Java 26; 27 is not listed yet
Lombok is the clearest case, because you can see the failure. The probe is one class with one @Getter (LombokProbe.java), compiled with each Lombok version on each JDK. It is a lower bound, since one annotation does not exercise everything Lombok does, and the changelog remains the support statement:
Lombok 1.18.34 on JDK 25: javac failed: java.lang.ExceptionInInitializerError
Lombok 1.18.40 on JDK 25: compiled; getName() = ankur
Lombok 1.18.40 on JDK 27: javac failed: java.lang.ExceptionInInitializerError
Lombok 1.18.48 on JDK 27: compiled; getName() = ankur
JDK 21, Lombok 1.18.48 on the class path only: exit=0 Note: Annotation processing is enabled because one or more processors were found
JDK 23, Lombok 1.18.48 on the class path only: exit=1 <repo>/lanes/src/LombokProbe.java:11: error: cannot find symbol
JDK 25, Lombok 1.18.48 on the class path only: exit=1 <repo>/lanes/src/LombokProbe.java:11: error: cannot find symbol
The first four lines are the version story: 1.18.34 fails on 25 with an ExceptionInInitializerError inside the compiler, 1.18.40 is the first that works on 25, and JDK 27 needs 1.18.48. The last three lines are a separate trap that comes with the same class. Since JDK 23, javac no longer runs annotation processors it merely finds on the class path: JDK 21 compiled the probe with a note, and 23 and 25 report cannot find symbol for a method Lombok should have generated. Maven and Gradle builds that declare the processor (annotationProcessorPaths, or Gradle’s annotationProcessor configuration) are not affected. Hand-run javac lines and IDE settings that relied on discovery are. Transcript: transcript 95.
Mockito’s failure is quieter until you run a test. The probe (MockitoProbe.java) mocks an interface and stubs one call; the output is from transcript 97:
Mockito 5.12.0 (ByteBuddy 1.14.15) on JDK 21: works: mock says hi
Mockito 5.12.0 (ByteBuddy 1.14.15) on JDK 25: FAILS: Mockito cannot mock this class: interface MockitoProbe$Greeter.
Mockito 5.17.0 (ByteBuddy 1.15.11) on JDK 25: FAILS: Mockito cannot mock this class: interface MockitoProbe$Greeter.
Mockito 5.18.0 (ByteBuddy 1.17.5 ) on JDK 25: works: mock says hi
Mockito 5.23.0 (ByteBuddy 1.17.7 ) on JDK 27: works: mock says hi
The mechanism is the one in the diagram: Mockito asks ByteBuddy to instrument the mocked type at run time, and the only thing that differs between the failing and the working rows is the ByteBuddy each version declares (1.14.15 and 1.15.11 fail, 1.17.5 and 1.17.7 work). So “Mockito 5.12+”, as this page used to say, was wrong: 5.12 works on 21 and fails on 25 and 27. Even the working versions leave two notices on stderr on Java 25, that Mockito is self-attaching and that a Java agent was loaded dynamically, and the first says the self-attach will stop working in a future JDK. The lasting fix is to register Mockito as an agent in the test JVM, as its documentation describes.
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (<repo>/build/upgrade/mockito/5.23.0/lib/byte-buddy-agent-1.17.7.jar)
What each dependency’s failure looks like, with the exact commands: chapter 15.
A preview feature is finished enough to try but not yet part of the standard, so javac and java refuse it unless you pass --enable-preview. When a feature graduates, the flag becomes unnecessary; when it does not, deleting the flag breaks the build. So the rule for this step is not “delete every flag”. It is “delete a flag only if the feature it enabled is final”. Three things that were previews in Java 21 are final in 25 and can lose the flag: scoped values (JEP 506), unnamed variables and patterns, the underscore (JEP 456, final in Java 22), and compact source files with an instance main (JEP 512). One is not final: structured concurrency (JEP 505) is still a preview in Java 25.
Here is the evidence for each, from transcript 81, transcript 90 and transcript 82. Scoped values first: JDK 21 refuses the file, JDK 25 compiles it with no flag (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
$ javac ScopedValueDemo.java (JDK 25, no flag)
exit=0
The underscore and the compact source file behave the same way, JDK 21 refuses and JDK 25 accepts with no flag (UnnamedVariables.java, CompactHello.java):
$ javac UnnamedVariables.java (JDK 21, no flag)
<repo>/lanes/src/UnnamedVariables.java:12: error: unnamed variables are a preview feature and are disabled by default.
exit=1
$ javac UnnamedVariables.java (JDK 25, no flag)
exit=0
$ java CompactHello.java (JDK 25, no flag)
compact source file: no class, no static, no String[] args
Structured concurrency is the exception, and the one this page used to get wrong. On Java 25 without --enable-preview, javac still refuses it (StructuredHub.java):
$ 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
Keep the flag if you use StructuredTaskScope. If your code uses StructuredTaskScope, keep --enable-preview on 25, 26 and 27. It is the fifth preview in 25 (JEP 505), the sixth in 26 (JEP 525) and the seventh in 27 (JEP 533), and the factory method Joiner.anySuccessfulResultOrThrow() was renamed in 26. The feature article, linked below, has the run that shows it.
Remember to look everywhere a flag can hide: the Maven or Gradle compiler arguments, the test runner’s JVM arguments (Surefire’s argLine, Gradle’s jvmArgs), Dockerfile entry points, IDE run configurations, and JAVA_TOOL_OPTIONS in deployment manifests.
The following changes are the ones most likely to break existing code loudly or, worse, to change its behaviour quietly. The table gives the state of each on the releases that were actually run; “not tested” means I did not run it on that release, which is not the same as fine.
Each row is a transcript: 84, 85, 83, 86, 88, 95. The important ones in detail:
The Security Manager cannot be installed. Code that calls System.setSecurityManager() gets an UnsupportedOperationException. On Java 21 this already happened by default; on 25 the message is that setting a Security Manager is not supported. Replace it with OS-level sandboxing or Java agents.
$ java SecurityManagerGone (JDK 21)
setSecurityManager: UnsupportedOperationException: The Security Manager is deprecated and will be removed in a future release
$ java SecurityManagerGone (JDK 25)
setSecurityManager: UnsupportedOperationException: Setting a Security Manager is not supported
Thread.stop, suspend and resume. This page used to say all three were removed. The truth is finer: suspend and resume do not compile on 23, 24 or 25, but stop() still compiles on 25 and throws at run time, so a codebase can pass the compiler and fail in production. It was removed in 26. The sources are ThreadStopRuntime.java and ThreadSuspendGone.java.
Finalization is not removed. It was deprecated for removal in Java 18, and this page claimed it was “removed by default”. It is not: a finalize() method still runs on 21, 25, 26 and 27, and stops only if you pass --finalization=disabled (FinalizerDefault.java). Replace it with try-with-resources or java.lang.ref.Cleaner anyway: it has been deprecated for removal since Java 18.
Unsafe and JNI warn on stderr from Java 24. Both are silent on 21 and print a warning on 24 and 25. The warnings are not fatal, but they are what a future release will turn into errors. sun.misc.Unsafe memory access has replacements in java.lang.foreign.MemorySegment (JEP 454, final since Java 22), and the JNI one is silenced by opting in with --enable-native-access=ALL-UNNAMED once you have audited the native libraries. The probes are UnsafeWarning.java and JniWarning.java:
$ java UnsafeWarning (JDK 25)
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
$ java JniWarning (JDK 25)
WARNING: A restricted method in java.lang.System has been called
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
You can find internal-API users before they run. jdeps --jdk-internals reads class files and names the JDK internals they touch; it is the second item of the checklist below. Here it is on the compiled UnsafeWarning class (transcript 96):
$ jdeps --jdk-internals --multi-release 25 UnsafeWarning.class
UnsafeWarning.class -> jdk.unsupported
UnsafeWarning -> sun.misc.Unsafe JDK internal API (jdk.unsupported)
sun.misc.Unsafe See https://openjdk.org/jeps/260
The class-path annotation processor default (Step 2) belongs in this list too. It is the change most likely to make a build fail with an error that mentions a method you never wrote, because the method was supposed to be generated.
One bullet was dropped. This section used to end with “stricter strong encapsulation: some internal JDK APIs accessible via --add-opens in Java 21 may now require explicit module declarations”. I could not tie that to a JEP or reproduce it, so it is removed rather than repeated. If you know a concrete case, the comments are open.
How each of these behaves on 21, 23, 24, 25, 26 and 27: chapter 14.
Upgrading without adopting new idioms is half the job. These are the highest-value migrations, and each one is a section of the feature article with code that ran on JDK 21, 25, 26 and 27.
Replace
With
Where it is explained
ThreadLocal
ScopedValue: structured, immutable and much cheaper on virtual threads. Final in 25
Do these after the project is green, in small commits, one idiom at a time. None of them is needed for the upgrade itself, and the last one carries a real cost while it is a preview. The prompts below do the mechanical part of each.
For most teams the answer is to stay on the LTS and skip to 29. Java 26 and 27 are six-month releases. Per Oracle’s Java SE Support Roadmap, Java 26’s premier support ends in September 2026, Java 27’s in March 2027, and Java 25’s not until September 2030. That is the reason to stop at 25. The reason to look at 27 anyway is that its changes arrive in one go in 29, and a service is easier to move if the surprises were found earlier.
Release
GA
Kind
Premier support until
25
Sept 2025
LTS
Sept 2030
26
March 2026
non-LTS
Sept 2026
27
Sept 2026
non-LTS
March 2027
28
March 2027
non-LTS, planned
Sept 2027
29
Sept 2027
LTS, planned
Sept 2032
Four things change between 25 and 27 that a 21-era codebase is likely to meet. The first is -XX:-ZGenerational, which 25 ignores with a warning and 26 and 27 reject (transcript 88):
$ java -XX:+UseZGC -XX:-ZGenerational -Xlog:gc+init -version (JDK 25)
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
$ java -XX:+UseZGC -XX:-ZGenerational -Xlog:gc+init -version (JDK 26)
Unrecognized VM option 'ZGenerational'
The second is reflection that writes to a final field. It is silent on 25 and warns on 26 (JEP 500), and the same run on 27 gives the same warning (transcript 70, transcript 91):
=== JDK 26, defaults
WARNING: Final field port in class FinalFieldMutation$Config has been mutated reflectively by class FinalFieldMutation in unnamed module @<hash> (file:<repo>/build/recap26/26/)
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
The third is the default collector in a one-CPU container. Java 26 picks Serial there and Java 27 picks G1 (transcript 01), so a container limit that gave you one collector on 26 gives you another on 27:
The toolchain has to catch up first. As of 2026-09-21 Gradle’s compatibility table lists nothing newer than Java 26 (Gradle 9.4.0), and Spring Boot’s system-requirements page lists 4.1.1 up to Java 26. Lombok needs 1.18.48 for 27, and Mockito needed a ByteBuddy that knows the version. So the practical rehearsal is a CI job that runs your tests on 27 in a container, with the production build left on 25, and JDK 27 building the same project with release at 25 is what Step 1 showed.
The lane table from 25 to 29 with the support windows: chapter 12.
Update maven.compiler.release or the Gradle toolchain to 25, and move the build JDK to 25 first. If you use Gradle, upgrade it to 9.1.0 or later (transcript 93).
Run jdeps --jdk-internals on every module jar to find internal API usage (transcript 96).
Remove --enable-preview wherever it enabled a feature that is now final (scoped values, the underscore, compact source files). Keep it if you use StructuredTaskScope.
Upgrade Spring Boot (3.5.x or 4.x), Hibernate 7.x, Lombok (1.18.40 or later), JUnit 6.x and Mockito (5.18.0 or later). Make sure annotation processors are declared explicitly, not found on the class path (transcript 95, transcript 97).
Replace all SecurityManager usage.
Replace finalizers with Cleaner or AutoCloseable. They still run on 25, but that is a deprecation, not a promise.
Search for Thread.stop, suspend and resume. Only stop compiles on 25, and it throws.
Run the full test suite; fix reflection breakage and module-access errors. Expect Unsafe and JNI warnings on stderr, and decide whether to add --enable-native-access=ALL-UNNAMED after auditing native libraries.
Switch I/O-bound executors to newVirtualThreadPerTaskExecutor().
Migrate ThreadLocal to ScopedValue where thread scope is not needed.
If you build native images, check your GraalVM distribution’s own support matrix for Java 25 before you change the toolchain. This page no longer claims one.
Measure startup time and GC pause times before and after. Generational is the only ZGC mode from Java 24, so delete -XX:-ZGenerational if you pass it: 24 and 25 ignore it with a warning and 26 and 27 refuse to start.
AI Prompts You Can Use
Each prompt below is designed for a specific upgrade task. Paste it into Claude, ChatGPT or Gemini and attach the relevant file(s). Each prompt is also a file in the repository, so you can copy it from there or diff it against a newer version. Prompts 1 to 6 were corrected on 21 September 2026 to match the runs above, and prompt 7 is new.
Prompt 1 — Upgrade pom.xml
What it does: Rewrites your Maven build file to target Java 25 in a single AI-generated diff, bumping all the common framework versions simultaneously. Saves the tedious cross-referencing of compatibility matrices, and now names the right minimum versions.
When to use it: At the very start of the upgrade — get the build green first, then deal with code changes. File: lanes/prompts/upgrade-1-pom.txt.
Here is my pom.xml targeting Java 21. Update it to target Java 25. Bump Spring Boot to the latest 3.5.x or 4.x release (3.4.x is documented only up to Java 24), Hibernate to 7.x (which needs Jakarta Persistence 3.2), Lombok to 1.18.40 or later, JUnit to 6.x, and Mockito to its current 5.x release. List any dependency that does not have a Java 25 compatible version and suggest alternatives.
Prompt 2 — Find Breaking Changes
What it does: Scans a Java file for the specific API removals and deprecations that are most likely to break in Java 25: SecurityManager, Thread.stop/suspend/resume, Object.finalize, sun.misc.Unsafe memory methods, JNI loading, and old java.net.URL constructors. For each finding it names the release that changed it and produces a prioritized fix list.
When to use it: After bumping the compiler release — most breaks will surface here before you ever run the tests. File: lanes/prompts/upgrade-2-breaking-changes.txt.
Scan this Java file for usages of SecurityManager, Thread.stop/suspend/resume, Object.finalize, sun.misc.Unsafe memory methods, System.loadLibrary and other JNI loading, and the old java.net.URL constructors. For each finding, say which Java release changed it (SecurityManager: cannot be installed since 24; Thread.suspend/resume: still compile on 21, gone by 23; Thread.stop: throws UnsupportedOperationException on 21 and 25, removed in 26; finalize: still runs, deprecated for removal; Unsafe memory access and JNI loading: run-time warnings from 24) and give a replacement with a before/after code snippet.
Prompt 3 — Remove Preview Flags
What it does: Reviews a Maven or Gradle build file and lists every --enable-preview compiler and runtime argument, names the preview feature the code actually uses, and removes the flag only where that feature is final in Java 25. It keeps the flag for structured concurrency.
When to use it: Once your dependencies are updated and the project compiles on Java 25, run this to clean up preview flags. File: lanes/prompts/upgrade-3-preview-flags.txt.
Review the attached Maven/Gradle build file and list every --enable-preview compiler and runtime argument. For each one, name the preview feature the code actually uses. Remove the flag only if that feature is final in Java 25 (scoped values, JEP 506, are final in 25; StructuredTaskScope is still a preview API in 25, 26 and 27, so a project that uses it must keep the flag). Explain each decision.
Prompt 4 — Modernize Idioms
What it does: Applies four Java 25 idioms to an existing file in one pass: records for data carriers, pattern-matching switch for type checks, sequenced collection APIs (getFirst/getLast), and ScopedValue instead of ThreadLocal. All changes preserve the original behaviour.
When to use it: After the project is green on Java 25 — this is the “nice to have” modernization pass that makes the codebase idiomatically Java 25. File: lanes/prompts/upgrade-4-modernize-idioms.txt.
Apply these Java 25 idioms to this file: records for data carriers, pattern-matching switch for type checks, sequenced collection APIs (getFirst/getLast), and ScopedValue instead of ThreadLocal. Preserve behaviour exactly and add a comment for each change explaining why the new form is preferred.
Prompt 5 — Virtual-Thread Safety Audit
What it does: Checks a class for the patterns that cause problems when you flip to a virtual-thread executor: ThreadLocal usage, JNI calls and native frames that still pin the carrier, synchronized blocks around I/O (harmless from Java 24, pinning on 21 to 23), and code that implicitly assumes a bounded thread count. Returns a risk report with line numbers.
When to use it: Before switching any executor to newVirtualThreadPerTaskExecutor() — always audit first. File: lanes/prompts/upgrade-5-virtual-thread-audit.txt.
Before I flip my executors to virtual threads on Java 25, audit this class for: ThreadLocal usage, JNI calls or native frames that stay on the stack while blocking (these still pin the carrier), synchronized blocks around I/O (harmless on Java 24 and later, pinning on 21 to 23), and any code that assumes a bounded thread pool. Report risks with line numbers and a severity (blocking/warning/low).
Prompt 6 — Upgrade Checklist Runner
What it does: Treats your entire project as input and produces a prioritised, risk-grouped upgrade checklist — blocking items (compile/test failures), warnings (deprecations and behavioural changes), and nice-to-haves (modernization opportunities) — with a rough developer-hours estimate for each group.
When to use it: At the beginning of a team upgrade to align on scope and schedule before writing a single line of code. File: lanes/prompts/upgrade-6-checklist-runner.txt.
Act as a Java 25 upgrade reviewer. Given the attached project files, produce a prioritized checklist of upgrade tasks, group them by risk (blocking / warning / nice-to-have), and estimate effort in developer-hours per group.
Prompt 7 — 25 to 27 Upgrade Scan
What it does: Scans a project that already runs on Java 25 for what changes on 26 and 27: reflection that writes to final fields, the renamed structured-concurrency factory methods, -XX:-ZGenerational, Thread.stop and java.applet, 12-byte object-header assumptions, and start scripts that rely on Serial in a one-CPU container. For each finding it gives the file, the line, the release that changed it and the fix.
When to use it: When you are deciding whether to rehearse on 27, or a few months before an LTS switch. File: lanes/prompts/upgrade-7-25-to-27.txt.
Act as a Java 25 to Java 27 upgrade reviewer. Scan the attached project for: reflection that writes to final fields (Field.setAccessible followed by Field.set on a final field; Java 26 warns, JEP 500), StructuredTaskScope code that uses the 25 names (Joiner.anySuccessfulResultOrThrow became anySuccessfulOrThrow in 26, and Joiner gained a third type parameter in 27), the -XX:-ZGenerational flag (ignored with a warning on 24 and 25, rejected on 26 and 27), Thread.stop and java.applet (removed in 26), JOL or Unsafe code that assumes a 12-byte object header (compact headers are the default in 27), and container start scripts that rely on the JVM choosing SerialGC in a one-CPU container (G1 is the default everywhere in 27). For each finding give the file, the line, the release that changed it, and the fix.
Can I skip Java 21 and go straight from Java 17 to Java 25?
Yes, but you will hit both the 17→21 and 21→25 changes simultaneously. The combined breakage surface is larger and harder to debug. Use the companion AI Prompts Playbook: Upgrading Java 8 → 11 → 17 → 21 → 25 to work through each stage separately, even if you compile against Java 25 from the start.
Does IntelliJ IDEA support Java 25?
I did not test any IDE for this page, so there is no run to point to, and an earlier version of this answer quoted release numbers without a source. Check the release notes of the IDE version you run for Java 25, and for 26 and 27 in particular, before you change the project SDK. IDE settings are also where hand-configured annotation processing lives, which Step 2 showed is worth a look.
Will Spring Boot 3.3.x work on Java 25?
I did not run it, so this is what the documentation says. The Spring Boot system requirements page lists Spring Boot 3.4.x as supporting up to Java 24, 3.5.x up to Java 25, and 4.1.1 up to Java 26; 3.3.x is older still. The earlier answer here, that 3.4 is the first with official Java 25 support, was wrong. Upgrade Spring Boot first, to 3.5.x or 4.x.
Do I have to upgrade Gradle?
If you use Gradle and want to run it on Java 25, yes: 8.14.3 failed on 25 in transcript 93 and 9.1.0 worked, and the Gradle compatibility table lists 9.1.0 as the first for Java 25. A Gradle that runs on 21 can still compile with a Java 25 toolchain, so the upgrade can be staged.
Can I compile on a newer JDK and still target Java 25?
Yes, with release. JDK 27 built the sample project with release at 25 and produced Java 25 class files (transcript 92), and the compiler refuses APIs newer than 25 (transcript 94). Do not use source and target alone for this.
Conclusion
For most codebases the Java 21 → 25 jump is mostly about the tools around Java rather than about Java itself. Bump your build, bring Gradle, Lombok and Mockito up to versions that know Java 25, keep the preview flag only where the feature is still a preview, replace what was removed, and let the AI prompts above do the line-by-line modernization. The reward is virtual threads without the pinning problem, scoped values, and a single ZGC mode, and the six-stage order gives you a safe rollback point at each step. Structured concurrency is the piece that has not landed: it is still a preview on 27.
Further Reading
asmhatre/javademos: the code and transcripts behind this article; chapter 14 checks every claim of the old version and chapter 15 covers the build files.
No Comments yet!