The 30 second answer
I'd upgrade build tools and libraries first, because old bytecode tools and frameworks are the usual blockers. Then I scan for use of internal JDK APIs, add back Java EE pieces like JAXB that were removed in Java 11, and fix reflection that strong encapsulation now blocks. I check removed JVM flags, run the full test suite, roll out gradually, and only then adopt new features.
- Tooling and libraries first. Maven or Gradle plugins, Lombok, Mockito, ByteBuddy, ASM and your framework versions must support Java 21.
- Find internal API use:
jdeps --jdk-internals app.jarlists dependencies on internal JDK classes and suggests replacements. - Removed modules. Java 11 removed the Java EE and CORBA modules (JEP 320), so JAXB, JAX-WS and the common annotations must come in as normal dependencies.
- Strong encapsulation. Since Java 17 (JEP 403) deep reflection into JDK internals fails by default. Upgrade the library; use
--add-opensonly as a temporary workaround. - JVM flags. Old GC logging flags like
-XX:+PrintGCDetailsare replaced by-Xlog; the CMS collector was removed in Java 14 (JEP 363); some removed flags stop the JVM from starting. - Test and roll out. Full test suite and performance tests, then a canary deployment.
- Adopt new features only after it runs: records, pattern matching, virtual threads.
Spring Boot 3 requires Java 17 or later and moves from javax.* to jakarta.* packages. [Opinion] So a Java 8 Spring Boot 2 app is usually a Java and framework migration together; doing it in steps (for example Java 17 on Spring Boot 2.7 first, then Spring Boot 3 and Java 21) reduces risk.
Sources: Oracle JDK 21 Migration Guide, JEP 320: Remove the Java EE and CORBA Modules, JEP 403: Strongly Encapsulate JDK Internals, JEP 363: Remove the CMS Garbage Collector, jdeps, Spring Boot system requirements