Java 17 to 21 is the easiest LTS upgrade in years. Nothing significant was removed, the language changes are additive, and most applications move with a version bump and a test run. This post covers the few things that do bite.
If you are coming from Java 8 or 11 rather than 17, read the Java 17 guide first — strong encapsulation lands there and it is the one genuinely disruptive change in the whole range.
Start here
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
Use release rather than source and target. It additionally
verifies you are only calling APIs that exist in the version you target, which
source/target does not — that difference lets code compile and then fail at
runtime on an older JVM.
Then upgrade build plugins and dependencies before switching the JDK, for the same reason as every other upgrade: a failure with one variable changed has one possible cause.
The things that actually break
1. Bytecode manipulation libraries. Anything that reads or generates class files
must understand the class-file version, and 21 bumps it to 65. Older versions of ASM, ByteBuddy,
CGLib and everything built on them — Mockito, Hibernate, older Spring — fail with errors like
Unsupported class file major version 65.
The fix is always a dependency upgrade, never a workaround. This is the single most common Java 21 upgrade failure.
2. Dynamic agent loading warnings. Tools that attach to a running JVM now warn, and a future release will require a flag:
WARNING: A Java agent has been loaded dynamically
WARNING: Dynamic loading of agents will be disallowed by default in a future release
Mockito's inline mock maker is the usual source. It is a warning today; adding
-XX:+EnableDynamicAgentLoading silences it, and upgrading Mockito is better.
3. Finalization. Deprecated for removal, and now disableable. If any class
overrides finalize(), replace it — AutoCloseable plus try-with-resources
does the job properly, and deterministically.
4. The security manager. Disabled by default since Java 18. If you were relying on it, it needs re-enabling explicitly and a plan, because it is going away.
Virtual threads: adopt them deliberately
The headline feature is opt-in, which is worth stressing. Upgrading to 21 does not put anything on virtual threads; you enable them.
Before you do, audit two things:
class Demo {
private final Object lock = new Object();
// Pins the carrier thread — blocking inside synchronized defeats the point
void risky() throws Exception {
synchronized (lock) {
Thread.sleep(100); // or a database call, or an HTTP request
}
}
private final ReentrantLock better = new ReentrantLock();
void fine() throws Exception {
better.lock();
try {
Thread.sleep(100);
} finally {
better.unlock();
}
}
}
And check for thread pools sized on the assumption that threads are expensive. A fixed pool of 200 in front of virtual threads reintroduces exactly the queueing you switched to remove.
ThreadLocal is the other thing to review: harmless with 200 threads, and a million
objects with a million virtual threads.
Framework support
Most of the effort in an LTS upgrade goes into dependencies rather than your own code, so it is worth knowing roughly where the ecosystem sits before starting.
Spring Boot 3.x targets Java 17 as a minimum and supports 21 fully, including a property that puts
request handling on virtual threads. Anything still on Spring Boot 2.x is on a line that stopped at
Java 17 and predates Jakarta EE 9's javax to jakarta rename — that migration
is a much bigger job than the JDK upgrade, and it should be done first and separately.
Hibernate, Jackson, Mockito, JUnit 5 and the major build tools all support 21. The libraries most likely to hold you up are small, unmaintained ones doing something clever with bytecode or reflection, which is the same set that caused trouble at 17.
A quick way to find them before you start:
# Anything compiled for an old target, or reaching into JDK internals
jdeps --jdk-internals --multi-release 21 target/myapp.jar
# What is out of date
mvn versions:display-dependency-updates
A useful flag while testing
# Report every time a virtual thread pins its carrier, with a stack trace
java -Djdk.tracePinnedThreads=full -jar myapp.jar
Run your load tests with that on. It names the exact synchronized blocks that need
attention, which is far better than guessing from a throughput graph.
What you get
Beyond virtual threads: pattern matching for switch, record patterns, sequenced collections, generational ZGC, and another round of general performance work.
There is also a support argument. Java 17's free public updates are winding down and 21 is the current baseline for most frameworks — Spring Boot 3.2 onwards, in particular, treats 21 as a first-class target and needs it for its virtual thread support.
The order that works
- On Java 17, upgrade build plugins. Then dependencies, especially anything touching bytecode.
- Switch the JDK and set
releaseto 21. Run the tests, then run the application. - Only then start using new language features, so a bisect stays meaningful.
- Adopt virtual threads last, as a separate change, with the pinning flag on and a load test.
Step four is the one people merge into step two and regret. Turning on virtual threads changes the performance characteristics of the whole application; doing it in the same commit as a JDK upgrade means any regression has two candidate causes.
Next
Module imports and simple source files is next, opening the Java 25 section — starting with the change that finally makes a first Java program one line long.