Java is 30 years old. It has survived the rise and fall of enterprise Java EE, the reactive programming wave, the microservices era, and the competition from Kotlin, Scala, and Go. It has done this not by staying still but by adapting — sometimes slowly, sometimes with mistakes, always with backward compatibility as a hard constraint. The evolution from JDK 8 to JDK 25 is the story of Java learning from its competition and its own failures, and systematically filling the gaps that had driven developers toward other languages or toward workarounds like Lombok and reactive frameworks.
This post traces that evolution in terms of the problems that existed, the solutions that were introduced, and what changed for production code at each stage. It is not a comprehensive JEP catalogue — it is a map of how Java thinking changed over eleven major releases.
JDK 8 (2014): The Functional Foundation
JDK 8 was Java’s largest single-release feature addition since generics in Java 5. Lambdas and the Stream API replaced the anonymous class pattern for functional-style operations. Optional introduced a type-safe way to represent nullable results. The Date/Time API (java.time) replaced the broken java.util.Date and Calendar. Default methods on interfaces allowed collections to gain new operations without breaking existing implementations.
These additions addressed real friction, but they also left gaps that became clear over time. Lambdas reduced boilerplate but the compiler error messages for type inference failures were often cryptic. The Stream API covered most collection operations but could not express stateful intermediate operations cleanly. Concurrency remained based on the platform thread model, which was fundamentally constrained by OS thread limits. JDK 8 was a strong foundation — but it was a foundation, not a complete platform for modern backend development.
JDK 9 to 11 (2017-2018): Structure and Cleanup
Java 9 introduced the module system (Project Jigsaw), which was the most structurally significant change to the JVM since generics. Modules gave the JDK a formal component model and enforced encapsulation of internal APIs. This broke a large number of libraries and tools that had relied on internal JDK APIs, and it exposed how much of the Java ecosystem depended on implementation details rather than the public API. The short-term pain was significant; the long-term payoff was a platform where internal implementation details could change without breaking public contracts.
Java 10 added var for local variable type inference — a quality-of-life improvement that reduced verbosity without changing the type system. Java 11, the second LTS after JDK 8, added the standardized HTTP Client, completed the String API with strip(), isBlank(), lines(), and repeat(), and open-sourced JDK Flight Recorder — previously a commercial Oracle JDK feature. The removal of the Java EE modules (JAXB, JAX-WS) from the JDK in Java 11 forced these to be declared as explicit dependencies, improving clarity at the cost of migration work.
JDK 12 to 16 (2019-2021): Language Modernization
This period was Project Amber’s most productive phase. Switch expressions (JDK 14) made switch return a value with arrow syntax, removing the fall-through bug surface. Text blocks (JDK 15) eliminated the multi-line string escape problem. Pattern matching for instanceof (JDK 16) removed the redundant cast-after-check. Records (JDK 16) introduced immutable data carrier classes with auto-generated constructors, accessors, equals(), hashCode(), and toString(). Each of these addressed a specific verbosity or safety problem that had driven Java developers toward Kotlin or Lombok.
ZGC and Shenandoah became production-ready in JDK 15, giving Java two low-latency GC options with pause times under 10 milliseconds regardless of heap size. For services where GC pause spikes appeared in 99th-percentile latency measurements, this was operationally significant without any code change — just a GC flag switch.
JDK 17 (2021): The LTS That Solidified Modern Java
JDK 17 is the LTS that most teams are currently on, and it represents a coherent set of language features that together change how Java code looks and how it expresses domain logic. Sealed classes (finalized from JDK 17) bound type hierarchies to a declared set of permitted subtypes. Records (finalized in JDK 16) gave sealed hierarchies the value-carrying building blocks they needed. Pattern matching for instanceof (finalized in JDK 16) provided the first layer of type-based dispatch without redundant casts. Together with switch expressions from JDK 14, these features compose: sealed + records + switch with patterns = algebraic data types with exhaustiveness checking.
Strong encapsulation was made the default in JDK 17 — reflective access to JDK internal APIs no longer generates a warning; it throws. This forced the resolution of library dependencies that had been deferred since JDK 9. By JDK 17, most major frameworks (Spring, Hibernate, Netty) had updated to avoid internal APIs, making the JDK 17 migration substantially cleaner than JDK 9 or 11.
JDK 21 (2023): Virtual Threads and the Concurrency Reset
JDK 21 is the release where Project Loom’s core feature became production-ready. Virtual threads (JEP 444) fundamentally changed the ceiling on Java concurrency. Before JDK 21, each thread corresponded to an OS thread, limiting practical concurrency to hundreds of threads per JVM. Virtual threads are JVM-managed, lightweight threads multiplexed onto a small pool of carrier OS threads. When a virtual thread blocks on I/O — a JDBC query, an HTTP call, a queue receive — it unmounts from its carrier thread, which picks up another virtual thread. The memory cost per virtual thread is hundreds of bytes rather than hundreds of kilobytes.
The practical consequence: you can have millions of virtual threads, one per request, all writing blocking synchronous code, and the JVM handles the multiplexing transparently. This eliminates the need for reactive programming for I/O concurrency in most applications. Spring Boot 3.2 enables virtual threads with a single property. Existing JDBC, RestTemplate, and similar blocking code continues to work and automatically benefits from virtual thread multiplexing.
JDK 21 also finalized pattern matching for switch (JEP 441) and record patterns (JEP 440), completing the language features that sealed classes and records had been building toward. SequencedCollection gave first-element and last-element access to ordered collections without get(0) or get(size()-1) hacks.
JDK 25 (2025): Project Loom Complete
JDK 25 is the next LTS after JDK 21. It finalizes the two remaining Project Loom components: Structured Concurrency (JEP 505), which gives concurrent task groups lifecycle management and failure propagation guarantees, and Scoped Values (JEP 506), which replaces ThreadLocal for immutable context propagation that works correctly with virtual thread forks. Stream Gatherers (JEP 485) fill the gap in the Streams API for stateful intermediate operations. The LTS cadence for JDK 25 means it will be the migration target for teams on JDK 17 through 2030 or beyond.
The Pattern: What Changed, What Did Not
Across these eleven releases, two things remained constant: backward compatibility and the JVM as the runtime target. Code written in JDK 8 largely runs on JDK 25 without modification. The JVM’s performance has improved substantially through better GC, improved JIT optimization, and virtual thread scheduling, and that improvement applies to old code running without changes.
What changed is the language’s ability to express intent. JDK 8 to JDK 25 is the journey from a language where you expressed everything through classes and interfaces and anonymous inner classes, to a language where sealed types express bounded alternatives, records express immutable data, pattern matching expresses type-based dispatch, virtual threads express concurrency, and text blocks express multi-line strings — each of these without the boilerplate that previously obscured the intent.
The migration decisions along this path are simpler than they look. JDK 8 to JDK 11: mostly library updates and adding removed Java EE dependencies. JDK 11 to JDK 17: largely additive, with strong encapsulation enforcement as the main risk. JDK 17 to JDK 21: virtual threads require testing for synchronized-block pinning and ThreadLocal heavy libraries. JDK 21 to JDK 25: additive, with Structured Concurrency and Scoped Values as new capabilities rather than migration requirements.
Interview Questions
What happens to an application heavily using ThreadLocal for request context propagation when it adopts virtual threads?
With platform thread pools, ThreadLocal effectively acts as a per-request cache: the pool is small (50-200 threads), threads are reused, and clearing ThreadLocals between requests manages a bounded number of entries. With virtual threads, one virtual thread is created per task. If 10,000 concurrent requests arrive, up to 10,000 ThreadLocal entries are created — one per virtual thread. Libraries that store expensive objects (database connection metadata, parser instances, large buffers) in ThreadLocal under the assumption of a bounded pool will create many more instances than intended, increasing memory pressure. The designed replacement is ScopedValue (finalized JDK 25), which is immutable per scope, propagates into virtual thread forks, and is automatically cleaned up at scope exit.
How does the system behave when a codebase uses var extensively and a method’s return type changes?
If code assigns a method’s return value to a var and later calls methods on it, the compiler infers the type at the call site. If the return type changes to a supertype or a different type, the compiler re-infers the variable’s type at the next compilation. If the inferred type no longer has the methods called on the variable, those call sites fail to compile. This is identical to the failure mode of explicit type declarations — the only difference is that with var, the reader cannot see the type at the call site without hovering in an IDE. For method return type changes, both var and explicit type declarations produce compile errors at affected call sites. The practical risk of var is reduced readability in code reviews, not reduced compile-time safety.
What issues arise when sealed interfaces are used as API return types across a library boundary, and the library adds a new permitted type in a minor version?
Adding a new permitted type to a sealed interface is a binary-breaking change for callers who have exhaustive switch expressions over it without a default. When the library ships the new permitted type, client code that compiled against the old version will have switch expressions that are no longer exhaustive — they will fail to compile against the new version. This makes sealed interface evolution a semantic versioning concern: adding a new permitted type requires a major version increment, not a minor one, if the sealed interface is part of the public API. The mitigation is to document sealed interfaces as closed contracts, or to use a non-sealed escape hatch for the extension points that are expected to grow.
Summary
Java’s evolution from JDK 8 to JDK 25 is the story of a language closing the gaps that drove developers toward alternatives. Verbosity was addressed by records, text blocks, var, and switch expressions. Type safety for domain modeling was addressed by sealed classes and pattern matching. Concurrency ceiling was addressed by virtual threads. Context propagation correctness was addressed by Scoped Values. At each stage, the backward compatibility guarantee meant existing code continued to work, while new code could express intent more directly. The result is a platform where a senior engineer’s productivity is substantially higher than it was in JDK 8, and where the common Java antipatterns — mutable data carriers, ThreadLocal misuse, reactive programming for I/O concurrency — have designated, simpler replacements.




