JDK 11 vs JDK 8: What Actually Changed and What Migration Costs

JDK 11 vs JDK 8

The migration from JDK 8 to JDK 11 is the most consequential Java version upgrade most teams have done. The gap spans three major releases — 9, 10, and 11 — each of which introduced fundamental changes to the JVM structure, its standard APIs, and the assumptions third-party libraries could make about the platform. Understanding what changed and what it costs is the prerequisite for a successful migration.

The Module System: The Core Migration Challenge

Java 9 introduced the module system (JEP 261, Project Jigsaw). The JDK itself was modularized — java.base, java.logging, java.sql, and dozens more. This had a direct consequence: internal JDK APIs that were previously accessible via reflection became restricted by default under strong encapsulation.

The specific mechanism: packages in JDK modules that are not exported cannot be accessed by code outside those modules, even via reflection. JDK 8 code that used sun.misc.Unsafe, internal codec implementations, or private JDK classes will fail at runtime on JDK 11. Libraries in the ORM, serialization, and bytecode manipulation space commonly do this and must be updated. The diagnostic tool is jdeps: running jdeps --jdk-internals --classpath <your-classpath> <your-jar> surfaces every internal API your application and dependencies use, along with recommended public replacements. Run this before touching anything else.

Removed APIs: JAXB, JAX-WS, and the Java EE Modules

Java 11 removed the Java EE modules that were bundled with the JDK: java.xml.ws (JAX-WS), java.xml.bind (JAXB), java.activation (JAF), and java.corba. These were always part of Java EE, not Java SE — the JDK bundled them for convenience. In JDK 11 they are gone. Any application using JAXB for XML binding or JAX-WS for SOAP must now declare them as explicit Maven/Gradle dependencies.

// Java 11+ — JAXB requires explicit dependency; was bundled in JDK 8, removed in JDK 11
// pom.xml additions required:
// <dependency>
//   <groupId>jakarta.xml.bind</groupId>
//   <artifactId>jakarta.xml.bind-api</artifactId>
//   <version>3.0.1</version>
// </dependency>
import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.Marshaller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class XmlSerializer {
    private static final Logger log = LoggerFactory.getLogger(XmlSerializer.class);

    public String toXml(Object payload) throws Exception {
        JAXBContext ctx = JAXBContext.newInstance(payload.getClass());
        Marshaller m = ctx.createMarshaller();
        m.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
        java.io.StringWriter sw = new java.io.StringWriter();
        m.marshal(payload, sw);
        log.debug("Serialized {} to XML", payload.getClass().getSimpleName());
        return sw.toString();
    }
}Code language: Java (java)

Collection Factory Methods: Immutable Collections

Java 9 added factory methods for small, immutable collections: List.of(), Set.of(), and Map.of(). These replace Arrays.asList() and Collections.unmodifiableList(). The collections produced are truly immutable — any modification throws UnsupportedOperationException immediately. Unlike Arrays.asList(), they also disallow null elements, surfacing null-related bugs at construction rather than later in the call chain.

// Java 9+ — immutable collection factories; cleaner and safer than Arrays.asList()
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Set;
import java.util.Map;

public class CollectionFactoryExamples {
    private static final Logger log = LoggerFactory.getLogger(CollectionFactoryExamples.class);

    // Fully immutable — set(), add(), remove() all throw UnsupportedOperationException
    private static final List<String> SUPPORTED_CURRENCIES = List.of("USD", "EUR", "GBP", "JPY");
    private static final Set<String> ADMIN_ROLES = Set.of("ROLE_ADMIN", "ROLE_SUPER");
    private static final Map<String, Integer> HTTP_CODES = Map.of(
        "OK", 200,
        "CREATED", 201,
        "BAD_REQUEST", 400,
        "NOT_FOUND", 404
    );

    public boolean isSupportedCurrency(String currency) {
        return SUPPORTED_CURRENCIES.contains(currency);
    }

    public void processRequest(String status) {
        Integer code = HTTP_CODES.get(status);
        if (code == null) {
            log.warn("Unknown status: {}", status);
            return;
        }
        log.info("Responding with HTTP {}", code);
    }
}Code language: Java (java)

Stream API Additions (Java 9)

The Stream API gained four new methods in Java 9 that address patterns commonly worked around with external iteration. takeWhile(predicate) stops processing when the predicate first returns false on an ordered stream. dropWhile(predicate) skips elements until the predicate returns false, then passes the remainder through. Stream.iterate(seed, hasNext, next) adds a termination condition to the previously infinite iterate. Stream.ofNullable(value) produces a zero or one-element stream from a possibly-null value without null checks.

// Java 9+ — takeWhile, dropWhile, iterate with termination, ofNullable
import java.util.stream.Stream;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class StreamApiExamples {
    private static final Logger log = LoggerFactory.getLogger(StreamApiExamples.class);

    public void processAmounts(List<Integer> sortedAmounts) {
        // takeWhile: process only amounts up to the $10,000 tier threshold
        long smallOrders = sortedAmounts.stream()
            .takeWhile(amount -> amount <= 10_000)
            .count();
        log.info("Orders in small tier: {}", smallOrders);

        // dropWhile: skip items below the minimum processing threshold
        sortedAmounts.stream()
            .dropWhile(amount -> amount < 1_000)
            .forEach(amount -> log.debug("Processing high-value order: {}", amount));
    }

    // ofNullable avoids wrapping null checks in stream pipelines
    public long countIfPresent(List<String> maybeList) {
        return Stream.ofNullable(maybeList)
            .flatMap(List::stream)
            .filter(s -> !s.isBlank())
            .count();
    }
}Code language: Java (java)

var for Local Variables (Java 10)

Java 10 introduced local variable type inference with var. The compiler infers the declared type from the initializer; the bytecode is identical to an explicit type declaration. var cannot be used without an initializer. It is useful for removing redundant type repetition — particularly in try-with-resources blocks and for verbose generic types — but it reduces readability when the inferred type is not obvious from the right-hand side of the declaration.

// Java 10+ — var for local type inference; identical bytecode to explicit type declaration
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class VarExamples {
    private static final Logger log = LoggerFactory.getLogger(VarExamples.class);

    public void queryOrders(javax.sql.DataSource ds) throws Exception {
        // var in try-with-resources eliminates type repetition
        try (var conn = ds.getConnection()) {      // inferred: Connection
            var stmt = conn.createStatement();     // inferred: Statement
            var rs = stmt.executeQuery("SELECT id, amount FROM orders"); // inferred: ResultSet
            while (rs.next()) {
                var id = rs.getString("id");        // inferred: String
                var amount = rs.getLong("amount"); // inferred: long
                log.info("Order {}: ${}", id, amount);
            }
        }
        // var where type is NOT obvious — avoid
        // var result = process(input); // What type is result? Reader must find process()
        // Explicit types aid readability when the assignment does not reveal the type
    }
}Code language: Java (java)

G1 as Default GC: Impact on Existing Tuning

Java 9 changed the default GC from Parallel GC to G1 (JEP 248). Parallel GC optimizes for throughput at the cost of pause time variability; G1 balances throughput with a configurable pause target (-XX:MaxGCPauseMillis, defaulting to 200ms). Applications tuned for Parallel GC with explicit -XX:+UseParallelGC are unaffected — the flag is honoured. Applications that relied on the JDK default without explicit GC flags are now on G1, with different memory and pause characteristics. Monitor GC logs (-Xlog:gc* replaces -verbose:gc in JDK 9+) for the first week after migration. Applications previously suffering from GC pause spikes under Parallel GC will typically improve without any GC configuration change.

Interview Questions

What happens when JDK 8 code that accesses sun.misc.Unsafe for field access is run on JDK 11?

On JDK 11, the JVM emits a warning and the access may succeed depending on whether the specific API is used via reflection and whether --add-opens flags are present. From JDK 16 onward, strong encapsulation is enforced by default and direct reflective access to sun.misc.Unsafe-adjacent internal APIs throws InaccessibleObjectException. The public replacement for low-level field access is java.lang.invoke.VarHandle, which provides the same operations with full module system support. The migration path is: use jdeps --jdk-internals to identify every affected library, then update those libraries to versions that have adopted VarHandle. If a library has no updated version, the options are to add --add-opens flags as a temporary workaround, or replace the library.

How does the system behave when code passes a List.of() result to a method that calls list.set(index, value)?

UnsupportedOperationException is thrown at the set() call site. This is true for all modification methods — add(), remove(), set(), replaceAll(), and sort(). Unlike Arrays.asList(), which permits set() but throws on structural modifications, List.of() is completely immutable. The exception surfaces at the modification point, not at construction, which means the failure can appear far from where the list was created — often inside a library method. The fix is to wrap in a mutable copy at the boundary: new ArrayList<>(List.of(...)) when passing to code that may modify the list.

What issues arise when migrating a Spring Boot 2.x fat JAR application from JDK 8 to JDK 11 without module-info.java?

Applications running without module-info.java run on the unnamed module and bypass most module system enforcement. The issues that do surface are: removed APIs (JAXB, JAX-WS — add explicit dependencies), libraries using reflective access to JDK internals (add --add-opens for each flagged library as a temporary bridge, then update the library), and potentially changed GC default. Spring Boot 2.x itself requires a version compatible with JDK 11. In practice, the migration is mostly dependency and configuration work, not code changes. Running jdeps --jdk-internals before migrating, updating Spring Boot to the latest 2.x patch, and adding missing Java EE dependencies covers 90% of issues.

Summary

The JDK 8 to JDK 11 migration is primarily a library compatibility task. The module system restricts previously-accessible internal APIs — jdeps --jdk-internals gives you the complete list before you start. The removed Java EE modules require explicit dependency additions. Everything else is additive: collection factories, Stream improvements, var, the HTTP Client, Flight Recorder. Run on the classpath (no module-info.java) to sidestep module enforcement if full modularization is not the goal. The productivity and operational gains — particularly Flight Recorder for production profiling and the standardized HTTP Client — justify the migration investment for any team still on JDK 8.

Leave a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Scroll to Top