Upgrading a Java 6 Codebase to JDK 11
Lessons from moving a 20+ year old Java codebase from JDK 6 to JDK 11 without production noticing.
The brief was simple: “Upgrade the application to Java 11.” The footnote was less simple: the codebase was 20+ years old, had been running on Java 6 since before some of my colleagues finished school, had no test coverage to speak of, and production couldn’t stop.
Here’s what I learned.
Understand the battlefield first
Before touching a single file, I spent time mapping the dependency landscape.
1
2
3
4
5
6
# Check what Java version the class files were compiled for
$ javap -verbose MyClass.class | grep "major version"
major version: 50 # 50 = Java 6, 52 = Java 8, 55 = Java 11
# Full dependency tree
$ mvn dependency:tree > deps.txt
I was looking for: libraries that used sun.* internal APIs, anything touching XML/JAXB/JAX-WS (these were removed from the JDK in Java 11), and duplicate jars at conflicting versions.
Also check the start scripts for old JVM flags. Several that were common in Java 6/7/8 were removed:
1
2
3
4
-XX:MaxPermSize # PermGen is gone (replaced by Metaspace in Java 8)
-XX:PermSize # same
-XX:+UseParNewGC # removed in JDK 10
-Xincgc # removed in JDK 9
These will cause a startup failure with a cryptic Unrecognized VM option error if you leave them in.
Don’t jump straight to 11
Jumping from Java 6 to Java 11 in one step means dealing with every breaking change from versions 7, 8, 9, 10, and 11 simultaneously. I staged it.
Phase 1 — change the Maven compiler target to Java 8 while still running on JDK 6. Fix compilation errors. This catches API and syntax issues without touching the runtime.
1
2
3
4
5
6
7
8
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
Phase 2 — compile and run the test suite on JDK 8. Fix runtime failures. This is where removed APIs and behaviour changes surface.
Phase 3 — run on JDK 11. The module system is the main challenge here.
The JAXB problem
JDK 9+ removed JAXB, JAX-WS, and related APIs that were bundled in JDK 6/8. Any code that used them will fail at runtime with:
1
java.lang.ClassNotFoundException: javax.xml.bind.JAXBException
The fix is to add them as explicit dependencies:
1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>2.3.3</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.3</version>
<scope>runtime</scope>
</dependency>
In our codebase, JAXB was used in about fifteen places, most of them in code that nobody had touched in a decade. I found them all via the compile errors once I switched to targeting JDK 11.
The internal API problem
Old code that used sun.* or com.sun.* classes will fail at runtime on JDK 11. Common offenders:
1
2
3
// Gone
import sun.misc.BASE64Encoder;
import sun.misc.BASE64Decoder;
Replacement:
1
2
// Available since Java 8
import java.util.Base64;
For third-party libraries using internal APIs that you can’t easily replace, --add-opens buys you time:
1
$ java --add-opens java.base/sun.misc=ALL-UNNAMED -jar myapp.jar
It’s a workaround, not a fix. Track down the library version that stopped using the internal API and upgrade to that.
Tests are your safety net — and if you don’t have them, this is the moment to add them
This is the honest part of the post. If you go into this migration with low test coverage, it’s high-risk. Every change you make, you’re relying on observation to catch regressions — which means some regressions reach production.
The silver lining is that the process of understanding old code well enough to migrate it is the same process needed to write characterisation tests for it. I used the migration as the forcing function to add tests to code I was modifying anyway.
Check memory before and after
JDK 11 with Metaspace behaves differently under memory pressure than Java 6 with PermGen. G1GC (the default in JDK 9+) also behaves differently from whatever the old app was using. Profile the JVM after migration:
1
2
3
-Xlog:gc*:file=/tmp/gc.log:time,uptime:filecount=5,filesize=20m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
In our case, G1GC performed better than the old parallel GC setup for our workload. But verify it in your own context — there’s no guarantee the default collector is the right one for your application.