Skip to main content

Java 17 to Java 25 Migration Guide: The Direct Jump

Java 21 is the LTS most guides tell you to upgrade to. But plenty of teams are still on Java 17, and when they finally move, the sensible target is the next LTS, Java 25, skipping 21 entirely. That is a jump across eight releases, and the release notes for eight releases are not a reading list anyone wants. This article does something simpler. It takes small programs compiled once for Java 17, runs them unchanged on Java 17 and Java 25, and shows what differs — what now fails, what silently changes, what merely warns. Then it shows how to have the JDK’s own tools find the same problems in your code. You need to know how to run a Java program; nothing more.
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.
One build, two runtimesjavac 17 –release 17probes compiled onceRun on JDK 17.0.20.1Run on JDK 25.0.4.1Compare the outputthe difference is the JDKThe scripts also compile a few sources with JDK 25 itself, to see what javac now refuses.
The diagram shows the method: because the class files are identical, nothing but the runtime varies. Later, compiling with the new JDK adds the reverse check.

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 throws UnsupportedOperationException. 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.

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.

FlagOn 17On 25Meaning
-Djava.security.manager=allowstartsJVM fails to startRemove it
-XX:+UseBiasedLockingstarts with a warningJVM fails to startRemove it; it was deprecated in 15
-XX:+UseConcMarkSweepGC, AggressiveOpts, MaxPermSize, UseParallelOldGCalready failsfailsAlready broken on 17
-Xverify:nonestarts with a warningstarts with a warningDeprecated since 13, still accepted
-XX:-UseCompressedClassPointersstartsstarts with a new warningDeprecated in 25
Only two of the options that start on 17 stop the JVM on 25: the Security Manager flag and biased locking. The others fail identically on both, which means a team already running on 17 has already removed them.

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 two jdeprscan 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.

A checklist for the jump

StepCommand or actionCatches
1. Search launch scriptsgrep for java.security.manager, UseBiasedLocking and other removed flagsJVM that will not start
2. Scan bytecodejdeprscan --release 25 --for-removal and jdeps --jdk-internalsRemoved and internal APIs
3. Compile with 25Build with the new JDK, keep --release 17 at firstHard compile errors
4. Test on 25Run the full suite and your integration environmentBehaviour changes
5. Check encodingCompare files written by 17 and 25; set -Dfile.encoding deliberately if in doubtSilent data differences
6. Upgrade tools firstGradle 9.1+, agents, mocking, coverageBuild 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.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.