Java 8/11 to Java 21, 25, or 27: A Practical Migration Guide

Java 8/11 to Java 21, 25, or 27: A Practical Migration Guide

Compatibility, performance, and a rollout you can reverse

An engineering guide to upgrading an existing Java system without turning every improvement into a prerequisite. Version and support information reviewed on October 18, 2026.

The first successful startup on a new JDK is encouraging. It is also an unusually weak definition of a successful migration.

A service can start, answer its health check, and still corrupt a partner's CSV export, fail when an APM agent instruments a particular class, or miss its overnight processing window. None of those problems is visible in a screenshot of java -version.

A useful migration plan starts with a more demanding question: Can we operate the same business workload on a maintained platform, with understood behavior and a tested recovery path?

This guide follows the broader modernization discussion in Java & JVM in 2025 and From Java to Kotlin. Here, the focus is an existing Java application: what to change first, what to measure, and how to keep the upgrade diagnosable.

The examples assume a server-side application with a reproducible build, database access, and external integrations. Desktop deployments, application-server-managed estates, and native-heavy applications need additional product-specific work. Code and command examples are illustrative; paths and service limits must match your system.

1. Choose a destination you can keep maintaining

As of the review date, Java 21 and 25 are LTS releases in Oracle's roadmap; Java 27 is a non-LTS release. Java 25 is already released, not a future LTS. Oracle lists Java 27's general availability in September 2026 and Premier Support through March 2027. Those are Oracle's support terms, not a universal contract for every JDK distribution. See the Oracle Java SE Support Roadmap.

Candidate A reasonable reason to choose it The decision you still need to make
Java 21 Your supported application stack and organizational baseline already converge on 21 Which distribution and update channel will you maintain?
Java 25 You want a newer LTS baseline and your dependencies support it Can the full platform, including agents and native components, pass qualification?
Java 27 You have a specific reason to adopt its changes and can sustain frequent upgrades Who owns the next feature-release migration and its deadline?

For a migration starting now, I would evaluate 25 alongside 21 before defaulting to the older LTS. I would choose 27 only with an explicit ongoing release process. That is an engineering recommendation, not a claim that one version is universally safer.

Distribution selection is part of this decision. Record the vendor, exact build, supported architectures, patch source, license, and support arrangement. Oracle's roadmap says Oracle JDK 21 updates beginning with the October 2026 CPU are planned to change licensing terms. That does not mean all Java 21 distributions become paid, or that Java 8 and 11 are universally unsupported. Evaluate the actual distribution and intended use.

An update policy that depends on leaving an old binary untouched indefinitely is not a maintenance strategy. Put the next patch qualification into the plan before approving the first migration.

Make the business case concrete

The benefit is not merely access to new syntax. A maintained runtime can make supported framework and tooling upgrades possible, reduce the number of exceptional environments your team operates, and create room for measured performance improvements. None of that fixes application authorization bugs or outdated libraries automatically.

The cost includes engineering investigation, vendor product upgrades, regression testing, parallel environments, operational training, and follow-up patch work. A short-lived increase in infrastructure cost may be necessary to compare candidates and preserve recovery options. New defaults can also expose assumptions that were previously invisible.

Evaluate deferral with the same discipline: which supported components become unavailable, which exceptions accumulate, and which future change becomes harder? Avoid an invented universal ROI. For one service, the payoff might be retiring an unsupported agent; for another, it might be a tested reduction in memory at the same SLO. Write that success criterion before proposing the project.

2. Separate four changes that are usually bundled together

“Upgrade Java” often hides four workstreams:

  1. Runtime: the JVM, GC behavior, launch options, container image, and operating system.

  2. Build: the JDK running the build, compiler toolchain, plugins, test runners, and annotation processors.

  3. Dependencies: frameworks, database drivers, serializers, bytecode libraries, agents, and native code.

  4. Application design: new syntax, virtual threads, data models, or architectural changes.

These workstreams interact, but they do not have to land in one unreviewable change. A runtime update can precede records. A dependency refresh can precede a framework migration. A new compiler can continue targeting an older Java release where that is a deliberate compatibility requirement.

Consider three different questions:

  • Binary compatibility: can the new runtime load and execute the existing artifact?

  • Source compatibility: does the source compile under the new compiler and its rules?

  • Behavioral compatibility: do the same inputs still produce acceptable outputs and side effects?

Passing one does not establish the others. Oracle's migration preparation guide recommends trying the application on the new JDK before recompiling. This is a useful diagnostic experiment: it narrows the first variable to the runtime, although removed APIs and incompatible dependencies can still prevent execution.

Keep experiments small enough to explain. If the first candidate changes the JDK, Spring generation, database driver, container OS, and concurrency model, a regression becomes a search across five simultaneous hypotheses.

3. Java 8 and Java 11 begin with different liabilities

A Java 8 application may rely on components that used to ship with the JDK. Java 11 removed the Java EE and CORBA modules listed in JEP 320, including JAXB and JAX-WS modules. A service that implicitly obtained XML binding classes from Java 8 may therefore need explicit dependencies before it can run on a modern runtime.

Adding an API JAR alone is not necessarily enough: a particular API may also require an implementation and matching transitive dependencies. Choose a coherent version family rather than resolving each missing class with whichever artifact appears first in a search result.

An application already running on Java 11 has crossed that particular runtime boundary. It may still contain libraries relying on reflective access that newer JDKs no longer permit, old bytecode parsers, or scripts assuming historical JVM options. Its difficulty depends on the dependency tree and deployment model, not just the number 11.

Two additional questions help expose scope early:

  • Does the product depend on Web Start, an embedded browser integration, or a vendor launcher? Treat deployment packaging as its own investigation.

  • Does it install a Security Manager? JEP 486 permanently disabled that facility in JDK 24. A design relying on it needs an architectural replacement when moving to 25 or 27; a flag is not a substitute for its former isolation model.

“Runs on Java 21” is therefore useful evidence for 25 or 27, but not proof of compatibility with them.

4. Produce evidence before estimating the whole project

The fastest useful first deliverable is an inventory with owners and test evidence.

Area Capture Why it matters
Runtime Vendor, exact update, architecture, launch command Developer and production JDKs often differ
Build Wrapper, plugins, compiler and test toolchains The build itself can fail before application code is examined
Libraries Resolved runtime dependencies, not only declared ones Transitive dependencies can contain the incompatible code
Operations Agents, certificates, native libraries, service scripts These may be absent from developer tests
Workload Peak traffic, scheduled work, integration formats A health check covers very little of the actual system

On a Unix-like development machine, start with:

java -version
java -XshowSettings:properties -version
./mvnw -version
./mvnw dependency:tree
# For a Gradle project instead:
./gradlew --version
./gradlew dependencies --configuration runtimeClasspath

Run the relevant checks inside the deployment image too. In a multi-module project, select each deployable module and its applicable configuration. Record the output with the candidate's build identifier. Review environment and diagnostic output before sharing it: paths, properties, and arguments may contain operational information.

Next, create a compatibility worksheet. Give each dependency an installed version, target-JDK evidence link, intended replacement, owner, and test. “Probably works” is an unresolved item, not a green cell.

Static analysis is a lead generator

For an ordinary application JAR whose dependencies are in lib/, a Java 21 investigation can include:

"$JDK21_HOME/bin/jdeps" --multi-release 21 --jdk-internals \
  --class-path 'lib/*' app.jar

"$JDK21_HOME/bin/jdeprscan" --release 21 \
  --class-path 'lib/*' app.jar

Set JDK21_HOME to an installed JDK 21 directory. jdeps examines class dependencies and can identify use of internal JDK APIs. --multi-release 21 chooses the relevant view of multi-release JARs; it does not certify Java 21 compatibility.

jdeprscan reports uses of deprecated Java SE APIs. It is not a comprehensive detector for removed APIs, third-party incompatibilities, or behavioral changes. An empty report is not a migration sign-off.

Executable archives with nested libraries require additional preparation: inspect their unpacked application classes and dependencies using the actual layout. Scan owned code and important dependency artifacts, resolve missing-class diagnostics, then exercise reflection, service loading, and generated classes through runtime tests. Static tools cannot infer every dynamically constructed class name.

5. Fix the build before interpreting every failure as a Java problem

There may be several JDKs involved in one build: the JVM running Maven or Gradle, the compiler toolchain, the JVM running tests, and the deployed runtime. Write them down separately.

For example, the Gradle compatibility matrix distinguishes Java toolchain support from support for running Gradle itself. A wrapper that can select a compiler is not automatically compatible with using that same JDK to execute the build daemon.

Inspect annotation processors, coverage tools, mock frameworks, and bytecode transformation libraries as well. Lombok, ASM, Byte Buddy, and agents are common places to investigate because they interact with compiler internals or class-file structure. Do not assume that updating the top-level framework updates every embedded copy.

If a transitional artifact must remain compatible with Java 11 while being compiled by JDK 21, a modern Maven Compiler Plugin can use:

<properties>
  <maven.compiler.release>11</maven.compiler.release>
</properties>

Pin an appropriate plugin version in your build. --release selects language and class-file rules and the platform API surface for that release; it is stronger than setting only source and target levels. The Maven compiler documentation explains the configuration.

This does not downgrade dependency JARs or prove runtime behavior on Java 11. Test that artifact on Java 11 if you promise to support it. Once the deployed application intentionally requires Java 21, set the release accordingly and retain an independently built old artifact for rollback.

6. Treat deep reflection failures as dependency evidence

A typical migration failure looks like this:

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

JEP 403 strengthened encapsulation of JDK internals in Java 17. The old broad --illegal-access escape hatch does not restore historical access. Find the component performing the reflective operation, identify a supported replacement, and test the affected behavior.

A targeted option may help diagnose or temporarily unblock a specific library:

java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar

This opens that package to unnamed modules, including classpath code. It is not a generic “Java compatibility” setting. --add-exports concerns access to a package's public types; --add-opens additionally permits deep reflection. Choose based on the actual failure.

For each exception, record the package, dependent component, justification, owner, and removal condition. Re-test without it after the dependency update. Otherwise a temporary bridge becomes an undocumented requirement copied into every future service.

7. Keep the Jakarta migration on a separate axis

Changing the JDK does not rename javax.servlet to jakarta.servlet.

That namespace change comes from the enterprise API generation used by your framework and container. Spring Boot's 3.0 migration guide combines a Java 17 baseline with Spring Framework 6 and Jakarta EE changes. These requirements often arrive together in a project, which makes them easy to conflate.

The container distinction matters: Tomcat 9 uses the Java EE-era servlet namespace; Tomcat 10 introduced the Jakarta namespace change. Tomcat 10.1 and 11 continue on that side of the boundary. See the Tomcat 10 migration guide and version matrix.

Do not globally replace every javax package: Java SE packages such as javax.sql and javax.crypto remain relevant. Check imports, generated sources, configuration, transitive libraries, and framework integrations against the APIs you actually use.

A useful sequencing decision is whether you can first move the existing framework to a supported patch level on the target JDK. If so, runtime qualification and the namespace migration can be separate changes. If your application server or framework cannot support the destination, their upgrade becomes a prerequisite. This is a dependency decision, not a reason to force every application through identical intermediate releases.

8. Test the failures that still return HTTP 200

Behavioral regressions often survive compilation and unit tests because the test environment shares the new defaults.

JEP 400 made UTF-8 the default charset for standard Java APIs in JDK 18, with specified exceptions such as console I/O. Code depending on a machine's previous default encoding can behave differently.

Suppose a partner requires a Windows-31J CSV. Make the contract explicit:

try (var writer = Files.newBufferedWriter(
        outputPath, Charset.forName("windows-31j"))) {
    writer.write(csvContent);
}

This chooses the encoding; it does not implement CSV quoting or prove that every character can be represented. Test characters outside ASCII, escaping, line endings, and the partner's actual parser. Where replacement characters are unacceptable, configure an encoder to report unmappable input and fail the export deliberately.

Build golden-file tests around the boundary: known input, expected bytes, and expected consumer behavior. A round trip through your own reader can miss a shared mistake in both writer and reader.

Apply the same reasoning elsewhere:

Boundary A useful test
Dates and time zones A real scheduled job near a daylight-saving transition
Locale formatting Exact output required by a downstream parser
TLS and certificates A handshake with representative endpoints and trust material
Serialization Old and new application versions reading each other's payloads
Database behavior Transactions, generated SQL, pool timeouts, and driver upgrades
Reports Fonts, layout, binary attachments, and non-ASCII content

When a security default rejects an old endpoint, investigate certificates, protocol support, and the endpoint upgrade path. Re-enabling obsolete behavior globally can turn one compatibility fix into a platform-wide regression.

9. Understand what virtual threads actually change

Virtual threads became final in Java 21 through JEP 444. They make a thread-per-task style practical for many workloads that spend substantial time waiting on I/O. They do not make a CPU-bound calculation execute faster or increase a database's capacity.

A simple capacity estimate helps explain the distinction. Under steady-state assumptions, 1,000 requests per second spending an average of 0.2 seconds in the system implies roughly 200 concurrent requests. This is an illustrative application of Little's Law, not a benchmark. If most of that time is waiting, reducing the cost of representing those waiting tasks can help. If the CPU is already saturated, adding runnable work usually just adds contention.

A small Java 21 example makes the ownership explicit:

import java.util.concurrent.*;

public class LimitedTasks {
    public static void main(String[] args) throws Exception {
        var downstreamSlots = new Semaphore(20); // Example limit
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> result = executor.submit(() -> {
                if (!downstreamSlots.tryAcquire(250, TimeUnit.MILLISECONDS)) {
                    throw new TimeoutException("Downstream is saturated");
                }
                try {
                    return callDownstream();
                } finally {
                    downstreamSlots.release();
                }
            });
            System.out.println(result.get());
        }
    }

    static String callDownstream() throws InterruptedException {
        Thread.sleep(50); // Simulated I/O, not a performance test
        return "ok";
    }
}

In a real service, reuse a suitably scoped executor and capacity limiter; this short-lived executor only demonstrates lifecycle and permit release. The limit must be shared across the requests it is intended to control. A new semaphore per request would impose no global limit.

Also configure connection, read, and overall request deadlines in the actual client. The permit-acquisition timeout above limits admission waiting, not network duration. Use ingress limits or bounded admission where necessary: cheaply creating a large number of waiting tasks can still consume memory. Avoid pooling virtual threads merely to restrict a downstream service; control the scarce resource directly.

Java 21 can pin a virtual thread to its carrier while blocking within synchronized code. JEP 491, delivered in Java 24, removed that source of pinning. Other cases remain, including certain native-code interactions and class-initialization situations. Advice to rewrite every synchronized block is therefore both too broad and version-dependent.

Adopt this concurrency change after the runtime baseline is stable. It deserves its own load tests, queue metrics, timeout tests, and failure scenarios.

10. Take newer capabilities selectively

The easiest language improvements are often local. Records, finalized in Java 16, can express a compact data carrier:

public record CustomerSummary(long id, String displayName) {}

That is useful for an internal projection or a response model when the surrounding framework supports it. It is not a universal replacement for ORM entities or mutable domain objects. Records provide shallow immutability of their component fields; a component referencing a mutable collection still needs careful handling.

Pattern matching for switch, final in Java 21, can make branching over types clearer. Introduce it where it improves a specific piece of code. A repository-wide syntax rewrite adds review noise without helping establish runtime compatibility.

Java 27 also deserves concrete evaluation rather than being described only as “newer.” Its official feature list includes:

These changes can matter without application source rewrites. They also create new variables for qualification. Preview and incubator features listed alongside them have a different stability contract. In particular, a preview feature should not quietly become a required dependency of a migration whose main purpose is operational stability.

11. Benchmark the service, then explain the result

A JDK upgrade can improve performance. It can also move the bottleneck or trade memory for latency. Treat performance as a measured outcome.

First compare the old and candidate runtimes under equivalent conditions: hardware or container limits, heap configuration, workload distribution, dataset, agents, and downstream behavior. Where a dependency change is mandatory, record that confounder rather than attributing every difference to the JVM.

Then run a separate tuning experiment. This separates “what changed by upgrading?” from “what can we improve on the new baseline?”

Capture at least the metrics that correspond to your service objectives:

  • p95/p99 latency and error rate at a defined offered load;

  • sustained throughput without increasing queues;

  • CPU, container throttling, and process RSS, not only Java heap;

  • allocation rate, GC pauses, and GC time;

  • database-pool waiting, outbound timeouts, and retry volume;

  • startup, warm-up, and graceful shutdown behavior.

A throughput number without its latency and error conditions is ambiguous. A load generator that slows down when the service stalls can understate the problem. Include traffic bursts, dependency slowdown, and representative background jobs, not just steady requests against a warm cache.

Java Flight Recorder can help connect a symptom to behavior. On a permitted Java 21 test process:

jcmd "$JAVA_PID" JFR.start name=migration settings=profile \
  duration=120s filename=migration.jfr

The jcmd reference documents the recording command. Check permissions, output location, and recording overhead for your environment. Compare recordings from equivalent workload windows; a slow trace and a fast trace under different traffic are not a controlled experiment.

Write acceptance criteria before looking at the candidate results. Use existing SLOs and business constraints: the nightly job's deadline, acceptable error budget, memory ceiling, and recovery behavior. Any new thresholds should be agreed explicitly rather than chosen afterward to make the upgrade pass.

12. Include the machinery surrounding the application

Test the production agent set early. JEP 451 introduced warnings in JDK 21 for dynamically loaded agents and described a direction toward stronger integrity. It distinguishes dynamic attachment from agents explicitly configured at startup. Check the exact agent version, loading mode, and target-runtime behavior; do not interpret “agent support” as one binary property.

Similarly, JEP 472 introduced JNI-related warnings and native-access controls in JDK 24. Inventory libraries that load native code and follow their documented configuration. The JEP's staged transition is not evidence that every later JDK has identical defaults, so verify the runtime you deploy.

Containers add another compatibility surface. A new base image can change libc, certificate stores, fonts, timezone files, and operating-system packages. Native libraries may support one architecture but not another. Keeping -Xmx unchanged also does not keep total process memory unchanged: thread stacks, direct buffers, metaspace, code cache, agents, and native allocations exist outside the heap.

Exercise health checks, metrics scraping, thread dumps, shutdown hooks, scheduled jobs, and crash recovery in the actual image. A deployment that passes API tests but cannot be diagnosed during an incident has not preserved operational capability.

13. Design rollback around artifacts and data

“Switch the JDK back” is an incomplete rollback plan.

Classes compiled for a newer release may not load on an older JVM. Dependencies may also require the new runtime. Preserve the old application artifact together with its JDK image, configuration, deployment manifest, and relevant agent versions. Treat that bundle as the rollback unit.

Data is the harder boundary. Before old and new versions coexist, ask:

  • Can both read the database schema and newly written values?

  • Can both consume messages and cached objects produced by the other?

  • Are session representations compatible?

  • Will replaying a request or job duplicate a side effect?

If a schema change is unavoidable, an expand-and-contract approach can preserve coexistence: add compatible structures, deploy code that tolerates both representations, migrate data, and remove old structures only after the rollback window closes. The details depend on the database and application; the key is to avoid an irreversible format change in the same step as the runtime cutover.

A canary rollout should define its cohort, observation window, promotion criteria, and abort criteria. Choose them from risk and workload rather than copying a universal percentage. Background consumers and scheduled jobs need their own rollout controls; routing a small share of HTTP traffic does not limit their effects.

Rehearse returning traffic to the old bundle. Confirm what happens to in-flight requests, queued jobs, sessions, and data already written by the candidate. A fast traffic switch does not undo completed business transactions.

14. Turn the first migration into a repeatable process

A practical sequence is:

Stage Evidence required to proceed
Inventory Reproducible current build, dependency map, baseline workload
Runtime probe Existing artifact tested on the candidate; failures classified
Compatibility work Required build and dependency changes isolated and reviewed
Qualification Business outputs, integrations, performance, and operations accepted
Release rehearsal Old bundle available; data coexistence and recovery tested
Production rollout Observed canary behavior meets predetermined criteria
Follow-up Temporary flags owned; patch cadence and next review scheduled

Intermediate JDKs can help localize when a failure appears. You do not need to put each intermediate version into production. Conversely, direct deployment to the final target is not mandatory if a vendor upgrade path requires a staged platform change. Choose the sequence that reduces uncertainty in your actual dependency graph.

Three starting situations usually produce different first actions. If the stack already supports the target, run the runtime probe and qualification work. If old dependencies dominate, update the blockers while keeping behavior observable. If the application server, operating system, and deployment model are all aging, plan the platform work together while preserving separate evidence for each change.

The migration is complete when the service's behavior and operations are understood on the new platform, recovery has been demonstrated, and the next update has an owner. Records, virtual threads, and further tuning can then become focused improvements with their own evidence.

Start with one deployable service, one candidate runtime, and one repeatable business workload. The most valuable outcome is a system you can update again without rediscovering every assumption that kept the old one running.