Ask a team which Java they run and the answer is almost always a long-term support release: 8, 11, 17, 21 or, increasingly, 25. Ask what LTS actually guarantees and the answers get vague. Some people think OpenJDK itself maintains old versions for eight years. Some think a non-LTS release is a beta. Some think staying on an LTS means they never have to think about updates. None of that is right, and the misunderstandings cost real money when a support window closes on a fleet nobody planned to move.
This article explains the model from first principles: how the release train works, who provides long-term support and what they promise, the support dates you need for planning, what changed in each LTS, and which changes break code when you hop from one to the next. It ends with a worked migration from 17 to 25 and a checklist. The runtime itself is covered in the Java overview; this page is about versions and time.
The release train and what LTS is
Since JDK 10 in March 2018, OpenJDK ships a feature release every six months, in March and September, whether or not any particular feature is ready. Features that are not finished simply wait for the next train, or ship as previews and incubators that must be switched on explicitly. That is why version numbers climbed from 9 to 27 in about nine years.
Each feature release then gets update releases on a fixed quarterly schedule, in January, April, July and October, carrying security and bug fixes. In the OpenJDK project itself, a feature release receives only two of these updates before the next feature release supersedes it. OpenJDK has no concept of long-term support. LTS is a decision made by vendors, who commit to keep backporting fixes into a chosen release line for years. Oracle designates the LTS releases and other vendors, including Red Hat, the Eclipse Adoptium project, Amazon, Microsoft, Azul and others, have followed the same choices. Community update projects for releases like 17 and 21 are led by vendor engineers, and that is where most of the free backports land.
The cadence of LTS designations changed once. JDK 11 and 17 came three years apart. From 17 onward the gap became two years, giving 21 in September 2023 and 25 in September 2025. On that cadence the next LTS is expected to be JDK 29 in September 2027. JDK 26, released in March 2026, and JDK 27, which reached general availability on 15 September 2026, are non-LTS releases: fully production quality, but with a six-month update window.
The support dates you plan against
Every vendor publishes its own end dates, so the first job is to know whose builds you run. Oracle's published roadmap is the usual reference point because other vendors tend to align with it. Oracle splits paid support into Premier Support, then Extended Support, then indefinite Sustaining Support, which provides no new fixes.
| Release | GA | Oracle Premier until | Oracle Extended until |
|---|---|---|---|
| 8 | March 2014 | March 2022 | December 2030 |
| 11 | September 2018 | September 2023 | January 2032 |
| 17 | September 2021 | September 2026 | September 2029 |
| 21 | September 2023 | September 2028 | September 2031 |
| 25 | September 2025 | September 2030 | September 2033 |
Two caveats matter more than the table. First, those dates are for paying Oracle customers. Oracle JDK builds from 17 onward have been offered under the No-Fee Terms and Conditions licence, under which free updates for an LTS continue until roughly a year after the next LTS ships and later updates move to a licence that requires a subscription for production use. Read the current terms before you rely on that, because they have changed before. Second, free OpenJDK builds from other vendors have their own end dates, which are usually generous for LTS lines but are published separately on each vendor's support page. Write down the vendor, the release and the end date for every runtime you deploy; that line in a spreadsheet is the whole of your Java support plan.
Note what the table says about this month: Premier Support for 17 ends in September 2026. Anyone still on 17 is now planning a move rather than idly considering one.
What each LTS brought
Each LTS bundles everything from the non-LTS releases since the previous one. The headline features are worth knowing because they decide what your code can use after an upgrade.
| LTS | Language and API | Runtime |
|---|---|---|
| 8 | Lambdas, streams, java.time, default methods | Metaspace replaced PermGen |
| 11 | var in lambdas, standard HTTP client, single-file source launch | G1 default since 9, TLS 1.3, module system since 9 |
| 17 | Records, sealed classes, pattern matching for instanceof, text blocks, switch expressions | Strong encapsulation of JDK internals |
| 21 | Virtual threads, record patterns, pattern matching for switch, sequenced collections | Generational ZGC |
| 25 | Scoped values, module import declarations, compact source files, flexible constructor bodies | Virtual threads no longer pin on synchronized (since 24), compact object headers as a product option, ahead-of-time cache improvements |
The 21 and 25 rows matter most for server code. Virtual threads change how you write I/O-bound services, and the 24 change that removed pinning inside synchronized blocks is the main reason many teams treat 25 as the release where virtual threads became safe to adopt broadly. Pattern matching over records and sealed hierarchies changes how domain code is modelled. The startup work from Project Leyden is arriving in pieces as ahead-of-time cache features.
What breaks on each hop
Upgrades fail for a small number of repeating reasons: removed modules and APIs, closed-off internals, changed defaults, and removed JVM flags. The useful habit is to know the list for each hop before starting.
- 8 to 11. The Java EE and CORBA modules were removed in 11 (JAXB, JAX-WS, javax.activation and friends), so code that relied on them must add them as ordinary dependencies. The version string changed from
1.8.0_xto11.0.x, breaking hand-written version parsers. The application class loader is no longer aURLClassLoader, breaking code that cast it to add jars at runtime. G1 replaced Parallel as the default collector in 9, changing pause and throughput behaviour. - 11 to 17. JDK 17 strongly encapsulates JDK internals and removed the
--illegal-accessescape hatch, so libraries that reflect intosun.*or privatejava.*fields fail unless you pass explicit--add-opens. Old versions of bytecode tools, mocking libraries and serialisers are the usual victims. The Nashorn JavaScript engine was removed in 15 and the CMS collector in 14. - 17 to 21. The default charset became UTF-8 in 18, which silently changes how files are read on systems whose platform encoding was something else. Finalization was deprecated for removal in 18. JDK 21 prints a warning when an agent is loaded dynamically into a running JVM, a step towards disallowing it by default.
- 21 to 25. Since 23, javac no longer runs annotation processors found on the class path unless you ask for it, which breaks builds that relied on processors such as Lombok being discovered implicitly. The Security Manager was permanently disabled in 24. Memory-access methods in
sun.misc.Unsafeemit warnings from 24, ahead of removal. The 32-bit x86 port was removed in 25. Non-generational ZGC was removed in 24, so the old mode flag no longer selects anything.
Removed JVM flags deserve their own warning. A flag that is merely obsolete prints a warning and is ignored. A flag that has been removed entirely makes the JVM refuse to start. Start scripts and container images carry flags for years, and the classic failure is a production pod in a crash loop because someone's 2019 options file still says -XX:+UseConcMarkSweepGC.
A worked migration: a service on 17 moving to 25
Take a typical Spring Boot service on 17, built with Maven, deployed as a container, with about 150 dependencies. The plan below works for most services and takes days of engineering, not months, provided the dependencies are kept reasonably current.
Step one is inventory. Run jdeps against the built application to find uses of JDK internals, and compile with all warnings on so deprecation-for-removal shows up.
# find code that reaches into JDK internals (run with the NEW JDK)
jdeps --jdk-internals --multi-release 25 --class-path 'target/lib/*' target/app.jar
# list removed or obsolete flags hiding in launch scripts and images
grep -rnE 'XX:[+-]?(UseConcMarkSweepGC|UseBiasedLocking|ZGenerational)|illegal-access' deploy/ docker/Step two is the build. Compile against the target release with the release option rather than source and target, because release also checks that you only call APIs present in that version. Make annotation processing explicit, since 25 will not discover processors for you.
<!-- pom.xml -->
<properties>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId>
<version>${lombok.version}</version></path>
</annotationProcessorPaths>
</configuration>
</plugin>Step three is dependencies. Upgrade the framework to a version that declares support for 25, then the libraries that touch bytecode or internals: mocking, serialisation, agents for APM and profiling, and anything that generates proxies. Most migration failures are an old Mockito, Byte Buddy, Jackson or APM agent rather than your own code. Step four is a CI matrix that builds and tests on both 17 and 25 until the switch, so regressions are caught on the day they are introduced. Step five is runtime: change the base image, delete removed flags, and compare GC logs, startup time, heap after GC and p99 latency on a canary against the old version for at least one full traffic peak. Only then switch the default and remove 17 from the matrix.
A cheap guard catches the most embarrassing failure, a service accidentally starting on the old runtime because an image tag was not updated:
public final class RuntimeGuard {
public static void check(int minimumFeature) {
int feature = Runtime.version().feature();
if (feature < minimumFeature) {
throw new IllegalStateException(
"Running on Java " + feature + ", need " + minimumFeature + " or later");
}
}
}
// in main(): RuntimeGuard.check(25);
Should you ever run a non-LTS release?
Non-LTS releases are not previews; they are full releases with the same testing. The cost is cadence: their free updates stop when the next release ships, so running 27 in production commits you to moving to 28 within about six months, and so on forever. Teams with strong automation, short-lived services and good test coverage do this successfully and get features and performance work a year or two earlier. Teams that upgrade once every few years should not, because a non-LTS runtime left in place becomes an unpatched runtime.
A middle path works well for most organisations: run production on the current LTS, but build and run the test suite against the latest feature release, and against early-access builds of the next, in a non-blocking CI job. Breakage then arrives as a slow trickle of small fixes rather than a wall of failures at the next LTS. This also tells you early when a library you depend on has stalled.
Operational traps and trade-offs
- Applying the quarterly updates. Staying on an LTS buys nothing if you do not take the update releases. Security fixes arrive in the January, April, July and October updates; base images and fat runtime bundles must be rebuilt each quarter, not pinned to the build you first tested.
- Many runtimes, one name. A fleet that says it runs 21 often runs a dozen different update levels from three vendors. Report the full
java.runtime.versionand vendor from every service as a metric, then you can answer a security advisory in minutes. - Collector and ergonomics drift. Defaults move between releases; container memory detection, default collectors and heap sizing have all changed over the years. Set heap and collector explicitly for anything latency sensitive, and re-baseline after each hop. See ZGC if you are reconsidering the collector at the same time.
- Skipping LTS releases. Jumping from 11 straight to 25 is fine and often cheaper than two hops, because you pay for the dependency upgrades once. The risk is a larger batch of behaviour changes landing together, so the canary and the comparison metrics matter more.
- Library support floors. Frameworks are raising their minimum Java versions. Staying on an old LTS eventually means staying on an old framework, which is the real lock-in, and usually arrives before the vendor support date does.
What to do next
- List every deployed runtime with vendor, full version and the vendor's published end date for that release line.
- If anything runs 17 or older, schedule its move now; aim directly for 25 unless a dependency blocks it.
- Run jdeps with the target JDK and grep launch scripts and images for removed flags before touching code.
- Switch builds to the release compiler option and make annotation processor paths explicit.
- Add a CI job on the latest feature release and on early-access builds, non-blocking, to catch breakage early.
- Rebuild images every quarter for the January, April, July and October updates, and export the runtime version as a metric.