Skip to main content
From JDK 8 to 25: Treating a Seventeen-Version Upgrade as Platform Engineering
  1. Posts/

From JDK 8 to 25: Treating a Seventeen-Version Upgrade as Platform Engineering

·2100 words·10 mins
NeatGuyCoding
Author
NeatGuyCoding

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.

JavaOne session: slides on dependency resolution and migration guides
Figure: Practical Dependency Resolution — Maven, Gradle, SBT, and dependency complexity


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:

ToolTypical useManual
jdeps --jdk-internalsFind dependencies on JDK internal APIsFor JEP 396
jdeprscan --for-removalScan APIs slated for removalUse with --release 25
jnativescanScan JNI/native library referencesComplements JEP 472 runtime policy
japicmpBinary API diffs on dependency minor bumpsThird-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).

Mermaid diagram 1


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 pathLayerInstantiationException (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:MaxPermSize etc. → mostly Won’t Start

Breakages by Version: JDK 9 — 17, including Won’t Start and Silent Change
Figure: Breakages by Version: JDK 9 — 17 — split packages, JEP 248/252/280, and related entries

JDK 9: seven breaking changes in a single release
Figure: JDK 9: seven breaking changes in a single release. The first wall.

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:

TopicJEP / basisTypical impact
Default charset UTF-8JEP 400Silent corruption of legacy file I/O on Windows; -Dfile.encoding=COMPAT for transition
Native accessJEP 472 (delivered in JDK 24)Needs --enable-native-access=ALL-UNNAMED, etc.
Security ManagerJEP 486 (JDK 24)-Djava.security.manager=... fails at startup
Annotation processing defaultjavac manual + JDK-8321314From JDK 24, processors do not run unless configured (not JEP 477; 477 is implicit classes/instance main)
Compact headersJEP 519Productized in JDK 25; JEP explicitly not targeting default object headers
java.lang.IOJEP 512New API coexists with java.io; adapt imports and teaching examples

Breakages by Version: JDK 18 — 25 — UTF-8, JEP 472, Unsafe, etc.
Figure: Breakages by Version: JDK 18 — 25 — Native access requires –enable-native-access (JEP 472)

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.doPrivileged variants run the action immediately when SM is off, without permission checks.
  • System.getSecurityManager() is always null; code that relied on SecurityException to block paths continues silently.
  • JEP 400: Charset.defaultCharset() defaults to UTF-8.
  • JEP 252: CLDR is the default locale data source.

Security paths silently succeed — JEP 486
Figure: Security paths silently succeed — No exception, that’s the problem

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).

Serialisation drift — InvalidClassException and serialver
Figure: Serialisation drift — Cached data from JDK 8 can’t be deserialised on JDK 25

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+.

The Lombok trap — javac no longer runs annotation processors by default
Figure: The Lombok trap — Since JDK 23, javac no longer runs annotation processors by default (version per JDK 24+ manual)


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.

What is a Software Supply Chain — + your dependencies
Figure: What is a Software Supply Chain — every dependency brings its own chain

Mermaid diagram 2


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.

Seven things to do on Monday — Audit startup flags, jdeps, dependency tree
Figure: Seven things to do on Monday — Diff PrintFlagsFinal between JDK 8 and 25

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 */ });
}

Why JDK 25 — Virtual threads, GC, Pattern matching, Security posture
Figure: Why JDK 25 — migration matters more than the destination — Virtual threads, 2-3x GC, 20%+ throughput (benchmark numbers yourself)

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.

Mermaid diagram 3

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
#

Related