Upgrading across an LTS
The best argument is not a feature: it is a NullPointerException that names which of four dereferences was null.
Java releases every six months and designates one every two years as long-term support. Teams do not upgrade every six months; they jump from one LTS to the next — 8 to 11, 11 to 17, 17 to 21 — and each jump is a project with a shape worth knowing before you start.
What you actually get, on one line of code
Here is the argument for upgrading, and it is not a feature list. The same program, the same line, with four dereferences on it:
orders.get("A-1").customer.address.city.length()On Java 8:
Exception in thread "main" java.lang.NullPointerException
at Npe.main(Npe.java:13)On a modern JDK:
Exception in thread "main" java.lang.NullPointerException: Cannot read field "city"
because "java.util.Map.get(Object).customer.address" is null
at Npe.main(Npe.java:13)The first tells you a line. The second tells you which dereference, by name, on a line with four candidates.
Multiply that by every production NPE your team will ever debug. That is a better argument than records and pattern matching, because it costs nothing to adopt — it is on by default and requires no code change at all.
The same is true of most of what an upgrade buys: a newer garbage collector, better JIT output, smaller container images, and startup improvements all arrive without a line being written. The language features are the part you choose; the runtime improvements are the part you get.
The two upgrades that are actually different
8 → 11 is the hard one, and it is hard for one reason: Java 9 modularised the JDK and removed things that used to be there.
- The Java EE modules are gone. JAXB, JAX-WS,
javax.annotation, CORBA. This is the most common break, and the fix is adding them as ordinary dependencies — they live on Maven Central now. - Internal APIs are enforced.
sun.misc.Unsafeand friends were always unsupported; from 9 they are genuinely inaccessible, and the illegal-access warnings became errors in 16. - The version string changed.
1.8.0_292became11.0.19. Anything parsing it breaks, and something always is. - The class-file version changes, which is the
UnsupportedClassVersionErrorthe class-file lesson is about.
11 → 17 and 17 → 21 are ordinary. Mostly they are dependency upgrades, a build plugin or two, and a check for removed methods. Teams that have done 8 → 11 are usually surprised by how small the next ones are.
The order that works
- Upgrade dependencies first, on the old JDK. Most upgrade failures are a library that does not understand the new class-file version, and fixing that while everything else is stable is far easier than debugging it alongside a JDK change. Spring Boot, the build plugins, anything doing bytecode manipulation — Lombok, Mockito, ByteBuddy, ASM — are the usual suspects.
- Compile with the new JDK, target the old release.
--release 11on a Java 17 compiler proves your code compiles without committing to the new bytecode. Note--releaserather than-source/-target: it also checks you are not using APIs that did not exist in the target. - Run the tests on the new JDK, still targeting the old. This is where bytecode-manipulating libraries fail, and it is the step that finds most problems.
- Deploy with the new runtime, old target. Now you have the GC and JIT improvements with no code change and a rollback that is one config value.
- Only then raise the target and start using new language features.
Each step is separately revertible, which is the whole point. A single change that moves compiler, runtime and language level at once has one outcome to debug and one thing to roll back: everything.
What tends to break
- Bytecode manipulation. Anything reading or writing class files needs a version that knows the new format.
- Reflection into the JDK. Frameworks reaching into
java.baseneed--add-opens, and each one should be a ticket rather than a permanent flag. - The default garbage collector changed to G1 in Java 9. If you had tuned for Parallel or CMS, those flags are wrong or gone — CMS was removed in 14.
- Date formatting. The default locale data provider changed to CLDR in Java 9, so some formatted dates differ. It is small, it is real, and it surfaces in a test that compares strings.
Stringgot compact strings in Java 9, which halves the memory for Latin-1 text and changes nothing you can observe except the heap.
Which LTS to be on
Be on an LTS, and be no more than one behind. The reason is not features; it is that security patches stop, and an unpatched JDK is the vulnerable-dependency lesson's subject at the bottom of your stack.
The distribution matters too and is easy to get wrong: Oracle's JDK has licence terms that have changed more than once. Temurin, Corretto, Zulu, Liberica and Microsoft's build are all OpenJDK and free, and any of them is a fine answer.