Java 17 Migration Guide (11→17)

July 28, 20264 min readUpdated 8/20/2026

Moving from Java 11 to 17 is a much smaller job than 8 to 11. Nothing large was removed from the JDK. The one change that breaks builds is strong encapsulation — reflective access into JDK internals stopped being a warning and became an error.

The change that matters

Java 9 hid the JDK's internal packages behind the module system but left the door open, printing a warning when something reached through it. Java 16 closed it by default and Java 17 made that permanent:

// Java 11 — a warning, and everything works
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by com.example.Tool to field java.lang.String.value

// Java 17 — an exception, and it does not
java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[]
    java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module

If you saw those warnings on Java 11 and ignored them, this is the bill. That is the single most useful thing to know before starting: run on 11 first, collect every warning, and fix those before switching the JDK.

Fixing it properly

The offender is almost never your code — it is a library reaching into the JDK. Old versions of Hibernate, Spring, Mockito, Lombok, Jackson's afterburner module and several bytecode tools all did it, and all have versions that do not.

So the fix is nearly always upgrade the dependency. Find which one from the exception's stack trace, check its release notes for Java 17 support, and move to that version.

# What is on the classpath and what does it reach into?
jdeps --jdk-internals --multi-release 17 target/myapp.jar

# Maven will tell you which dependency versions are available
mvn versions:display-dependency-updates

The flag you should not reach for

# Reopens a package. Works. Also postpones the problem indefinitely.
java --add-opens java.base/java.lang=ALL-UNNAMED -jar myapp.jar

--add-opens restores the old behaviour for one package. It is the right answer for exactly two situations: an unmaintained dependency with no fixed version, and buying time during an upgrade you have already scheduled.

It is the wrong answer as a permanent fix, because the flag has to be threaded through every way the application is started — your IDE, the test runner, the container entrypoint, the CI job — and each one is a place someone will forget. A build that needs six --add-opens flags is a build that will break again on the next upgrade.

If you must use it, put it in the build so it is at least recorded:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <argLine>--add-opens java.base/java.lang=ALL-UNNAMED</argLine>
    </configuration>
</plugin>

Other things that break

  • The security manager is deprecated for removal. It still works in 17 but warns. Almost nothing uses it; if you do, start planning.
  • Runtime.exec(String) parsing changed subtly around quoting. Use the String[] or ProcessBuilder form, which you should have been using anyway.
  • Nashorn is gone — removed in 15. See the Java 11 guide; the symptom is a null engine rather than an error.
  • Floating-point is always strict. strictfp became the default and the keyword is now redundant. This can change results in the last bit on some platforms — relevant only if you have tests asserting exact double values, which is a bad idea regardless.

Should you go straight to 21 or 25?

A fair question, since 17 is no longer the newest LTS. The honest answer is that 11 → 17 → 21 in two steps is usually easier than 11 → 21 in one, and not because the second hop is hard — it is because a failed upgrade needs to be bisected.

Strong encapsulation is the one genuinely disruptive change in this whole range, and it lands in 17. If you jump straight to 21 and something breaks, you are debugging that change alongside every other difference at once. Landing on 17 first isolates it.

The exception is a small application with few dependencies and good test coverage, where going directly to 21 or 25 is fine — the later hops are almost entirely additive. Check the Java 21 and Java 25 guides for what each adds, then decide based on how many dependencies you have rather than how many versions you are skipping.

The upgrade order

  1. On Java 11: upgrade build plugins, then dependencies. Fix every illegal-access warning. Do this in separate commits so a failure has one cause.
  2. Switch the JDK and set the release level. Use release, not source/target — it checks that you only call APIs that exist in the version you are targeting.
<properties>
    <maven.compiler.release>17</maven.compiler.release>
</properties>
  1. Run the tests, then run the application. Reflective-access failures often appear only at runtime, on the code path that does the reflecting — a passing build proves less here than usual.
  2. Only then start using the new language features. Upgrading the JDK and rewriting code in the same change makes a bisect useless.

Why it is worth it

Java 11 to 17 is the release range where the language changed most. Records, sealed classes, switch expressions, text blocks, pattern matching and helpful NullPointerExceptions all landed in it.

There is also a straightforward performance argument. Compact strings, a faster concatenation strategy and six years of G1 tuning mean most applications get faster on the same hardware with no code changes at all — which is a rare thing to be able to say about an upgrade.

Next

Virtual Threads is next, opening the Java 21 section — and it is the largest change to how Java handles concurrency in twenty years.