Java 11 was the first long-term support release after Java 8. Oracle’s announcement that commercial Java 8 support would end pushed the bank’s architecture committee to approve a migration. In theory: update the JDK, update the build files, done. In practice: six months of discovery.

This is a frank account of what broke.

The Scale

The estate was ~150 Java services, ranging from small utilities to multi-module applications with complex dependency trees. All running Java 8. Most using Spring Boot 1.x, various other frameworks, and direct use of sun.* internal APIs.

We ran the migration in four phases:

  1. Compile with Java 11 (javac --release 11), run on Java 8 — identifies compile-time issues
  2. Run on Java 11 with --add-opens flags — runtime issues
  3. Remove --add-opens — proper module boundary compliance
  4. Audit dependencies for known Java 9–11 incompatibilities

Phase 1 was supposed to take two weeks. It took six.

What javac --release 11 Found

sun.misc.Unsafe direct usage: about 15 services used Unsafe directly for off-heap allocation, memory ordering, or field offset calculation. javac --release 11 issued warnings (it still compiled), but the runtime module system required --add-opens java.base/sun.misc=ALL-UNNAMED. We replaced these with java.lang.invoke.MethodHandles.privateLookupIn() and VarHandle where possible.

Removed APIs: several APIs present in Java 8 were removed in Java 11 as part of JEP 320 (removal of Java EE and CORBA modules):

Removed packages that caused compilation failures:
  javax.xml.bind.*       (JAXB — XML binding)
  javax.activation.*    (JavaBeans Activation Framework)
  javax.annotation.*    (Java EE annotations)
  com.sun.xml.internal.* (Sun's internal XML implementation)

JAXB was the most common. Our services used it for generating trade confirmation XML for SWIFT messages. The fix was adding it as an explicit Maven dependency:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<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>
</dependency>

This worked, but now version conflicts between services were possible where before they’d all used the built-in implementation. Managing this in a monorepo with shared dependency versions mitigated the issue.

Phase 2: What Failed at Runtime

Reflection on private fields: many libraries (Jackson, Hibernate, Spring) used reflection to access private fields. Java 9+ requires explicit module opens declarations for this. Without them, you get:

java.lang.reflect.InaccessibleObjectException:
  Unable to make field private java.util.ArrayList java.util.Collections$UnmodifiableList.list
  accessible: module java.base does not "opens java.util" to unnamed module @5197848c

The short-term fix: add --add-opens flags to the JVM invocation:

1
2
3
4
5
java --add-opens java.base/java.util=ALL-UNNAMED \
     --add-opens java.base/java.lang=ALL-UNNAMED \
     --add-opens java.base/sun.nio.ch=ALL-UNNAMED \
     # ... many more
     -jar service.jar

We ended up with 12–20 --add-opens flags for some services. This is not a solution — it’s a workaround that suppresses the module system. The proper fix was updating to library versions that didn’t require illegal reflective access.

Library version incompatibilities:

LibraryJava 8 versionJava 11 requiredIssue
Spring Boot1.5.x2.1+internal API changes
Jackson2.6.x2.9+reflection changes
Hibernate5.0.x5.3+javax.persistence removal
Netty4.0.x4.1+sun.misc.Cleaner usage
Mockito1.x2.xreflection for field injection

Each library upgrade potentially broke other things. The actual migration required resolving a dependency upgrade chain, not just updating the JDK.

Phase 3: Removing --add-opens

After upgrading libraries, we worked through each remaining --add-opens flag and either:

  • Found a library update that removed the need
  • Replaced the code that required it with the official API
  • Accepted it as a permanent flag for a third-party library we couldn’t update

One notable case: the database connection pool (a vendor library, not open source) used reflection to access private fields in java.sql.Connection. The vendor released a Java 11 compatible version three months into our migration. Until then, that flag stayed.

Encoding Changes

A subtle runtime change: Java 11 defaulted to UTF-8 for file I/O in most cases, but some edge cases changed behaviour compared to Java 8. Services reading configuration files in the default platform encoding (Charset.defaultCharset()) on Windows build machines broke when the build environment was updated.

The fix is the same as it’s always been: never use Charset.defaultCharset(). Always specify StandardCharsets.UTF_8 explicitly. We found this by running the test suite on Windows and Linux build agents — it only manifested on Windows.

Performance: Before and After

Post-migration performance measurements on three representative services:

Service type              Java 8 p99    Java 11 p99    Delta
─────────────────────────────────────────────────────────────
FIX message processor     210µs          195µs         -7%
Risk calculation batch    8.2s           7.4s          -10%
HTTP API (trade lookup)   45ms           41ms          -9%
Startup time              12.1s          10.8s         -11%
GC pause (G1GC, 8GB)      45ms           38ms          -16%

Java 11 was consistently faster. The JIT improvements, G1GC updates, and other internal improvements added up to a 7–16% improvement depending on the workload type. Worth the migration cost even without the support deadline pressure.

What to Do Before Starting

The lessons that would have saved us time:

Run jdeps on every JAR before starting:

1
jdeps --jdk-internals --multi-release 11 --class-path '.:*' service.jar

This identifies uses of internal APIs at the bytecode level, including ones in transitive dependencies.

Upgrade Spring Boot first (to 2.x) as a separate change before the JDK upgrade. Many other library upgrades flow from Spring Boot’s dependency management. Conflating the Spring upgrade with the JDK upgrade doubles the change surface.

Set up a Java 11 CI job early. Run the test suite against Java 11 from the start, even if you’re still developing on Java 8. Failures in CI are far cheaper to investigate than surprises during the migration push.

Budget 3× longer than expected. The dependency chain issues always take longer than the actual JVM incompatibilities. We estimated 8 weeks; it took 24.