Java programming: A deep dive into Java 21's key features

Java's ongoing relevance: A closer look at language evolution

I wasn’t always a fan of Java, but in recent years I started appreciating the language and its ecosystem more and especially after I decided to use Java 21 for a new personal project. When it comes to getting introduced to the world of JVM, I started my journey with Scala, a delightful language that blends Object Oriented and Functional Programming concepts into a concise language focusing on type safety. 

So then, why didn’t I use Scala? Some of the reasons why I decided on Java was because I’ve learned there were some new exciting improvements to the language, also I wanted to take the opportunity to explore a bit more Java’s current state of ecosystem, frameworks and libraries. There are plenty of other newer languages targeting the JVM platform but the ones that achieved the most traction are Scala and Kotlin, both strongly and statically typed programming languages just like Java. 

Comparing Java vs. Scala vs. Kotlin

Between Scala and Kotlin, Kotlin’s syntax seems more familiar to developers already experienced in Java and its main goal is to be a more modern and concise language that is fully interoperable with Java while fixing some of Java’s warts. Currently, Kotlin is more popular in the Android development space. 

Scala, on the other hand, enjoys its popularity mainly within data engineering (Apache Spark et’al) and backend application development. Scala has less verbose syntax, some may even compare it to Python on steroids (especially Scala3) and a more robust type system that helps eliminate more errors at compile time.

Developers rank Java higher

Let’s get some insights into the adoption and popularity of Scala, Kotlin and Java by looking at Stack Overflow surveys as well as the TIOBE and Redmonk language popularity indexes. 

The chart below is based on the Stack Overflow results for the most popular languages. Here I’m only cherry picking the three languages mentioned.

Stack Overflow chart results for the most popular languages

Redmonk language index

Here is the Redmonk index as of January 2023, which is based on Github and Stack Overflow data. We can see Java’s at the top with Scala and Kotlin also at relatively high ranks.

Redmonk Q121 programming language rankings graph

Source: https://redmonk.com/sogrady/2023/05/16/language-rankings-1-23/

TIOBE programming index

Now looking at the TIOBE index, which takes into consideration search terms across 25 different search engines. Java is up there in the top languages as well. Scala and Kotlin did not make the top 10 to be shown on the chart but for 2023 they rank 15 and 36 respectively.

TIOBE programming community language rankings graph

Source: https://www.tiobe.com/tiobe-index/

As you can see, Java is still very popular even with the strong competition coming from Kotlin and Scala. There is certainly some decline over the years where a piece of the pie was also taken by other programming languages, including other ecosystems (e.g., Python, Go, Rust).

Navigating Java: A commitment to backwards compatibility

Let’s take a look at what Java has been up to in the last several years as well as other factors that contribute to its strong popularity. 

Some of the popularity has to do with Java’s DNA, which is a commitment to strong backwards compatibility. Yes, there have been some intentional compatibility breakages over the years to support improvements to the language and tooling but there is usually a lot of consideration and strong justification for such changes. This is one of the big reasons why Java is so popular in enterprise settings. The stability of the JVM platform translates to more engineers being productive and focusing on solving business problems and shipping code rather than fighting the tools.

Java relevancy factors to consider

Starting with Java 9, OpenJDK changed their release cycle from “whenever it is ready it ships” to every-6-month intervals – March and September. The goal was to mitigate delays in delivering new versions caused by certain enhancements not being ready. In order to make this work, the concept of preview features was introduced. If a certain feature is fully functional but is still subject to breaking changes it will be introduced as a preview in the next version. 

This allows the developer community to provide feedback and help shape implementation to be finalized in the next regular releases. Every couple of years, one of the releases will be tagged by Oracle as an LTS (Long Term Release) release and other JDK vendors will follow suit. For example, Amazon Web Services maintains its own JDK build called Corretto JDK for which AWS will provide long-term support.

6 month release of features and relevance graph

Source: https://dev.java/evolution/

Another factor for Java relevancy is the last mover advantage. Other JVM languages like Scala evolve at a rather fast pace. I continue to appreciate and enjoy using Scala but its commitment to backwards compatibility and tooling story has a lot to be desired (but it’s getting better!). On the flip side, Scala is on the cutting edge pushing language and compiler design to the new levels. 

Java however plays the long game, taking the time, observing how the industry is evolving, evaluating what other languages are doing and cherry picking what works well and improving the language where it makes the most sense. Let’s take a quick look at some select improvements to the language that landed beyond Java 8.

The impact of Java 10's 'var' keyword on verbosity

When talking to developers who are already proficient in other modern languages and are just learning Java, when asked what they dislike the most, verbosity comes up close to the top. Before Java 10 was released, we had to explicitly declare the variable type on the left side of the assignment. In Java 10 a new special “var” keyword was introduced to be used in place of the actual type. During the compilation stage Java compiler will insert the actual type inferred from the expression on the right-hand side of the assignment.

    //Java 8
HashMap map = new HashMap();

DatabaseEngineColumnCacheImpl cache = new DatabaseEngineColumnCacheImpl();

Optional accessRole = user.getUserAccessRole();

//Java 10, no need to convince compiler anymore of what are the actual types
var map = new HashMap();

var cache = new DatabaseEngineColumnCacheImpl();

var accessRole = user.getUserAccessRole();
  

Above are some trivial examples, but this really cuts down on verbosity when reading the code. This however is not a new thing in the industry. Developers of other modern statically typed programming languages have enjoyed type inference for a long time. 

For example, Scala had even more advanced type inference since its inception in 2004. Nevertheless, it is a very welcome feature of Java. Type inference is limited to local variable declaration, e.g. in method bodies, and from a practical standpoint, that’s where it matters the most. 

Overcoming type checking challenges with instanceof and pattern matching

instanceof is a language keyword used to check if a given object is of some specific type. For example, given an object of type Object which could be just about anything, we may need to check what is the underlying type at runtime to execute an operation specific to that underlying object type.

Here’s an example, perhaps a bit contrived but should be sufficient to explain the issue. Let’s say we have interface Shape and a number of classes that represent specific shapes. Somewhere in our code we would like to get information about the shape's perimeter. Unfortunately the interface does not specify a method to calculate perimeter that each extending class should implement and for the sake of example we also have no access to refactor this code. 

This leaves us with only one option: Implementing perimeter calculation as some sort of utility method that checks for a specific type, either Rectangle or Circle and calculates perimeter accordingly. 

    interface Shape {}

public class Rectangle implements Shape {
    final double length;
    final double width;
    public Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }
}

public class Circle implements Shape {
    final double radius;
    public Circle(double radius) {
        this.radius = radius;
    }
}

public static double getPerimeter(Shape shape) {
    if (shape instanceof Rectangle) {
        Rectangle r = (Rectangle) shape;
        return 2 * r.length + 2 * r.width;
    } else if (shape instanceof Circle) {
        Circle c = (Circle) shape;
        return 2 * c.radius * Math.PI;
    } else {
        throw new RuntimeException("Unknown shape");
    }
}
  

Looking at the getPerimeter method implementation, even though we already checked the type, we still have to downcast and declare a new variable before we can perform the operation. This is simply because the compiler still sees shape as an instance of Shape.

Pattern matching for instanceof allows us to declare a variable of the type we checked to be available in the scope of if-else block. In Java 14, the same if-else block would look like this.

    public static double getPerimeter(Shape shape) {
    if (shape instanceof Rectangle r) {
        return 2 * r.length + 2 * r.width;
    } else if (shape instanceof Circle c) {
        return 2 * c.radius * Math.PI;
    } else {
        throw new RuntimeException("Unknown shape");
    }
}
  

This is a nice improvement to the compiler that incrementally becomes just a bit more smarter. Pattern matching for instanceof was a larger effort that was continuously expanded in later versions of java including pattern matching for record classes, which is the next big feature I would like to touch on.

From verbosity to simplicity, Java's records reshape data classes

Ok, this one is a big deal, at least for me. I really enjoy how effortlessly Scala allows us to model things using data classes. Java is known to be great at domain modeling but before records were introduced defining data containers, or so-called POJOs, was very verbose which brought various libraries to the ecosystem that perform code generation instead of writing repetitive code by hand (e.g. Lombok). 

Let’s look at the following example.

    public class Person {
    public final String id;
    public final String name;
    public final Integer age;

    public Person(String id, String name, Integer age) {
        this.id = id; 
        this.name = name;
        this.age = age;
    }
}

var p1 = new Person("a1b", "Frank", 30)
var p2 = new Person("a1b", "Frank", 30)

p1.equals(p2) //false, oh?
  

First of all, compared to other high level programming languages, this is already quite verbose syntax for defining a simple data class. Moreover, when we wish to model data in such a way we often would like to compare objects based on their contents and in this example one would assume (if they’re new to Java) p1 should be equal to p2, but that’s not the case. 

This is because in Java, objects are references to memory and without explicitly telling the compiler how objects can be equated, the default strategy of equals() is to compare memory addresses. This is exactly what == operator does. So then, what do we need to do to make our Person object comparable with another instance of the same type?

    public class Person {
    public final String id;
    public final String name;
    public final Integer age;

    public Person(String id, String name, Integer age) {
      this.id = id;
      this.name = name;
      this.age = age;
    }

    @Override
    public boolean equals(Object o) {
      if (o == this) return true;
      if (o == null || !(o instanceof Person)) return false;

      Person p = (Person) o;
      return Objects.equals(p.id, this.id) && Objects.equals(p.age, this.age) && Objects.equals(p.name, this.name);
    }

    @Override
    public int hashCode() {
      int hash = 7;
      hash = 31 * hash + Objects.hashCode(id);
      hash = 31 * hash + Objects.hashCode(name);
      hash = 31 * hash + age;
      return hash;
    }
}
  

Turns out there’s quite a bit to do. We need to override equals and hashCode to define rules for how objects can be compared. Libraries like Lombok take care of this for us by generating those methods so we don’t have to write them, but now Java has a way to address this natively.

Enter records. As of Java 17 (in preview since 14)  we have a new way to define classes using the record keyword. Building on our previous example let’s see how we can improve it with Records.

    record Person(String id, String name, Integer age) {};

var p1 = new Person("1ab", "Frank", 30);
var p2 = new Person("1ab", "Frank", 30);

p1.equals(p2); //true, yes sir!

p1 == p2; //false, which is expected as these are still two different instances
  

Exploring 'with' for Java records

That’s it! Here Java compiler happily generates bytecode for us, taking into account all parameters defined to implement equals and hashCode methods. Records classes also provide toString and getter methods, but no setters. Record classes are designed to be immutable, which means once an instance is created we can only read object members and cannot change their values. 

Immutability helps reduce bugs in concurrent programs and makes it easier to reason about the code. Currently creating an updated instance is a bit of a nuisance, requiring you to copy all arguments from one record to another, but this will hopefully change in the future with new feature “With for records” outlined in one of the drafts part of project Amber by Brian Goetz himself – a Java Language Architect. Eventually we will be able to create new instance of a record object by modifying one or more of its members, for example:

    var p3 = p2 with { name = "Joe" };
  

There are some libraries that help with this already, you can take a look at this github project record-builder. In addition to solving the limitation of modifying a record object, it also generates builders which is very helpful for more complex data classes.

There is more to Java records than what this example is demonstrating. Other features of records are ability to define custom constructors and we can still define regular methods that can operate on the underlying data. Serialization of records is also simpler and safer than serialization of instances of normal class due to records being immutable by design. Records can also be deconstructed into values which are leveraged by the next feature - pattern matching for records.

Pattern matching for records

Pattern matching for records was finalized in Java 21. Pattern matching and records delivered as part of project Amber enabled a new programming paradigm, Data Oriented Programming. DOP emphasizes on modeling problems as immutable data structures and performing calculations using non-mutating general purpose functions. I think I’ve heard of this before, that’s right, that’s a well-known concept in functional programming! It is worth mentioning that the introduction of features that facilitate the DOP paradigm are not to replace OOP but to help solve certain tasks more elegantly. Where OOP is great at defining and governing code boundaries in large, DOP is more useful in the small to model and act on data.

Let’s get to the chase. What is pattern matching all about? We can think of it as the opposite of class constructor. Class constructor allows us to construct an object by providing some data, while pattern matching allows us to deconstruct or extract the data that were used in the object's construction. Let’s see how this looks in practice.

Pattern matching example

In this example we’re modeling different transaction types using record classes and the objective is to write a method that will consume a list of transactions and calculate account balance. If the transaction is of Purchase type we need to increment balance, if it is Payment type we decrease balance and if it is PaymentReturned we again increase the balance. 

    public interface Transaction {
    String id();
}

record Purchase(String id, Integer purchaseAmount) implements Transaction {}

record Payment(String id, Integer paymentAmount) implements Transaction {}

record PaymentReturned(String id, Integer paymentAmount, String reason) implements Transaction {}

List transactions = List.of(
    new Purchase("1", 1000),
    new Purchase("2", 500),
    new Purchase("3", 700),
    new Payment("1", 1500),
    new PaymentReturned("1", 1500, "NSF")
);
  

Let’s assume we do not have access to the codebase that defines transactions to refactor it, or this part of code should not be responsible for calculating balance. One of the ways to implement the calculateAccountBalance in our code could be as follows.

    public static Integer calculateAccountBalance(List transactions) {
  var accountBalance = 0;
  for (Transaction t : transactions) {
    if (t instanceof Purchase p) {
      accountBalance += p.purchaseAmount();

    } else if (t instanceof Payment p) {
      accountBalance -= p.paymentAmount();

    } else if (t instanceof PaymentReturned p) {
      accountBalance += p.paymentAmount();
    } else {
	throw new RuntimeException("Unknown transaction type");
    }
  }

  return accountBalance;
}
  

This implementation is not too bad, it is fairly readable, with more types of transaction to handle it may get somewhat lengthy. Pattern matching for records improves on this with the following implementation.

    public static Integer calculateAccountBalance(List transactions) {
  var accountBalance = 0;

  for (Transaction t: transactions) {
    switch(t) {
      case Purchase p -> accountBalance += p.purchaseAmount();
      case Payment(var id, var amt) -> accountBalance -= amt;
      case PaymentReturned(var id, var amt, var reason) -> accountBalance += amt;
	default -> throw new RuntimeException("Unknown transaction type");
    }
  }

  return accountBalance;
}
  

This looks a bit cleaner and less verbose. Notice the switch keyword –  it has been available in Java for a long time now. In Java 17, switch has been enhanced to support expressions with familiar syntax to lambdas and in Java 19 and 21 switch was further enhanced to support pattern matching on records. 

When using pattern matching we can either refer to the instance of the type, as shown in the first case, or deconstruct the type into its parts in the second and third case. Pattern matching for switch also allows us to match a pattern based on boolean expression with a new when keyword. For example, we can match the same type multiple times with different predicates and execute different logic for each case.

    switch(t) {
      case PaymentReturned p when p.reason.equals("NSF") -> ...
	case PaymentReturned p -> ...
}
  

If you’re still not convinced by the usefulness of pattern matching, there is one more thing. Let’s say after some time we introduced a new transaction type, let’s call it Credit, to model reversed purchase transactions. By adding a new record type that implements Transaction our code will still compile in both implementations. We will only discover an issue at runtime where our logic encounters a type that it does not know how to handle and an exception will be thrown. 

Usability of pattern matching for records is further enhanced by another language feature that landed back in Java 17, sealed classes and interfaces (JEP409). Marking our interface as sealed, gives information to the compiler that there is a finite number of implementations for Transaction, therefore the compiler can verify for us that all the cases had been handled in pattern matching. Implementation classes need to be placed in the same file as the sealed interface (e.g. inside of the interface) or by using permits keyword on the interface to specify a closed type hierarchy (see JEP for more details). 

Now our code will simply not compile if we missed handling one of the cases. In order to ensure this however we need to remove the default case which normally would catch any missing pattern.

    public sealed interface Transaction {
    String id();

    // define in here records extending interface
}


public static Integer calculateAccountBalance(List transactions) {
  var accountBalance = 0;

  for (Transaction t: transactions) {
    switch(t) {
      case Purchase p -> accountBalance += p.purchaseAmount();
      case Payment(var id, var amt) -> accountBalance -= amt;
      case PaymentReturned(var id, var amt, var reason) -> accountBalance += amt;
    }
  }

  return accountBalance;
}
  

Now the compiler will interrupt with the following friendly error message - “Compilation failure: The switch statement does not cover all possible values”. 

The sealed keyword on the interface would not help us out in our initial implementation using if-else blocks therefore pattern matching here is more advantageous. There is more to sealed interfaces/classes than what I touched on in this post, but this is one of the examples that shows how different language features work well together and improve the robustness of the code.

It is worth mentioning that one could use the Visitor design pattern to accomplish the above-described problem. This pattern however introduces quite a bit more complexity and amount of code to be written. With Java 21 introducing sealed interfaces and pattern matching, the Visitor design pattern is effectively obsolete.

Java 21's impact on the development landscape

Java 21 is a feature packed release building upon features since Java 17. Java 21 was chosen as the next LTS release by vendors which is great news especially for bigger companies who typically only allow the use of LTS versions.

There are a lot of new exciting features I did not mention in this post. Virtual Threads a.k.a project Loom perhaps being the most anticipated feature that was finalized in Java 21, but that’s a bigger topic to cover that would require a separate blog post. You can learn more about the recent JDK 21 release on OpenJDK website

Java tends to evolve in a steady and responsible way. It is carefully watching how the software industry is changing and what language features are needed to keep the language relevant while carefully implementing features and ensuring backwards compatibility.

With version 21 Java is a joy to use, it is yet another big milestone that should keep Java strongly planted in the industry for years to come.


Marcin Kossakowski, Lead Software Engineer

Marcin is a lead software engineer in Small Business Card helping build new charge card products. Marcin is passionate about data engineering, distributed and event driven systems. In his spare time he enjoys hiking and mountain biking.

Explore #LifeAtCapitalOne

Startup-like innovation with Fortune 100 capabilities.

Learn more

Related Content