Java’s Modern Evolution: From Java 9 to Today – A Developer’s Journey Through the New Era
Table of Contents
In my previous article, we explored Java’s foundational journey from version 1.0 through the transformative Java 8 release. That exploration ended with Java 8’s revolutionary introduction of lambda expressions and streams, features that fundamentally changed how we write Java code. Today, I want to continue that journey by diving into what I consider Java’s “modern era” – from Java 9 onwards.
Having worked with Java for over eleven years now, I can confidently say that the period from Java 9 to today represents the most dramatic transformation in Java’s history since its inception. We’re not just talking about new language features here; we’re looking at a complete reimagining of how Java evolves, how we structure applications, and how we think about performance and scalability.
The shift that began with Java 9 in 2017 introduced something unprecedented: a predictable six-month release cycle that has fundamentally changed how we adopt and integrate new Java features. Gone are the days of waiting years for major updates. Instead, we now live in an era of continuous evolution, where each release brings focused improvements that build upon previous innovations.
The Great Transformation: Java 9 and Project Jigsaw
Java 9, released in September 2017, marked the beginning of this new era with one of the most ambitious undertakings in Java’s history: Project Jigsaw and the Java Platform Module System (JPMS). After working with the traditional classpath for so many years, the introduction of modules felt like learning a new language within Java itself.
Understanding the Module System
The module system addressed what developers had been struggling with for decades: “JAR hell” and the monolithic nature of the JDK. Before modules, every application carried the entire JDK runtime, regardless of which parts it actually used. Dependencies were resolved at runtime, leading to those frustrating NoClassDefFoundError exceptions that could bring down production systems.
Here’s how the module system fundamentally changed our approach:
// module-info.java - The heart of the module system
module com.example.userservice {
requires java.base; // Implicit in all modules
requires java.sql; // For database operations
requires com.example.common; // Our shared utilities
exports com.example.userservice.api; // Public API
exports com.example.userservice.model; // Data models
// Internal packages are automatically encapsulated
// com.example.userservice.internal is not exported
}This declaration creates a contract that the compiler and runtime enforce. No more accidental dependencies on internal APIs, no more classpath surprises. The module system knows exactly what your application needs and ensures those dependencies are available at compile time.
What impressed me most about modules was how they enabled the JDK team to break apart the monolithic runtime. You can now create custom runtime images containing only the modules your application actually needs:
jlink --module-path $MODULE_PATH \
--add-modules com.example.userservice \
--output custom-runtimeThis capability has proven invaluable in the container era, where smaller images translate directly to faster deployments and reduced resource consumption.
Interactive Development with JShell
Java 9 also introduced JShell, Java’s long-awaited REPL (Read-Eval-Print Loop). For someone who had been switching between Java and languages like Python for quick experimentation, JShell felt like a revelation:
jshell> var numbers = List.of(1, 2, 3, 4, 5)
numbers ==> [1, 2, 3, 4, 5]
jshell> numbers.stream()
...> .filter(n -> n % 2 == 0)
...> .mapToInt(Integer::intValue)
...> .sum()
$2 ==> 6The ability to quickly test API behavior, explore new libraries, or prototype algorithms makes JShell valuable for rapid experimentation. It’s particularly useful when combined with the module system for testing module interactions without the overhead of setting up full project structures.
The New Release Cadence: Java 10 and Beyond
Java 10, arriving just six months after Java 9, demonstrated Oracle’s commitment to the new release schedule. While smaller in scope than Java 9, it introduced one of my favorite productivity features: local variable type inference with the var keyword.
Type Inference: Reducing Boilerplate Without Losing Safety
The var keyword represents a perfect balance between conciseness and type safety:
// Before Java 10 - verbose but explicit
Map<String, List<CustomerOrder>> ordersByCustomer = new HashMap<String, List<CustomerOrder>>();
FileInputStream inputStream = new FileInputStream("data.txt");
// Java 10 onwards - concise but still type-safe
var ordersByCustomer = new HashMap<String, List<CustomerOrder>>();
var inputStream = new FileInputStream("data.txt");
// Type inference works beautifully with complex types
var userProcessor = new UserDataProcessor<>(
new DatabaseUserRepository(),
new EmailNotificationService(),
new AuditLogger()
);What I appreciate about var is how it maintains Java’s strong typing while eliminating redundant type declarations. The compiler still enforces type safety; you’re just not forced to repeat information that’s already obvious from the initializer.
Long-Term Support and Java 11
Java 11, released in September 2018, marked another milestone as the first Long-Term Support (LTS) release under the new cadence. This version focused on stabilization and practical improvements, including the standardization of the HTTP Client API that had been incubating since Java 9.
Modern HTTP Client
The new HTTP Client API finally gave Java developers a modern, fluent interface for HTTP operations:
var client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.build();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.build();
// Synchronous request
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
// Asynchronous request
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(this::processUserData)
.join();This API’s support for HTTP/2, connection pooling, and reactive programming patterns made it feel modern and efficient compared to the legacy HttpURLConnection.
Platform Cleanup
Java 11 also began the process of removing outdated technologies. The removal of Java EE and CORBA modules forced applications to explicitly depend on these libraries, promoting a cleaner separation between the core platform and enterprise extensions.
The Pattern Matching Revolution: Java 12-17
The releases from Java 12 through Java 17 introduced a series of features that collectively transformed how we write data-oriented code. This period saw the evolution of switch expressions, the introduction of records, and the beginnings of pattern matching.
Switch Expressions: From Statements to Expressions
The enhanced switch, introduced as a preview in Java 12 and finalised in Java 14, eliminated many common sources of bugs:
// Traditional switch - verbose and error-prone
String dayType;
switch (dayOfWeek) {
case MONDAY:
case TUESDAY:
case WEDNESDAY:
case THURSDAY:
case FRIDAY:
dayType = "Weekday";
break;
case SATURDAY:
case SUNDAY:
dayType = "Weekend";
break;
default:
throw new IllegalArgumentException("Invalid day: " + dayOfWeek);
}
// Modern switch expression - concise and safe
var dayType = switch (dayOfWeek) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
case SATURDAY, SUNDAY -> "Weekend";
};The arrow syntax eliminates fall-through behavior by default, while the expression form ensures exhaustiveness checking and eliminates the need for temporary variables.
Records: Data Classes Done Right
Java 14’s introduction of records (finalized in Java 16) addressed a long-standing pain point: the boilerplate required for simple data carriers:
// Traditional approach - lots of boilerplate
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Point)) return false;
Point point = (Point) obj;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "Point{x=" + x + ", y=" + y + "}";
}
}
// Records - automatic generation of all the above
public record Point(int x, int y) {}Records automatically generate constructors, accessors, equals(), hashCode(), and toString() methods. They’re perfect for DTOs, value objects, and any scenario where you need immutable data containers.
Sealed Classes: Controlled Inheritance
Java 17’s sealed classes provide precise control over inheritance hierarchies:
public sealed interface Shape
permits Circle, Rectangle, Triangle {
double area();
}
public final class Circle implements Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
public final class Rectangle implements Shape {
private final double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
public non-sealed class Triangle implements Shape {
// Triangle can be extended by other classes
private final double base, height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double area() {
return 0.5 * base * height;
}
}Sealed classes enable the compiler to perform exhaustiveness checking in switch expressions, making pattern matching more powerful and safer. Here’s how this works in practice:
// Before sealed classes - compiler can't guarantee exhaustiveness
public String describeShape(Shape shape) {
return switch (shape) {
case Circle c -> "A circle with radius " + c.radius();
case Rectangle r -> "A rectangle " + r.width() + "x" + r.height();
case Triangle t -> "A triangle with base " + t.base();
// Default case required - compiler doesn't know all possible types
default -> throw new IllegalArgumentException("Unknown shape type");
};
}
// With sealed classes - exhaustiveness checking guarantees completeness
public String describeShape(Shape shape) {
return switch (shape) {
case Circle c -> "A circle with radius " + c.radius();
case Rectangle r -> "A rectangle " + r.width() + "x" + r.height();
case Triangle t -> "A triangle with base " + t.base();
// No default case needed! Compiler knows these are all possible types
// If we add a new permitted type, this code won't compile until updated
};
}The compiler knows exactly which types can implement Shape, so it can verify that all cases are handled. If you forget to handle a case or add a new permitted type later, you’ll get a compile-time error rather than a runtime surprise.
The Concurrency Revolution: Virtual Threads and Project Loom
Perhaps the most significant advancement in modern Java is Project Loom’s virtual threads, which became a preview feature in Java 19 and were finalized in Java 21. This feature fundamentally changes how we approach concurrent programming.
Understanding Virtual Threads
Traditional Java threads are expensive resources. Each platform thread consumes around 1MB of memory and requires significant OS kernel involvement for scheduling. This limitation has forced developers into complex asynchronous programming patterns or careful thread pool management.
Virtual threads solve this by implementing a many-to-few mapping between application threads and OS threads:
// Traditional approach - limited by OS thread count
try (var executor = Executors.newFixedThreadPool(100)) {
for (int i = 0; i < 10000; i++) {
final int taskId = i;
executor.submit(() -> {
// This would overwhelm the thread pool
performNetworkOperation(taskId);
});
}
}
// Virtual threads - can handle millions of concurrent tasks
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1000000; i++) {
final int taskId = i;
executor.submit(() -> {
// Each task gets its own virtual thread
performNetworkOperation(taskId);
});
}
}Virtual threads are so lightweight that you can create millions of them without concern. When a virtual thread blocks on I/O, the underlying platform thread can be reused for other virtual threads, maximizing CPU utilization.
Structured Concurrency
Alongside virtual threads, Java introduced structured concurrency to manage groups of related concurrent tasks:
public class UserService {
public UserProfile fetchUserProfile(String userId) throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Launch multiple concurrent operations
var userTask = scope.fork(() -> fetchUser(userId));
var preferencesTask = scope.fork(() -> fetchPreferences(userId));
var activityTask = scope.fork(() -> fetchRecentActivity(userId));
// Wait for all tasks to complete or any to fail
scope.join();
scope.throwIfFailed();
// Combine results
return new UserProfile(
userTask.resultNow(),
preferencesTask.resultNow(),
activityTask.resultNow()
);
}
}
}Structured concurrency ensures that all child tasks are properly cancelled if the parent scope is cancelled, preventing resource leaks and making concurrent code more predictable.
Text Processing and String Templates
Java has continuously improved its text processing capabilities. Text blocks, introduced in Java 13 and finalised in Java 15, revolutionised how we handle multiline strings:
// Traditional approach - ugly and error-prone
String json = "{\n" +
" \"name\": \"" + name + "\",\n" +
" \"age\": " + age + ",\n" +
" \"email\": \"" + email + "\"\n" +
"}";
// Text blocks - clean and maintainable
String json = """
{
"name": "%s",
"age": %d,
"email": "%s"
}
""".formatted(name, age, email);Java also experimented with string templates, which were introduced as a preview feature in Java 21 and continued through Java 22. However, after extensive community feedback, string templates were completely withdrawn from Java 23 due to design concerns around their processor-centric nature being confusing to users and lacking compositionality. As of Java 24 (released March 2025), there is no consensus on a better design, so this feature remains absent from the platform.
Pattern Matching Evolution
Pattern matching has evolved significantly, with instanceof pattern matching finalized in Java 16 and switch pattern matching continuing to develop:
// Pattern matching with instanceof
public static String formatValue(Object obj) {
return switch (obj) {
case Integer i -> "Integer: " + i;
case String s -> "String: " + s;
case List<?> list -> "List with " + list.size() + " elements";
case null -> "null value";
default -> "Unknown type: " + obj.getClass().getSimpleName();
};
}
// Record patterns for data extraction
public record Point(int x, int y) {}
public record Circle(Point center, int radius) {}
public static String describeShape(Object shape) {
return switch (shape) {
case Circle(Point(var x, var y), var radius) ->
"Circle at (" + x + "," + y + ") with radius " + radius;
case Point(var x, var y) ->
"Point at (" + x + "," + y + ")";
default -> "Unknown shape";
};
}This pattern matching capability, combined with records and sealed classes, enables a more functional programming style while maintaining Java’s type safety.
Performance and Modern JVM Features
The modern Java era has brought significant performance improvements. Java 15 marked a major milestone when both ZGC and Shenandoah garbage collectors graduated from experimental status to production-ready, offering pause times of single-digit milliseconds even for multi-gigabyte heaps. ZGC was first introduced as experimental in Java 11, while Shenandoah debuted in Java 12, with both collectors being refined over multiple releases before reaching production status.
Native Interoperability and Performance
Project Panama has also introduced the Foreign Function & Memory API (finalized in Java 22), which provides a safer and more efficient alternative to JNI for calling native code and managing off-heap memory. This enables Java applications to integrate with native libraries without the complexity and safety concerns of traditional JNI.
The Modern Development Experience
The cumulative effect of these improvements has transformed the Java development experience. Features like:
- JShell for interactive development and learning
- Text blocks for readable multiline strings
- Records for concise data modeling
- Pattern matching for expressive conditional logic
- Virtual threads for scalable concurrent programming
- Sealed classes for precise API design
have made Java feel modern and expressive while retaining its core strengths of reliability, performance, and backward compatibility.
Looking Forward
With Java 24 released in March 2025, several trends continue to shape Java’s evolution:
Continued Language Evolution: Pattern matching will become more sophisticated, with features like pattern matching in switch expressions becoming more powerful and comprehensive.
Performance Focus: The JVM continues to optimize for modern hardware, with improvements in garbage collection, just-in-time compilation, and native interoperability.
Developer Experience: Each release brings refinements that make common development tasks more straightforward and less error-prone.
Cloud and Container Optimization: Java continues to adapt to cloud-native deployment patterns with faster startup times, smaller memory footprints, and better container awareness.
Conclusion
The journey from Java 8 to Java 24 represents a renaissance in Java development. The introduction of the six-month release cycle has enabled continuous innovation while maintaining stability through LTS releases. Features like virtual threads, records, pattern matching, and the module system have modernized the language without sacrificing its core principles.
For developers who remember the long waits between major Java releases, this new era feels almost revolutionary. We now have a Java that evolves continuously, incorporating modern programming paradigms while maintaining the reliability and performance that made it successful in enterprise environments.
The Java of 2025 is more expressive, more performant, and more enjoyable to work with than ever before. Whether you’re building microservices, processing large datasets, or creating modern web applications, the features delivered through Java 24 provide the tools you need to write cleaner, more efficient code.
In future articles, I may explore practical applications of these modern Java features, showing how to combine virtual threads, records, and pattern matching to build highly scalable, maintainable applications. Until then, I encourage you to experiment with these features in your own projects and experience firsthand how they can transform your Java development approach.
What’s your experience with modern Java features? Have you adopted virtual threads in production? I’d love to hear about your journey with Java’s evolution in the comments below.