Migrating a High‑Traffic Java 8 Service to JDK 21/25: Pitfalls, Compatibility Checks, and Best‑Practice SOP

The author details a production migration of a core transaction service from Java 8 to JDK 21/25—handling millions of users and 30 k QPS—by exposing runtime silent‑failure pitfalls, outlining six compatibility‑risk categories, prescribing version‑selection rules, a step‑by‑step gray‑scale upgrade and rollback plan, and quantifying performance gains such as ZGC latency reduction and AppCDS startup speedup.

Tinker Programmer
Tinker Programmer
Tinker Programmer
Migrating a High‑Traffic Java 8 Service to JDK 21/25: Pitfalls, Compatibility Checks, and Best‑Practice SOP

First incident: payment callbacks failed on day one

After deploying the first five instances in a gray‑scale rollout, the payment‑callback success rate dropped from 99.9% to 97%, prompting an immediate rollback.

Investigation revealed that the project used ByteBuddy 1.10.x for dynamic proxies. Java 21’s class‑file version (major 65) was unrecognized by this old ByteBuddy version, producing malformed proxy classes and causing Spring AOP aspects to be skipped, which broke signature verification in payment callbacks.

Upgrading ByteBuddy to 1.14.x fixed the issue. The bug only manifested under real production traffic because the faulty proxy classes were created only for specific request paths, escaping local and pre‑release tests.

Key insight: Major JDK upgrades are more likely to cause silent runtime failures than compile‑time errors.

Step 1: Version selection – direct recommendation

Conservative route (recommended for most teams): Upgrade to JDK 21 LTS . It has mature ecosystem support (Spring Boot 3.2+, Quarkus 3.x), offers virtual threads, ZGC, pattern matching, and is supported until 2028.

Aggressive route (new projects or teams with capacity to experiment): Jump to JDK 25 LTS . It provides the most features, compact object headers for memory savings, and is supported until 2030, but some frameworks and toolchains may not yet be fully compatible.

Never: Upgrade to Java 11 or 17, as their Premier Support ends soon, leading to another upgrade cycle within two years.

For projects already on Java 11 or 17:

Java 11 should be upgraded to 21 or 25 this year because Premier Support has ended and no security patches are provided.

Java 17 can be used until September 2026, but IO‑intensive services are strongly advised to move to 21+ to benefit from virtual threads.

Version‑selection principle: Choose only LTS releases; non‑LTS versions are supported for six months and act as hidden landmines in production.

Step 2: Compatibility inspection – six mandatory checks

Based on three large‑scale upgrades, the author categorises compatibility problems into six groups and recommends checking them in priority order.

1. Removed JDK internal APIs (highest priority)

jdeps --jdk-internals --multi-release 21 -recursive target/*.jar > jdk-internals-report.txt

Focus on packages such as sun.misc.* (e.g., BASE64Encoder, Unsafe, Signal), sun.reflect.* (e.g., ReflectionFactory), and com.sun.*.

Remediation: sun.misc.BASE64Encoder

java.util.Base64
sun.misc.Unsafe

→ evaluate necessity; replace with VarHandle (Java 9+) or MemorySegment (Java 22+ FFM API)

If replacement is impossible, temporarily open the module with --add-opens for the specific package.

2. Removal of Java EE modules (Java 11+)

Search the codebase and dependencies for: javax.xml.bind.* → replace with

jakarta.xml.bind.*
javax.annotation.*

(e.g., @PostConstruct) → replace with

jakarta.annotation.*
javax.ws.rs.*

→ replace with jakarta.ws.rs.* Also verify transitive dependencies; older Spring or Hibernate versions may still pull in the old packages.

3. Strong encapsulation of internal APIs (Java 17+)

Java 17 encapsulates internal APIs, breaking frameworks that use reflection (FastJSON, older AOP libraries, old Hibernate). Temporary fix: open specific packages with --add-opens (e.g., java.base/java.lang=ALL-UNNAMED), but avoid opening the entire module ( --add-opens java.base=ALL-UNNAMED) as it defeats modular security and performance benefits.

4. Bytecode manipulation library versions

ASM, CGLIB, ByteBuddy, Javassist are tightly coupled to the JDK version. Minimum required versions for Java 21 are:

ASM ≥ 9.5

ByteBuddy ≥ 1.14

CGLIB: recommend switching to ByteBuddy for Java 17+ as CGLIB is no longer maintained.

Check with:

mvn dependency:tree | grep -E "(asm|byte-buddy|cglib)"

5. JVM flag changes

-XX:+UseConcMarkSweepGC

removed after Java 14 → switch to G1 or ZGC. -XX:MaxRAMFraction deprecated → use -XX:MaxRAMPercentage (e.g., -XX:MaxRAMPercentage=75.0). -XX:+PrintGCDetails and -XX:+PrintGCDateStamps replaced by unified logging -Xlog:gc* (Java 9+). -Djava.endorsed.dirs and -Djava.ext.dirs removed after Java 9.

6. Encoding and timezone pitfalls (Java 18+)

Java 18 defaults to UTF‑8 (JEP 400). Code that relied on platform default encoding (e.g., Windows GBK) may now produce garbled text.

Audit the following usages and explicitly specify UTF‑8: new FileReader(path),

new FileWriter(path)
String.getBytes()
System.getProperty("file.encoding")

Fix by using new InputStreamReader(in, StandardCharsets.UTF_8).

Step 3: Gray‑scale upgrade strategy – safety first

The author performed three upgrades following this rhythm, achieving zero P0 incidents.

Phase 1: Offline verification (1–2 weeks)

Compilation verification: Compile the entire codebase with the target JDK and resolve any compilation errors.

Unit testing: Run all unit tests, paying special attention to bytecode‑related tests (AOP, mocking, serialization).

Integration testing: Execute end‑to‑end business‑flow tests.

Load testing: Replicate production traffic, compare latency, throughput, GC pauses, memory, and CPU usage. Key metrics: P99 latency, error rate, GC pause time, memory consumption.

Chaos testing: Randomly kill instances to verify startup speed and recovery capability.

Phase 2: Canary (1–3 days)

Deploy to 1–2 online instances, route 10% of traffic.

Monitor core metrics: error rate, P99 latency, GC behavior, business exceptions.

Observe for at least 24 hours covering peak and off‑peak periods.

Rollback immediately if any issue appears.

Phase 3: Small batch (3–7 days)

Scale to 5–10 instances, route 30–50% of traffic.

Continue monitoring, focusing on tail metrics (P99.9, GC frequency, memory leaks).

Compare resource usage between new and old version instances.

Phase 4: Full rollout (1–3 days)

Upgrade all instances.

Keep the last batch of old‑version instances as a "escape pod"; after 48 hours of stable operation, decommission them.

Rollback mechanisms

Blue‑green deployment: Switch traffic between two environments for second‑level rollback.

Canary rollback: Take problematic instances offline; traffic automatically shifts to old version.

Configuration‑center switch: Treat JDK version as a configuration item; dynamically revert to the old version if the deployment platform supports it.

Step 4: Immediate zero‑cost performance benefits after upgrade

No business‑logic changes are required to reap these gains:

GC performance boost: Switching from CMS to ZGC reduces GC pauses from seconds to milliseconds; moving from Parallel to G1 improves throughput for large heaps.

G1 string deduplication: Enable -XX:+UseStringDeduplication (effective under G1) to save 5–15% memory for workloads with many duplicate strings (JSON, XML, logs).

Compact strings (Java 9+): Internal representation changes from char[] to byte[], halving memory for pure ASCII strings.

JIT compiler improvements: C2 optimisations yield 10–30% speedups for compute‑intensive code.

Class Data Sharing (CDS): AppCDS (Java 10+) speeds up startup by 30–50%.

In the author’s transaction service on identical hardware, the observed improvements were:

P99 GC pause reduced from 800 ms to 0.5 ms after switching to ZGC.

Heap usage dropped 12% after enabling G1 string deduplication.

Startup time cut from 12 s to 6 s with AppCDS.

Common pitfalls – “blood‑shed” list

Pitfall 1: Lombok version incompatibility

Lombok < 1.18.30 does not support Java 17+ module system; upgrade to ≥ 1.18.30.

Pitfall 2: Conflict between MapStruct and Lombok

After upgrading Lombok, generated code may clash with Lombok’s @Builder. Upgrade MapStruct to ≥ 1.5 and ensure annotationProcessorPaths order in pom.xml is correct.

Pitfall 3: Spring Boot version

Spring Boot 2.x supports up to Java 8/11 (partial 2.7 support for 17 but not recommended). Migrating to Java 21 requires Spring Boot 3.x, which replaces javax.servlet with jakarta.servlet; direct servlet API usage must update package names.

Pitfall 4: JAXB/Jackson XML serialization

Java 11 removed JAXB. Projects depending on jackson-dataformat-xml may still pull in old JAXB implementations. Explicitly add jakarta.xml.bind-api and its implementation, otherwise a ClassNotFoundException: jakarta.xml.bind.JAXBContext will occur.

Pitfall 5: Date/Time API issues

Legacy code may still use java.util.Date or Calendar. Mixing old and new APIs can cause timezone bugs; replace core paths with java.time.LocalDateTime or ZonedDateTime during migration.

Pitfall 6: Unsafe alternatives

If sun.misc.Unsafe is used for off‑heap memory, Java 17+ encapsulates it. Use --add-opens java.base/sun.misc=ALL-UNNAMED as a temporary bridge, but long‑term replace with Java 22’s FFM API ( MemorySegment + Arena) for safer, higher‑performance off‑heap access.

Future outlook – ongoing Java evolution

Valhalla project: Value types and generic primitive types (e.g., List<int>) will reduce boxing overhead.

Leyden project: AOT compilation and startup optimisations will further lower Java application startup time and memory footprint.

Babylon project: GPU computing and AI inference support on the JVM.

These projects are still under development, but once mature they will push Java performance and applicability to new heights.

Three immediate actions

If you are still on Java 8, run jdeps --jdk-internals to list internal APIs used by your project; early identification of the biggest upgrade blocker is crucial.

Set up a test environment matching the target JDK version (e.g., a local Docker container with JDK 21/25) and run all unit and integration tests to resolve compilation errors and obvious runtime exceptions.

Pick a non‑core service (e.g., an internal tool, monitoring service, or BFF layer) as a pilot, execute the full gray‑scale upgrade process, and gather experience before tackling the core transaction service.

Series conclusion

The series spans twelve years of Java evolution from Java 8 to JDK 25 across nine articles, consolidating the author’s encountered pitfalls, validated conclusions, and source‑code investigations.

Java is no longer the "heavy, verbose, slow‑changing" language of the past. From lambdas to virtual threads, from CMS to ZGC, and from HttpURLConnection to HTTP/3, the language has undergone comprehensive improvements. If you are still on Java 8, there are concrete performance, stability, and developer‑efficiency reasons to upgrade—not merely to chase the newest version.

Production upgrades are high‑stakes; follow the SOP: compatibility checks, offline verification, gray‑scale rollout, then full deployment, proceeding step by step.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

javamigrationPerformanceZGCVirtual ThreadscompatibilityJDK 21JDK 25
Tinker Programmer
Written by

Tinker Programmer

Solving problems with code, sharing practical tech insights, and leveling up together!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.