Java: evolving without losing its soul

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 (publicprivate).

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.

Java

Stream API, lambda expressions & method references

Streams enable declarative and concise collection manipulation while leveraging the new functional interface.

Java
  • Map / Reduce:
Java
  • And parallel support!
Java

Asynchronous programming

CompletableFuture enables smooth non-blocking programming by providing asynchronous task execution (supplyAsync()), chaining (thenApply()thenAccept()), combination (thenCombine()allOf()), and error handling (exceptionally()).

Java

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

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! 🧐

Java

Records

records 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.

Java

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.

Java

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:

Java

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:

Java

Deconstruction and conditions

Java also supports deconstructing record classes during pattern matching and allows conditions using the when keyword, while still ensuring exhaustiveness validation:

Java

Pattern matching also works with if, but requires using instanceof:

Java

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 CompletableFutures), and calls are therefore blocking. However, we still want to process a stream of requests concurrently.

Java
  • Java 8 – CompletableFutures

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.

HTML
  • 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.

Java

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
  1. Java 11 Language Changes – https://docs.oracle.com/en/java/javase/11/language/java-language-changes.html
  2. Java 17 Language Changes – https://docs.oracle.com/en/java/javase/17/language/java-language-changes.html
  3. Java 21 Language Changes – https://docs.oracle.com/en/java/javase/21/language/java-language-changes.html
  4. JEP Records – https://openjdk.org/jeps/395
  5. JEP Record Patterns – https://openjdk.org/jeps/440
  6. JEP Sealed Classes – https://openjdk.org/jeps/409
  7. JEP Switch Expressions – https://openjdk.org/jeps/361
  8. JEP Pattern Matching for instanceof – https://openjdk.org/jeps/394
  9. JEP Pattern Matching for switch – https://openjdk.org/jeps/441
  10. JEP Virtual Threads – https://openjdk.org/jeps/444
  11. The Long Road to Java Virtual Threads – https://dzone.com/articles/the-long-road-to-java-virtual-threads
  12. Are Virtual Threads Going to Make Reactive Programming Irrelevant? – https://youtu.be/zPhkg8dYysY
  13. Handling Virtual Threads – https://dzone.com/articles/handling-virtual-threads
  14. JEP Virtual Threads | Pinning https://openjdk.org/jeps/444#Pinning
  15. JEP Synchronize Virtual Threads without Pinning – https://openjdk.org/jeps/491