From JDK 8 to 25: Treating a Seventeen-Version Upgrade as Platform Engineering#
Many teams still understand “upgrade the JDK” as swapping the production java executable. If compile, test, and build classpaths still carry Java 8–era dependencies and flags, failures often do not surface on switch day—they cluster in integration tests, canaries, or weeks later when cached data is deserialized.
Across seventeen major versions, breakage can be grouped roughly by blast radius into four buckets: won’t start (deprecated GC/SM flags), starts then crashes (split packages, NoClassDefFoundError, reflective encapsulation), compiles but behavior changed (locale, UTF-8, string concatenation), and fails later (serialization UIDs, missing annotation processing). This article treats JDK 8→25 as end-to-end Java asset migration: use JDK tooling first to map breakage, address it layer by layer, then separate “reach 25” (a project) from “stay on a six-month cadence” (a capability).
What you migrate: every classpath, not one runtime JAR#
Why: sun.* in transitive dependencies, APIs marked for removal, and JNI/FFM usage rarely appear in business src/ but trigger NoClassDefFoundError or startup warnings on the target JDK. Upgrading only the production runtime while tests still compile on an old JDK exposes inconsistency late in CI.
Mechanism / constraints: Maven/Gradle resolve compile, test, and runtime trees differently; container JAVA_OPTS can diverge from local dev scripts. On JDK 25, System.getProperty(“java.class.path”) reflects only the current process load path.
How to:
mvn -q dependency:tree -DoutputFile=target/deps.txt
mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt
public final class ClasspathProbe {
public static void main(String[] args) {
String cp = System.getProperty("java.class.path");
if (cp == null) return;
for (String e : cp.split(java.io.File.pathSeparator))
if (e.endsWith(".jar")) System.out.println(e);
}
}
Common pitfalls: Assuming “the main project is on 25” means the whole stack is upgraded; ignoring test scope, annotation processors, and JARs bundled with build plugins. In the upgrade charter, write three invariants: dev machines, CI agents, and production images share java -version and JAVA_HOME; archive dependency:tree before each release; for fat JARs or shaded artifacts run jdeps -R again, because merged packages hide transitive origins.

Pre-upgrade static reconnaissance: jdeps, jdeprscan, jnativescan#
Why: Module strong encapsulation since JDK 9, JEP 403 removing --illegal-access, and JEP 472 constraints on native access show up first in bytecode and launch flags, not in business-logic diffs.
Mechanism / constraints:
| Tool | Typical use | Manual |
|---|---|---|
jdeps --jdk-internals | Find dependencies on JDK internal APIs | For JEP 396 |
jdeprscan --for-removal | Scan APIs slated for removal | Use with --release 25 |
| jnativescan | Scan JNI/native library references | Complements JEP 472 runtime policy |
| japicmp | Binary API diffs on dependency minor bumps | Third-party, not bundled with JDK |
How to (classpath must cover all JARs, including ~/.m2 or lib/*):
jdeps --multi-release 25 --jdk-internals -R app.jar lib/*.jar
jdeprscan --release 25 --for-removal --class-path "lib/*:app.jar"
jnativescan --class-path "lib/*:app.jar"
Common pitfalls: Scanning only target/classes; skipping jdeprscan --for-removal and conflating “deprecated” with “scheduled for removal.” Another subtle case is JNI vs FFM: jnativescan gives a static picture; whether runtime still needs --enable-native-access depends on the target JDK’s JEP 472 phase policy (warning paths are productized from JDK 24; future releases may tighten to deny).

First wall: modules, GC, and encapsulation in JDK 9–17#
Why: JDK 9 alone bundled module path, default GC switch, CLDR locale, and string-concatenation implementation changes—the slides call this the “first wall from nine to seventeen.” Keeping Parallel GC tuning, CMS flags, or embedded Java EE APIs stacks failures at steps like 9, 14, and 17.
Mechanism / constraints (aligned with official JEPs):
- Split packages / module path →
LayerInstantiationException(JEP 261 context) - Default GC Parallel → G1 (JEP 248) → throughput and pause curves change; silent class of issue
- CLDR default locale (JEP 252) → formatting output drift
- Strong encapsulation (JEP 396, JDK 16) → InaccessibleObjectException
- Removal of
--illegal-access(JEP 403, JDK 17) → reflective back doors cannot be relied on long term - CMS / Nashorn /
-XX:MaxPermSizeetc. → mostly Won’t Start


How to (transitional, not the end state):
java --add-opens java.base/java.lang=ALL-UNNAMED -jar legacy.jar
java -Djava.locale.providers=COMPAT,CLDR -jar app.jar
Common pitfalls: Baking --add-opens into permanent config without pushing library upgrades; on JDK 17+ assuming --illegal-access=permit still works.
Second wall: encoding, native access, and compiler behavior in JDK 18–25#
Why: Changes in 18–25 often land in one upgrade window: some won’t start, some run with no exception but different results, others appear only after recompilation.
Mechanism / constraints:
| Topic | JEP / basis | Typical impact |
|---|---|---|
| Default charset UTF-8 | JEP 400 | Silent corruption of legacy file I/O on Windows; -Dfile.encoding=COMPAT for transition |
| Native access | JEP 472 (delivered in JDK 24) | Needs --enable-native-access=ALL-UNNAMED, etc. |
| Security Manager | JEP 486 (JDK 24) | -Djava.security.manager=... fails at startup |
| Annotation processing default | javac manual + JDK-8321314 | From JDK 24, processors do not run unless configured (not JEP 477; 477 is implicit classes/instance main) |
| Compact headers | JEP 519 | Productized in JDK 25; JEP explicitly not targeting default object headers |
java.lang.IO | JEP 512 | New API coexists with java.io; adapt imports and teaching examples |

How to:
java --enable-native-access=ALL-UNNAMED -jar app-with-jni.jar
java -Dfile.encoding=COMPAT -jar app.jar
Common pitfalls: Anchoring JEP 472 only on JDK 25; treating slide footnotes that say “JEP 477” as the annotation-processing change (the number is stale—follow the javac manual). For sun.misc.Unsafe memory access under JEP 471/498, treat warnings as a removal countdown, not ignorable compiler noise; bytecode toolchains (ASM, ByteBuddy) must match target class file version or fail earlier in the build.
Hard startup failures: audit JVM flags before business code#
Why: Obsolete GC flags (e.g. CMS, JEP 363), legacy GC logging (JEP 158, JEP 271, unified as -Xlog), and Security Manager launch options can exit the JVM before application main. Without a diff, teams see site-wide failure while “the app didn’t change.”
Mechanism / constraints: The java launcher manual documents GC logging migration to -Xlog; -XX:+PrintFlagsFinal exports full flag sets for two JDK versions to compare.
How to:
"$JAVA8_HOME/bin/java" -XX:+PrintFlagsFinal -version > flags-jdk8.txt
"$JAVA25_HOME/bin/java" -XX:+PrintFlagsFinal -version > flags-jdk25.txt
diff -u flags-jdk8.txt flags-jdk25.txt | rg -i 'gc|logging|security' || true
ENV JAVA_OPTS="-XX:+UseG1GC"
# Remove -Xloggc:, CMS, SecurityManager-related options
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /app/app.jar"]
Common pitfalls: Checking only application-repo JAVA_OPTS while Helm charts, systemd units, and CI matrices keep old flags. In practice, smoke-test minimal startup with java -version and java -jar app.jar --help before the full integration suite; on Kubernetes, sync init containers and sidecars—they often copy stale JDK/flag templates from the main container.
Runtime reflection and “swallowed” encapsulation exceptions#
Why: After JEP 396/403, setAccessible(true) on JDK-internal types may throw InaccessibleObjectException. Frameworks that catch and ignore it produce random production failures instead of clear stack traces.
Mechanism / constraints: Static signal from jdeps --jdk-internals; at runtime -Xlog:exceptions=info makes JVM-recorded exceptions easier to surface in integration environments (JVM throw paths only—does not replace app-level swallowing).
How to:
java -Xlog:exceptions=info -jar build/integration-tests.jar
try {
var f = LegacyLib.class.getDeclaredField("internal");
f.setAccessible(true);
} catch (InaccessibleObjectException ex) {
System.getLogger("migrate").log(System.Logger.Level.ERROR, ex.getMessage(), ex);
}
Common pitfalls: Relying on unit tests without full integration; not cross-checking jdeps hits with InaccessibleObjectException in logs.
Silent semantics: Security Manager removal, locale, and UTF-8#
Why: These are “runs but different”—easy to miss when tests only assert no exception.
Mechanism / constraints:
- JEP 486 (JDK 24): Security Manager permanently disabled; six
AccessController.doPrivilegedvariants run the action immediately when SM is off, without permission checks. System.getSecurityManager()is alwaysnull; code that relied onSecurityExceptionto block paths continues silently.- JEP 400:
Charset.defaultCharset()defaults to UTF-8. - JEP 252: CLDR is the default locale data source.

How to:
java -Djava.locale.providers=COMPAT,CLDR -Dfile.encoding=COMPAT -jar app.jar
Files.writeString(path, text, StandardCharsets.UTF_8);
Audit code for catch (SecurityException), doPrivileged(), getSecurityManager() != null, and similar patterns.
Common pitfalls: Treating SM removal as “delete launch flags” only; ignoring permission assumptions in dependencies (risk judgment, not a literal JEP conclusion).
Serialization: validate shipped binaries and freshly recompiled artifacts on separate tracks#
Why: With Java built-in serialization across major versions, classes without a fixed serialVersionUID may get a different runtime-computed UID after recompilation, causing InvalidClassException—sometimes days later on cache read.
Mechanism / constraints: Serializable documents explicit serialVersionUID; serialver can generate UID constants while still on JDK 8.
How to:
serialver com.example.model.OrderDto
public class OrderDto implements java.io.Serializable {
private static final long serialVersionUID = 1234567890123456789L;
}
Run two CI tracks in parallel: test-shipped-jar (old build artifacts) and test-recompiled (new compile on JDK 25). Long term, explicit-schema formats (JSON, Protobuf, Avro) are more robust (engineering recommendation).

Common pitfalls: “Compiles clean” equals serialization compatible; test fixtures never include JDK 8–era serialized bytes.
Build trap: annotation processing on JDK 24+ and Lombok#
Why: From JDK 24, javac no longer runs annotation processors by default; Lombok relying on implicit discovery can compile successfully yet hit runtime NoSuchMethodError (missing getters/builders). Unrelated to JEP 477 (implicit classes); track implementation via JDK-8321314.
Mechanism / constraints: Lombok changelog declares JDK 25 support in 1.18.40; 1.18.42 is mainly IDE/ErrorProne fixes. Slides saying “Since JDK 23” disagree with the current manual—use 24+.
How to:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Common pitfalls: Bumping Lombok without declaring it in annotationProcessorPaths / Gradle annotationProcessor; unit tests never calling Lombok-generated methods. In CI, add a decompile or javap -c spot-check on key DTOs to confirm getter/builder bytecode exists; apply the same rule to MapStruct, Micronaut, and other processors—do not assume “it compiled before” on JDK 24+.

Mechanical refactors and supply-chain reality#
Why: javax→jakarta and deprecated API replacements can be batched with OpenRewrite, but blockers are often whether third-party libraries have caught up. The speaker cites rough figures—a modern app ~150 transitive dependencies, only ~22% of open-source Java components actively maintained—not independently verifiable; validate with SBOM and repository metadata.
How to:
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
-DactiveRecipes=org.openrewrite.java.migrate.UpgradeToJava25
Common pitfalls: Scoping upgrades to your repo only; not engaging maintainers on EOL library replacement or commercial support paths.


Actionable wrap-up checklist and JDK 25 capability profile#
Why: Breaking a one-shot big jump into repeatable steps reduces “we don’t know which layer broke.” Slides stress: if the JVM won’t start, nothing else matters—diff PrintFlagsFinal first, run jdeps/jdeprscan on the full classpath, integration-test with -Xlog:exceptions=info, and validate shipped vs recompiled artifacts separately.

After reaching JDK 25, platform capabilities include virtual threads JEP 444, modern GC (G1/ZGC/Shenandoah), records/sealed/pattern matching, strong encapsulation, and FFM. Closing-slide “2–3× GC” and “20%+ throughput” are speaker/slide marketing claims—validate on your workload with JFR and benchmarks:
java -XX:StartFlightRecording=duration=60s,filename=baseline.jfr -jar app.jar
try (var ex = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
ex.submit(() -> { /* IO-bound work */ });
}

Continuous upgrades (speaker view): Under OpenJDK’s six-month release cadence, “jump every few years” stacks the breakage layers in this article into one window. Practical habits: each release train, rerun jdeprscan --for-removal on the full classpath; gate dependency bumps with japicmp for binary compatibility; use OpenRewrite recipes like UpgradeToJava25 for mechanical changes; compare GC pauses and allocation rates with JFR under the same load; maintain an EOL ledger for immovable transitive deps and align with maintainers or commercial support. Distributions (Adoptium, Corretto, Liberica, Zulu, etc.) patch on different schedules, but language and JVM behavior follow OpenJDK—do not treat “runtime-only swap, no build change” as low risk.

Common pitfalls: Treating JDK 25 as the finish line; ignoring drift among build plugins, test containers, IDE, and production JDK versions. Teams still planning “jump every few years” should embed this article’s breakage table in the risk register at kickoff and assign owners per category (platform, middleware, product lines)—avoid compressing all compatibility work into the last two weeks before release.
References and further reading#
- JDK 25 documentation
- java application launcher manual (PrintFlagsFinal and GC logging migration)
- javac compiler manual — JDK 25 (annotation processing and -proc)
- jdeps manual
- jdeprscan manual
- Java object serialization specification and serialver
- JEP 396: Strongly encapsulate JDK internals by default
- JEP 400: UTF-8 by default
- JEP 472: Warn on native access and restrict by default
- JEP 486: Permanently disable the Security Manager
- OpenRewrite Java migration recipe catalog
- Project Lombok changelog



