Versions and status. JDK 17.0.20.1 and JDK 25.0.4.1 (both Temurin), Maven 3.9, on a 2-CPU Linux machine. Java 25 is an LTS release. Spring Boot 4.1.1 for the framework rows. Everything below was run unless the text says it is a quotation from documentation. Companion code: javademos/migration-17-25.
How the comparison works: compile once, run twice
The most common way a migration goes wrong is that the code was never really tested on the new JDK; it was compiled on it. Those are different tests. Here the probe programs are compiled once by JDK 17 with--release 17, then the same class files run on both runtimes. Any difference in behaviour is the runtime’s alone.
Things that stop working
Two features that Java 17 still allowed are gone. The first is the Security Manager, a sandbox mechanism most applications never used on purpose but some application servers and old launch scripts still enable. Installing one is a single call: try {
System.setSecurityManager(new SecurityManager());
System.out.println("installed: " + (System.getSecurityManager() != null));
} catch (Throwable t) {
System.out.println("failed: " + t.getClass().getName() + ": " + t.getMessage());
}
Source: SecurityManagerProbe.java, lines 5–10.
--- SecurityManagerProbe
[17.0.20.1] WARNING: A terminally deprecated method in java.lang.System has been called
[17.0.20.1] WARNING: System::setSecurityManager has been called by SecurityManagerProbe (file:/tmp/migration-17-25-work/c17/)
[17.0.20.1] WARNING: Please consider reporting this to the maintainers of SecurityManagerProbe
[17.0.20.1] WARNING: System::setSecurityManager will be removed in a future release
[17.0.20.1] installed: true
[25.0.4.1] failed: java.lang.UnsupportedOperationException: Setting a Security Manager is not supported
Captured in 01-runtime-behaviour.txt.
On 17 it works, with a warning that it is on its way out. On 25 the call throwsUnsupportedOperationException. That is the mild version. A startup flag that merely allows the Security Manager is worse, because the JVM refuses to start at all:
--- SecurityManagerProbe with -Djava.security.manager=allow
[17.0.20.1] WARNING: A terminally deprecated method in java.lang.System has been called
[17.0.20.1] WARNING: System::setSecurityManager has been called by SecurityManagerProbe (file:/tmp/migration-17-25-work/c17/)
[17.0.20.1] WARNING: Please consider reporting this to the maintainers of SecurityManagerProbe
[25.0.4.1] Error occurred during initialization of VM
[25.0.4.1] java.lang.Error: A command line option has attempted to allow or enable the Security Manager. Enabling a Security Manager is not supported.
[25.0.4.1] at java.lang.System.initPhase3([email protected]/System.java:1970)
Captured in 01-runtime-behaviour.txt.
Check your launch scripts. An old Tomcat, Jetty or custom start script that passes -Djava.security.manager in any form turns into a JVM that dies during initialisation on 25, before any of your code runs. Search the deployment scripts, not only the source.
The second removal is Thread.stop(). It worked on 17 (and, as the probe shows, actually stopped the thread). On 25 it throws:
--- ThreadStopProbe
[17.0.20.1] stop() accepted; thread alive afterwards: false
[25.0.4.1] stop() failed: java.lang.UnsupportedOperationException
Captured in 01-runtime-behaviour.txt.
Things that keep working but mean something different
These are the changes that do not fail a test. The first is the default character encoding. Before Java 18,FileWriter and friends used the platform’s encoding, which on a server with a minimal locale (LC_ALL=C, common in containers) is US-ASCII. From Java 18 the default is UTF-8 regardless. The probe writes the word “café” with no charset argument:
Path f = Files.createTempFile("charset", ".txt");
try (Writer w = new FileWriter(f.toFile())) { w.write("café"); }
byte[] bytes = Files.readAllBytes(f);
System.out.println("Charset.defaultCharset() = " + Charset.defaultCharset());
System.out.println("bytes written for the 4-letter word with an accent = " + bytes.length + " (UTF-8 would be 5)");
Files.delete(f);
Source: CharsetProbe.java, lines 8–13.
--- CharsetProbe
[17.0.20.1] Charset.defaultCharset() = US-ASCII
[17.0.20.1] bytes written for the 4-letter word with an accent = 4 (UTF-8 would be 5)
[25.0.4.1] Charset.defaultCharset() = UTF-8
[25.0.4.1] bytes written for the 4-letter word with an accent = 5 (UTF-8 would be 5)
Captured in 01-runtime-behaviour.txt.
On 17 the accented letter cannot be represented and only four bytes are written; on 25 it is written correctly (five bytes). If you have files written by an old JVM under a non-UTF-8 default and read by a new one, or the other way round, they will not agree. The migration is a correction of past bugs, but it changes bytes on disk. Two more behaviours changed less dramatically.finalize() is deprecated for removal but still runs on 25 by default, so nothing breaks; and sun.misc.Unsafe still works but now prints a warning the first time its memory methods are used:
[17.0.20.1] finalize() ran: true
[25.0.4.1] finalize() ran: true
--- UnsafeProbe
[17.0.20.1] read back: 42
[25.0.4.1] WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
[25.0.4.1] WARNING: sun.misc.Unsafe::allocateMemory has been called by UnsafeProbe (file:/tmp/migration-17-25-work/c17/)
[25.0.4.1] WARNING: Please consider reporting this to the maintainers of class UnsafeProbe
[25.0.4.1] WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
[25.0.4.1] read back: 42
Captured in 01-runtime-behaviour.txt.
Read this warning as a deadline. Libraries such as older versions of caches, serialisers and networking frameworks use Unsafe internally. The warning names the class that made the call, so the fix is to upgrade that library, not your own code. I did not test which library versions have moved off it.
Garbage collection: the default did not change, the monitoring might
The change of default collector that came with the move from Java 8 to 9 does not repeat here. With no flags, both JDKs choose G1 on this machine and pick the same heap sizes. One visible difference is that the JVM now reports a third collector bean, which matters if a dashboard or alert matches on collector names:# 05-gc-defaults (no flags, same 2-CPU machine)
--- JDK 17.0.20.1
InitialHeapSize = 98566144
MaxHeapSize = 1570766848
UseG1GC = true
collector: G1 Young Generation
collector: G1 Old Generation
processors: 2
--- JDK 25.0.4.1
InitialHeapSize = 98566144
MaxHeapSize = 1570766848
UseCompactObjectHeaders = false
UseG1GC = true
collector: G1 Young Generation
collector: G1 Concurrent GC
collector: G1 Old Generation
processors: 2
Captured in 05-gc-defaults.txt.
Going deeper: what this does not test
Same defaults do not mean same performance. G1 itself was changed across these releases, so pause times and throughput on your workload can differ, and a load test is the only way to know. Compact object headers exist as a flag on 25 and default to off, which the transcript shows; I did not measure them here.
- Transcript: 05-gc-defaults.txt
- Related on this site: Java 21 to 25 LTS features
JVM options that used to work
Long-lived applications carry long-lived startup flags. Nine options were tried on both runtimes, including some that were already dead on 17:# flag | JDK 17 | JDK 25 (exit code: first warning or error line)
-Djava.security.manager=allow | exit 0: (no message) | exit 1: Error occurred during initialization of VM
-XX:+UseBiasedLocking | exit 0: Option UseBiasedLocking was deprecated in version 15.0 and will likely be removed in a future release. | exit 1: Unrecognized VM option 'UseBiasedLocking'
-XX:+UseConcMarkSweepGC | exit 1: Unrecognized VM option 'UseConcMarkSweepGC' | exit 1: Unrecognized VM option 'UseConcMarkSweepGC'
-XX:+AggressiveOpts | exit 1: Unrecognized VM option 'AggressiveOpts' | exit 1: Unrecognized VM option 'AggressiveOpts'
-XX:MaxPermSize=128m | exit 1: Unrecognized VM option 'MaxPermSize=128m' | exit 1: Unrecognized VM option 'MaxPermSize=128m'
-XX:+UseParallelOldGC | exit 1: Unrecognized VM option 'UseParallelOldGC' | exit 1: Unrecognized VM option 'UseParallelOldGC'
-Xverify:none | exit 0: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release. | exit 0: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
-XX:-UseCompressedClassPointers | exit 0: (no message) | exit 0: Option UseCompressedClassPointers was deprecated in version 25.0 and will likely be removed in a future release.
-XX:+ParallelRefProcEnabled | exit 0: (no message) | exit 0: (no message)
-XX:+UseStringDeduplication | exit 0: (no message) | exit 0: (no message)
Captured in 02-startup-flags.txt.
| Flag | On 17 | On 25 | Meaning |
|---|---|---|---|
-Djava.security.manager=allow | starts | JVM fails to start | Remove it |
-XX:+UseBiasedLocking | starts with a warning | JVM fails to start | Remove it; it was deprecated in 15 |
-XX:+UseConcMarkSweepGC, AggressiveOpts, MaxPermSize, UseParallelOldGC | already fails | fails | Already broken on 17 |
-Xverify:none | starts with a warning | starts with a warning | Deprecated since 13, still accepted |
-XX:-UseCompressedClassPointers | starts | starts with a new warning | Deprecated in 25 |
APIs that no longer compile
Compiling old source with the new JDK is the reverse check. Four small files were compiled with each JDK, with removal warnings switched on:# 03-compile-removed-apis: javac -Xlint:removal on JDK 17 and JDK 25
UsesCompiler.java [17.0.20.1] [removal] Compiler in java.lang has been deprecated and marked for removal
UsesCompiler.java [25.0.4.1] cannot find symbol
UsesRunFinalization.java [17.0.20.1] compiles cleanly
UsesRunFinalization.java [25.0.4.1] [removal] runFinalization() in Runtime has been deprecated and marked for removal
UsesThreadGroupDestroy.java [17.0.20.1] [removal] destroy() in ThreadGroup has been deprecated and marked for removal
UsesThreadGroupDestroy.java [25.0.4.1] [removal] destroy() in ThreadGroup has been deprecated and marked for removal
UsesThreadSuspend.java [17.0.20.1] [removal] suspend() in Thread has been deprecated and marked for removal
UsesThreadSuspend.java [25.0.4.1] cannot find symbol
Captured in 03-compile-removed-apis.txt.
java.lang.Compiler and Thread.suspend() exist on 17 as deprecated-for-removal and are gone on 25: a hard error, not a warning. Runtime.runFinalization() gained its removal warning between the two. ThreadGroup.destroy() is unchanged. In practice, warnings you tolerated on 17 are the list of what can vanish; treat [removal] as a countdown.
Finding these in your own code, without guessing
You do not have to write probes for your application. The JDK ships two scanners that read compiled class files.jdeps --jdk-internals lists uses of JDK-private classes; jdeprscan lists uses of deprecated APIs and can be told to check against a specific release. Run them with the new JDK’s tools, targeting release 17 and then 25, on the jar that JDK 17 built:
# 04-jdeps-jdeprscan (scanning probes.jar, built by JDK 17, with the JDK 25 tools)
--- jdeps --jdk-internals
probes.jar -> jdk.unsupported
UnsafeProbe -> sun.misc.Unsafe JDK internal API (jdk.unsupported)
--- jdeprscan --release 17 --for-removal
Jar file probes.jar:
class SecurityManagerProbe uses deprecated class java/lang/SecurityManager (forRemoval=true)
class SecurityManagerProbe uses deprecated method java/lang/System::setSecurityManager(Ljava/lang/SecurityManager;)V (forRemoval=true)
class SecurityManagerProbe uses deprecated method java/lang/System::getSecurityManager()Ljava/lang/SecurityManager; (forRemoval=true)
--- jdeprscan --release 25 --for-removal
Jar file probes.jar:
class FinalizerProbe$Res overrides deprecated method java/lang/Object::finalize()V (forRemoval=true)
class SecurityManagerProbe uses deprecated class java/lang/SecurityManager (forRemoval=true)
class SecurityManagerProbe uses deprecated method java/lang/System::setSecurityManager(Ljava/lang/SecurityManager;)V (forRemoval=true)
class SecurityManagerProbe uses deprecated method java/lang/System::getSecurityManager()Ljava/lang/SecurityManager; (forRemoval=true)
class ThreadStopProbe uses deprecated method java/lang/Thread::stop()V (forRemoval=true)
Captured in 04-jdeps-jdeprscan.txt.
The twojdeprscan runs are the useful part. Scanning against release 17 flags only the Security Manager. Scanning against release 25 adds Object.finalize() and Thread.stop(). The difference between the lists is the work the jump adds. jdeps found the Unsafe dependency directly.
What the scanners cannot see. They read your bytecode and your dependencies’ bytecode, but not reflection by string, not startup flags, and not behavioural changes like the default charset. The launch scripts and a run of the real test suite on 25 remain necessary.
Frameworks and build tools
The last question is whether your stack runs on 25. Two kinds of evidence exist. First, a run: the same small Spring application in four generations (Boot 2.7.18 on Spring 5.3 and Hibernate 5.6, then 3.5.16, 4.0.8 and 4.1.1) was built and its one test run on JDK 25:# 06-legacy-stack-on-25: mvn test on JDK 25 for four generations of the same app (bytecode target is release 17 in every row)
Spring Boot 2.7.18 (Spring 5.3, Hibernate 5.6): Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
Spring Boot 3.5.16: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
Spring Boot 4.0.8: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
Spring Boot 4.1.1: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
Captured in 06-legacy-stack-on-25.txt.
All four pass. That is a surprise worth reporting and a claim worth limiting: the application is tiny, has one test, compiles to release 17 bytecode, and the test uses no mocking library or agent. It shows that these frameworks start and serve a request on 25; it does not show they are supported there. Second, the vendors’ own statements, which are what to rely on for support. These are quotations, not results of running code:# 07-framework-minimums (quoted from vendor documentation on 2026-09-28; NOT produced by running code)
Spring Boot 4.1.1 system requirements (https://docs.spring.io/spring-boot/system-requirements.html):
"Spring Boot 4.1.1 requires at least Java 17 and is compatible with versions up to and including Java 26."
Spring Framework 7.0.9 or above; Maven 3.6.3 or later; Gradle 8.14 or later, and 9.x
Captured in 07-framework-minimums.txt.
Gradle Java compatibility (https://docs.gradle.org/current/userguide/compatibility.html), "support for running Gradle":
Java 21: 8.5 and after | Java 24: 8.14 and after | Java 25: 9.1.0 and after | Java 26: 9.4.0 and after
Captured in 07-framework-minimums.txt.
Going deeper: the other tools on your path
Gradle is the tool most likely to block the jump: the table above says running Gradle on Java 25 needs 9.1.0 or newer, and Boot 4.1.1 itself asks for Gradle 8.14 or 9.x. If you are on an older Gradle, upgrade the build tool before the JDK. Maven 3.9 built every module in this article on 25.
I did not test Lombok, Mockito, agents, or code-coverage tools, all of which read class files and have historically needed a release per Java version. Check each one’s compatibility notes for 25.
- Quoted requirements: 07-framework-minimums.txt
- Related on this site: Automating javax to jakarta and Boot 3 to 4 upgrades with OpenRewrite
A checklist for the jump
| Step | Command or action | Catches |
|---|---|---|
| 1. Search launch scripts | grep for java.security.manager, UseBiasedLocking and other removed flags | JVM that will not start |
| 2. Scan bytecode | jdeprscan --release 25 --for-removal and jdeps --jdk-internals | Removed and internal APIs |
| 3. Compile with 25 | Build with the new JDK, keep --release 17 at first | Hard compile errors |
| 4. Test on 25 | Run the full suite and your integration environment | Behaviour changes |
| 5. Check encoding | Compare files written by 17 and 25; set -Dfile.encoding deliberately if in doubt | Silent data differences |
| 6. Upgrade tools first | Gradle 9.1+, agents, mocking, coverage | Build failures on the new JDK |
Should you skip 21? Nothing in this run argues against it. The removals that hurt (the Security Manager, Thread.stop, biased locking) happened across 17 to 25 whichever route you take, and going to 25 directly means you migrate once instead of twice. What this article cannot tell you is how your workload performs: run your own load test on 25 before the cut-over.
No Comments yet!