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.
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 -100jdeps --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.DirectBufferThis 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.
The complication is that javax.persistence → jakarta.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 INFOThe 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 moduleThis 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.jarThe --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:
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:
jdepsreport showing no internal API usage post-migration- Dependency vulnerability scan against the new classpath
- Load test comparison (p50, p95, p99 latency; error rate; GC metrics) between Java 8 and Java 21 builds
- Rollback procedure with specific triggers (error rate > X, GC pause > Yms)
Key Takeaways
- Run
jdeps --jdk-internalsagainst 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-opensflags 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.