Java is about to celebrate its 30th anniversary. Thirty years, that’s roughly my experience in development: 10 years of C++, 10 years of C#, and now 10 years of Java. Over this period, evolution has not only been software-related but also hardware and infrastructure-based. My very first program ran on a Z80 at 2.5MHz, and today our applications run 24/7 on dual-CPU AMD EPYC servers with 64 cores at 3.5GHz connected via 10Gbit networks.
For younger developers, Java probably has an outdated image, reminiscent of an era they didn’t experience and associated with enterprise practices that smell like mothballs. With new languages emerging and some gaining popularity, it’s worth questioning Java’s relevance in modern backend development.
In this article, I will briefly recall Java’s historical strengths and its ecosystem, and then we’ll explore the evolution the language has undergone over the past decade.
Java: a solid foundation
A language with simple syntax, statically typed and compiled
Java offers a readable syntax, inspired by a simplified version of C++. Its static typing system supports generics and enables clear interface declarations while providing strong guarantees for future code evolution. The compiler helps avoid runtime surprises (AttributeError
, anyone? 🤡) and prevents encapsulation breaches (public
, private
).
The JDK: a comprehensive standard library
The Java Development Kit (JDK) provides a rich set of standard APIs covering various development aspects, including data structures, networking, and advanced thread and concurrency management (via java.util.concurrent
).
The JVM: a versatile engine
The Java Virtual Machine (JVM) allows multiple languages (Java, Scala, Kotlin) to run on a single platform. It provides automatic memory management through the garbage collector (GC) and optimizes performance with the Just-In-Time (JIT) compiler. When needed, it also allows interoperability with C/C++ via the Java Native Interface (JNI).
Choose your garbage collector
Java offers several garbage collection algorithms (G1, Parallel, etc.), allowing performance adjustments based on specific needs: minimizing latency for request-server applications or maximizing throughput for intensive processing. Fine-grained control options help optimize memory management based on object lifetimes.
A mature open-source ecosystem
Java’s ecosystem is rich and diverse. At Babbar, for instance, we use:
- Apache Spark (in Scala) and Hadoop for big data
- Apache Pulsar and Zookeeper for messaging and clustering
- RocksDB (in C++ via JNI) for key-value storage
- gRPC and Protobuf for inter-service communication
- Prometheus for monitoring
A reliable build system
Apache Maven is widely used for Java project management, offering dependency management that helps prevent or resolve inevitable version conflicts. Its plugin system automates a wide range of tasks (compilation, testing, packaging, etc.). Granted, builds are rarely the most exciting part of a project for developers 🙄.
Profiling in production
VisualVM allows remote monitoring of Java applications, including thread analysis, GC statistics, CPU profiling, and stack & heap dumps for troubleshooting worst-case scenarios. This is invaluable because, of course, weird things only happen in production 🤬.
The foundation of modern Java: Java 8 & 11
In 2014, Java 8 introduced major changes. With Java 11 and type inference, we get expressive syntax while significantly reducing boilerplate code.
Static & default methods in interfaces
A small but much-needed addition. However, it took until Java 17 to allow static methods in non-static classes.
Stream API, lambda expressions & method references
Streams enable declarative and concise collection manipulation while leveraging the new functional interface.
- Map / Reduce:
- And parallel support!
Asynchronous programming
CompletableFuture
enables smooth non-blocking programming by providing asynchronous task execution (supplyAsync()
), chaining (thenApply()
, thenAccept()
), combination (thenCombine()
, allOf()
), and error handling (exceptionally()
).
Towards a more expressive Java: Java 17 & 21
The following versions introduce a set of features that, when combined, open new possibilities for developers to model their data and implement their algorithms.
switch
expressions
Until now, switch
was a statement. In the following example, you need to declare the variable, assign it (=
) in each branch, remember to add break
(except when you shouldn’t), and include a default
clause because the compiler does not check for exhaustiveness 😬.
Java 17 introduces an additional syntax for switch
, making it an expression (something that evaluates to a value). It also brings exhaustiveness checking, which provides a strong guarantee. Now, you might say that we’re not going to add a new day to the week anytime soon, but this is just an example! 🧐
Records
record
s are a new type of class designed for objects that represent structured data. Their fields are private and immutable (private final
). Accessors are automatically generated to allow access to fields via method calls or references.
Sealed classes
Sealed classes and interfaces allow you to restrict subclass declarations to a predefined set using the permits
keyword. At first glance, this might not seem very useful, but it plays a crucial role later on.
Pattern matching
Now that we have sealed classes and switch
expressions, the next logical step is to introduce exhaustive pattern matching in Java 🤩.
exhaustiveness
In the following example, the compiler ensures that all allowed shapes are covered in the switch
:
If a new shape is later added to the list of allowed shapes, the above code will no longer compile. This guarantee is what makes this feature so powerful, so avoid adding a default
clause in the switch
, which would act as a catch-all:
Deconstruction and conditions
Java also supports deconstructing record classes during pattern matching and allows conditions using the when
keyword, while still ensuring exhaustiveness validation:
Pattern matching also works with if
, but requires using instanceof
:
With all these features, Java offers more expressiveness, better compile-time validation, and less boilerplate 🥳.
Rethinking concurrency – Project Loom
Java 21 introduces virtual threads through Project Loom, aiming to simplify concurrency management by eliminating the limitations of traditional threads—particularly in terms of resource consumption—and the complexities associated with asynchronous programming.
Carrier Threads vs Virtual Threads
Virtual threads are lightweight threads managed by the JVM, running on a limited number of native threads called carrier threads (or OS threads). Unlike traditional threads, a vast number of virtual threads can be multiplexed onto a limited set of carrier threads.
Virtual threads are significantly lighter than OS threads: their creation and management do not require system-level allocation or context switching, allowing for the creation of “millions” without significantly impacting memory and performance. They rely on a dedicated ForkJoinPool
, which intelligently distributes virtual threads among carrier threads using a work-stealing approach, optimizing CPU core utilization.
Impact on existing code
The deep integration of virtual threads within the JDK and JVM has led to an enticing promise for projects that heavily use native threads for concurrency management: you replace your threads with virtual threads, it compiles, it works, all while consuming fewer resources.
At Babbar, we experienced this firsthand with our crawler, and the promise holds true! The commit that switches from native threads to virtual threads only involves modifying a few lines where threads are declared and initialized. As a result, we reduced the number of system threads by a factor of 10. Hats off to Project Loom! 👏
The return of imperative code
In general, virtual threads offer what other languages have introduced through green threads and the async
/ await
model, but in a Java-friendly way and without additional keywords.
- Simplified writing of asynchronous (imperative) code
No more continuation chains and confusing terminology! With virtual threads, concurrent code is written in a readable, imperative manner. Unlike CompletableFuture
-based approaches, a single virtual thread can handle a sequence of steps without being fragmented into multiple tasks.
- Preservation of call stack and exceptions
Unlike traditional non-blocking approaches, virtual threads retain the full call stack, making traceability easier. Exceptions work as they do in normal synchronous code, avoiding the complications of error handling in reactive models.
Use cases
The multiplexing of virtual threads onto native threads has implications for the optimal task profile. As with implementations in other languages, virtual threads are best suited for handling a large number of tasks that spend most of their time waiting (for a response, a lock, etc.).
- Ideal Use Case: I/O-Bound Tasks
Virtual threads excel in high-latency I/O tasks (HTTP requests, database access), where the thread can be suspended without monopolizing an OS thread. Project Loom ensures that all blocking operations in the JDK support multiplexing.
- Anti-Pattern: Compute-Intensive Tasks
Virtual threads are not suitable for CPU-intensive tasks (heavy computations, machine learning). Such tasks should be delegated to dedicated ForkJoinPool
threads or an appropriate ExecutorService
. However, it is entirely possible to submit a task from a virtual thread and wait for its result—the carrier thread will not be blocked, as the virtual thread will simply “wait” for the computation result!
Example
In this example, unlike the previously shown asynchronous approach, our services are not asynchronous (they do not return CompletableFuture
s), and calls are therefore blocking. However, we still want to process a stream of requests concurrently.
- Java 8 –
CompletableFuture
s
With Java 8, chaining operations is preferable to increase concurrency, and if a call blocks while waiting for a response, the current thread is blocked as well.
- Java 21 – Virtual Threads
With Java 21, no additional work is needed. If the service methods block, the virtual thread will automatically be suspended, and the carrier thread will be free to handle another task.
Wish list
- Virtual Threads in VisualVM
With the integration of virtual threads in Java, observability via VisualVM has disappeared since only native threads are visible. Given that a task is expected to spend most of its time waiting, it is unfortunate that we can no longer measure this directly in production.
- Thread Factory for Carrier Threads
As it stands, it seems that the number of carrier threads can only be defined via options passed at JVM startup. We would like to have finer control over concurrency within the same application, depending on the use case.
- Project Valhalla
My past experience has kept me closely connected to low-level code and hardware. I have retained an intuition for the cost and memory layout of data structures, read/write access patterns in an algorithm, and their implications on caching, for example.
Java does not support value types or generics with primitive types (List<int>
is not a valid type in Java). Every time I have to manipulate Map.Entry<Integer, Float>
, it pains me a little; this is why we use fastutil
whenever possible, but it is not enough.
The Valhalla project aims to solve this issue, but the road is still long, even though the project was initiated over a decade ago.
Conclusion
After nearly 30 years of existence, Java continues to demonstrate an impressive ability to evolve while staying true to its core principles. Despite the introduction of modern concepts like pattern matching and virtual threads, the language has maintained a simple and consistent syntax, avoiding the accumulation of complexity that could have made it less accessible. On the contrary, Java is now more expressive, offers stronger guarantees, and requires less boilerplate.
Its backward compatibility allows teams to gradually adopt new features without disruption, ensuring a smooth transition to recent versions. At Babbar, we have experienced this firsthand with Java 21: the benefits brought by virtual threads integrated seamlessly into our existing code.
It is clear that Java is not merely surviving in the backend ecosystem – it is continuously reinventing itself, proving that maturity and innovation can go hand in hand. And there are still new features in the pipeline!
References
- Java 11 Language Changes – https://docs.oracle.com/en/java/javase/11/language/java-language-changes.html
- Java 17 Language Changes – https://docs.oracle.com/en/java/javase/17/language/java-language-changes.html
- Java 21 Language Changes – https://docs.oracle.com/en/java/javase/21/language/java-language-changes.html
- JEP Records – https://openjdk.org/jeps/395
- JEP Record Patterns – https://openjdk.org/jeps/440
- JEP Sealed Classes – https://openjdk.org/jeps/409
- JEP Switch Expressions – https://openjdk.org/jeps/361
- JEP Pattern Matching for
instanceof
– https://openjdk.org/jeps/394 - JEP Pattern Matching for
switch
– https://openjdk.org/jeps/441 - JEP Virtual Threads – https://openjdk.org/jeps/444
- The Long Road to Java Virtual Threads – https://dzone.com/articles/the-long-road-to-java-virtual-threads
- Are Virtual Threads Going to Make Reactive Programming Irrelevant? – https://youtu.be/zPhkg8dYysY
- Handling Virtual Threads – https://dzone.com/articles/handling-virtual-threads
- JEP Virtual Threads | Pinning https://openjdk.org/jeps/444#Pinning
- JEP Synchronize Virtual Threads without Pinning – https://openjdk.org/jeps/491