Skip to main content
Java

Migrating Off Java 8 in a Regulated Codebase: The Order of Operations

Ravinder··9 min read
JavaMigrationJava 8Java 21Modernization
Share:
Migrating Off Java 8 in a Regulated Codebase: The Order of Operations

Why This Is Still Happening in 2026

Java 8 reached end-of-life for Oracle commercial support in January 2019. Yet a significant portion of regulated industries — financial services, healthcare, utilities — still run Java 8 in production. The reasons are not ignorance: they are certification requirements, validated system boundaries, vendor library contracts, and change control processes that make "just upgrade" a multi-quarter project even for a medium-sized codebase.

The risk of staying on Java 8 in 2026 is no longer theoretical. Security patches for Java 8 require commercial support contracts. The OpenJDK project stopped maintaining Java 8 update releases. Vendors are beginning to drop Java 8 compatibility from their client libraries. The migration is not optional; the question is how to do it without destabilizing a production system that cannot afford extended downtime.

This article is about the order of operations — not the happy path, but the sequence that surfaces problems early, keeps the system deployable throughout the migration, and avoids the anti-patterns that make Java upgrades fail.


The Landscape You Are Walking Into

Moving from Java 8 to Java 21 is not a version bump. It crosses several architectural discontinuities.

timeline title Key Breakpoints in Java 8 → 21 Migration Java 9 : Module system (JPMS) introduced : Internal APIs encapsulated : Classpath still works with warnings Java 11 : Java EE and CORBA removed : LTS baseline — vendor support stabilizes Java 17 : Strong encapsulation enforced : --illegal-access removed : LTS — most library support targets here Java 21 : Virtual threads GA : Sequenced collections : Pattern matching stable : LTS — current target

Each of these breakpoints represents a category of problems you will encounter. The module system encapsulation issues peak at Java 17. The Java EE removal hits at Java 11. Understanding which issues fall into which category lets you plan staged mitigation.


Phase 1: Dependency Triage (Before Touching the JVM)

Do not change the JVM first. Change everything that will break on a newer JVM before you run it on one.

Step 1: Run jdeps against your classpath

jdeps --jdk-internals \
      --multi-release 21 \
      --class-path "$(ls lib/*.jar | tr '\n' ':')" \
      target/myapp.jar 2>&1 | tee jdeps-report.txt
 
grep -v "^$" jdeps-report.txt | grep -v "Warning:" | head -100

jdeps --jdk-internals lists every use of internal JDK APIs across your code and your dependencies. The output looks like:

myapp.jar -> JDK removed internal API
   com.acme.util.XmlParser -> sun.misc.BASE64Decoder
   com.acme.util.XmlParser -> sun.misc.BASE64Encoder
vendor-library-1.4.jar -> JDK removed internal API
   com.vendor.io.FastSerializer -> sun.nio.ch.DirectBuffer

This is your triage list. Your own code is fixable. Vendor library issues require vendor upgrades.

Step 2: Build the dependency compatibility matrix

For each dependency that appears in jdeps output or is a known Java-EE-adjacent library:

Library Current Version Java 21 Compatible Version Notes
javax.xml.bind:jaxb-api 2.3.0 Remove, use jakarta.xml.bind-api:4.x Removed in Java 11
javax.annotation:javax.annotation-api 1.3.2 jakarta.annotation-api:3.x Package rename
com.sun.xml.ws:jaxws-ri 2.3.x 4.x Complete rewrite needed
Spring Framework 4.3.x 6.x (requires Jakarta) Breaking change
Hibernate 5.4.x 6.x (Jakarta namespace) Breaking change

This matrix is the project plan for Phase 1. Each row with a "Breaking change" note is a sub-project.

Step 3: Address sun.misc.Unsafe and internal API usage in your code

// Java 8 — works but deprecated
import sun.misc.BASE64Encoder;
byte[] encoded = new BASE64Encoder().encode(data);
 
// Java 11+ — correct replacement
import java.util.Base64;
String encoded = Base64.getEncoder().encodeToString(data);
// Java 8 — direct internal API access
import sun.misc.Unsafe;
Unsafe unsafe = Unsafe.getUnsafe();  // breaks in Java 17 with strong encapsulation
 
// Java 9+ — use VarHandle or MethodHandles instead
import java.lang.invoke.VarHandle;
import java.lang.invoke.MethodHandles;
VarHandle handle = MethodHandles.lookup()
    .findVarHandle(MyClass.class, "myField", int.class);

Phase 2: The Java EE to Jakarta Namespace Migration

If your codebase uses any of the Java EE APIs — javax.persistence, javax.servlet, javax.xml.bind, javax.ws.rs — you face the Jakarta namespace migration. Java 11 removed the Java EE modules from the JDK. Jakarta EE moved the packages from javax.* to jakarta.*.

This is not a find-and-replace problem, despite what it looks like.

flowchart LR subgraph javax["javax.* (Java EE / old)"] J1[javax.persistence] J2[javax.servlet] J3[javax.xml.bind] end subgraph jakarta["jakarta.* (Jakarta EE / new)"] K1[jakarta.persistence] K2[jakarta.servlet] K3[jakarta.xml.bind] end J1 -->|"rename + API changes"| K1 J2 -->|"rename only"| K2 J3 -->|"rename + removed features"| K3

The complication is that javax.persistencejakarta.persistence is a rename, but the API also changed between Hibernate 5 and Hibernate 6. A simple namespace rename will compile but produce runtime errors if you are running against a library that expects the new API semantics.

Recommended approach: upgrade libraries first, then rename namespaces.

# Use the Eclipse Transformer for automated namespace migration
# after dependency versions are updated
java -jar eclipse-transformer.jar \
  target/myapp.jar \
  target/myapp-jakarta.jar \
  --overwrite \
  --log-level INFO

The Eclipse Transformer handles bytecode-level transformation. It is more reliable than textual find-and-replace for complex cases.


Phase 3: Module System Gotchas

The JPMS module system does not require you to create a module-info.java. You can run an entire modular JDK application on the unnamed module (the classpath). However, the module system still affects you through encapsulation.

The most common encapsulation errors:

java.lang.reflect.InaccessibleObjectException: Unable to make field private final ... accessible:
module java.base does not "opens java.lang" to unnamed module

This happens when libraries use reflection to access private fields of JDK classes. The temporary fix:

java \
  --add-opens java.base/java.lang=ALL-UNNAMED \
  --add-opens java.base/java.util=ALL-UNNAMED \
  --add-opens java.base/sun.nio.ch=ALL-UNNAMED \
  -jar application.jar

The --add-opens flags are a bridge. They should be documented, tracked, and eliminated as libraries are upgraded. Each flag represents a library that has not yet adapted to the module system. By Java 21, libraries that require extensive --add-opens are candidates for replacement.

Tracking your --add-opens debt:

# collect all --add-opens in use
grep -r "add-opens" . --include="*.sh" --include="*.yaml" \
  --include="*.properties" --include="Dockerfile"

Each unique --add-opens is a dependency on module internals that will break in a future Java version. Prioritize eliminating them as part of dependency upgrades.


Phase 4: Staged JVM Upgrade

With dependencies compatible and namespace migration complete, you can change the JVM. The safest sequence:

flowchart TD A[Java 8 in production] --> B[Build and test with Java 11\nDeploy to Java 8 JVM] B --> C[Build and test with Java 17\nDeploy to Java 8 JVM] C --> D[Deploy to Java 11 in staging\nCI validates compatibility] D --> E[Deploy to Java 17 in staging\nLoad test] E --> F[Deploy Java 17 to production\n1 region first] F --> G[Monitor GC behavior 48h] G --> H[Full production rollout] H --> I[Begin Java 21 preparation]

The key insight is that you can build with a newer Java version while still running on the old one (within the --release compatibility range). This lets you catch source-level compatibility issues before changing your runtime.

<!-- Maven: compile with Java 17 syntax, target Java 11 class files -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <release>11</release>
  </configuration>
</plugin>

GC changes to expect when moving from Java 8 to Java 17+:

Java 8 defaults to Parallel GC. Java 11+ defaults to G1GC. G1 has different heap sizing heuristics and pause time behavior. After the JVM upgrade, monitor:

  • Old gen occupancy patterns (G1 is more aggressive about old gen collection)
  • Humongous allocation rate (use JFR — see the previous article)
  • GC pause duration distribution (G1 targets 200ms by default; tune with -XX:MaxGCPauseMillis)

Vendor Support and Compliance Considerations

In regulated environments, "we tested it" is not sufficient. Your software vendors, middleware vendors, and cloud platform providers all have Java compatibility matrices that are part of your compliance evidence.

Before upgrading production:

  • Confirm vendor support statements for Java 17/21 in writing. Support matrices change; get the current version.
  • Review your container base image — many regulated environments pin base image versions, and the Java 21 base image may require a separate approval process.
  • Check your monitoring and APM agents — New Relic, Dynatrace, Datadog, AppDynamics all have Java version compatibility requirements for their agents. Mismatched agents can silently fail or corrupt metrics.
  • If your application is FIPS 140-2 certified, verify that the new JDK provider supports your required algorithms. Java 21 removed some legacy providers.

Change control documentation that reviewers ask for:

  1. jdeps report showing no internal API usage post-migration
  2. Dependency vulnerability scan against the new classpath
  3. Load test comparison (p50, p95, p99 latency; error rate; GC metrics) between Java 8 and Java 21 builds
  4. Rollback procedure with specific triggers (error rate > X, GC pause > Yms)

Key Takeaways

  • Run jdeps --jdk-internals against your entire classpath before touching the JVM — this surfaces both your code issues and vendor library issues in one pass.
  • The Java EE to Jakarta namespace migration is not a find-and-replace; library major versions must be updated before namespace renaming or you will see runtime failures on correct-looking code.
  • Track --add-opens flags as explicit technical debt; each one represents a library using JDK internals that will eventually break.
  • The safest migration sequence builds with a newer Java compiler version first, then changes the runtime JVM separately — this decouples source compatibility from runtime compatibility.
  • G1GC (the Java 11+ default) has different heap and pause behavior than Parallel GC (Java 8 default); expect GC tuning to be necessary after the JVM upgrade, not before.
  • In regulated environments, vendor support statements, APM agent compatibility, and change control documentation are part of the migration work, not afterthoughts.