What's new in JDK 20?

Introduction

Hey Java Developers, JDK 20 has just been released. Let's take a look at some of the key changes included in this release. Here, we will explore what's new in JDK 20. Since we now have a release every six months, we will discuss the additions made in all the enhancements (JEPs).

Oracle JDK Releases

JDK 20 is the first release of 2023 and the last release before the upcoming long-term support version. JDK 20 replaces JDK 19, which will be superseded by JDK 21 later this year in September. Furthermore, JDK 20 is offered under the no-fee terms and conditions, allowing us to use it in production at no cost.

In total, we have seven JEPs, all of which are either incubator JEPs or preview JEPs. These JEPs are grouped into three parts. Firstly, we have language features introduced by Project AMBER. Secondly, we have library improvements from Project PANAMA. Lastly, we have three improvements from Project LOOM, which aim to update how Java manages concurrency

Language Features

Let's start with the language features as they directly impact developers.

Record Patterns (2’nd Preview) 

According to Oracle, JDK 20 reintroduces the 2nd preview of record patterns and includes three changes. These changes make it much easier to deconstruct records and perform operations on their components. 

1. One of the additions is the support for type inference of generic record patterns.

This means that developers can now rely on the compiler to infer the types of the components when using record patterns with generics. It eliminates the need to explicitly specify the types, making the code more concise and readable.

interface Name <T> { }
record FullName <T> (T Firstname, T Lastname)  implements Name<String> { };

  public static void main(String... args) {
      FullName <String> name = new FullName<>("Aman", "Gupta");
      printName(name);
  }

  public static void printName(Name name) {
      if(name instanceOf FullName(var first, var last))
          System.out.println(first + " , " + last);
  }

2. Support for Record Patterns has been added in Enhanced for loop headers.

In JDK 20, there is also an addition of support for Record Patterns in enhanced for loop headers. This allows developers to use record patterns directly in the headers of enhanced for loops, simplifying the iteration over records and accessing their components

record Point (int x, int y) { };
static void dump(Point[] pointArray) {
      for(Point(var x, var y) : pointArray) {
          System.out.println("(" + x + " , " + y + ")");
  }
}

3. Removed support for named record patterns.

In JDK 20, the support for named record patterns has been removed. Previously, developers had the ability to specify names for individual components when deconstructing records using patterns. However, this feature has been removed in this release.

Object object = new Point(1,2);

if(object instanceOf Point(int i, int j) p) {
  System.out.println(i + " , " + j );
}

Record Patterns can be nested to access data at any level of the record hierarchy. Let's take an example to illustrate this:

Suppose we have a record hierarchy consisting of two record types: Person and Address. The Person record contains an Address record as one of its components.

record Address(String street, String city, String country) {}

record Person(String name, int age, Address address) {}

// Example usage
Person person = new Person("John Doe", 30, new Address("123 Main St", "City", "Country"));

// Nested record pattern to access data
if (person instanceof Person p && p.address() instanceof Address a) {
    String street = a.street();
    String city = a.city();
    String country = a.country();
    // Use the extracted data
    System.out.println("Address: " + street + ", " + city + ", " + country);
}

In this example, we use a nested record pattern to access the Address record nested within the Person record. By using the instanceof operator with pattern matching, we can destructure the person object and access the components of the nested Address record (street, city, and country).

JDK 16 introduced Pattern Matching for instanceof

Before

if (obj instanceof String) {

  String s = (String) obj;
  //use s
}

After

if (obj instanceof String) {
    //use s
}

JDK 16 introduced pattern matching for the instanceof operator. Prior to this, if we wanted to perform actions on an object of a certain type, we had to perform a test, declare a variable, manually cast it, and then use it. However, with pattern matching, we can accomplish all of that in a single line.

JDK 16 also Introduced Records

JDK 16 also introduced Records, which simplify the creation of classes used solely to carry immutable data.

Records are more than just a way of reducing the amount of code when declaring a class. Knowing that a class is a record enables us to make interesting optimizations, such as the ones we will now have with record patterns.

Before

class Point
{
final int x_coordinate;
final int y_coordinate;

      //constructor
public Point(int x_coordinate, int y_coordinate) {
super();
this.x_coordinate = x_coordinate;
this.y_coordinate = y_coordinate;
}
     
      //getters
public int getX_coordinate() {
return x_coordinate;
}

public int getY_coordinate() {
return y_coordinate;
}

@Override
public String toString() {
return "Point [x_coordinate=" + x_coordinate + ", y_coordinate=" + y_coordinate + "]";
}

@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + x_coordinate;
result = prime * result + y_coordinate;
return result;
}

@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null || getClass() != obj.getClass())
return false;

Point point = (Point) obj;
if (x_coordinate != point.x_coordinate)
return false;
if (y_coordinate != point.y_coordinate)
return false;
return true;
}
}

After

record Point (int x_coordinate, int y_coordinate)

In JDK 20, we can observe a combination of these two features: pattern matching and record classes."

Pattern Matching and Record Classes Combined

The next step in our list is the records patterns preview, which also combines functionality from two prior enhancements. It allows the use of record classes and pattern matching, both of which were introduced in JDK 16.

In this example, we have defined a record class called 'Point' with the expected components X and Y. In the 'printSum' method, our objective is to first test if the object we received is a Point. If it is, we want to utilize the values of that Point.

Before

record Point(int x, int y) {}

static void printSum(Object o) {
  if(o instanceof Point p) {
      integer x = p.x;
      integer y = p.y;

      System.out.println(x+y);
  }
}

After

record Point(int x, int y) {}

static void printSum(Object o) {
  if(o instanceof Point (int x, int y)) {
      System.out.println(x+y);
  }
}

Notice that the intermediate variable 'p' is only created to access the 'X' and 'Y' values of the Point. It would be better if we could not only test if it is a Point but also extract those values.

In other words, here is the new code with the 'Point (int x, int y)' part, which represents a Record Pattern. 

Pattern matching - More complicated Object Graphs

The true power of pattern matching is its ability to scale elegantly to much more complicated object graphs.

Consider the following declarations: Let's make the Point more complicated by introducing a color. We define a ColoredPoint as a combination of a Point and a color. Finally, a rectangle is defined by two ColoredPoints.

record Point(int x, int y) {}
enum Color (RED, GREEN, BLUE)
record ColoredPoint (Point p, Color c) {}
record Rectangle (ColoredPoint upperLeft, ColoredPoint lowerRight) {}

We know that we can extract the components of an object using a record pattern. If we want to extract the color and the upper-left point, we can write 'coloredPoint.upperLeft' for the upper-left point. However, the 'coloredPoint' for the upper-left is also a record that we can further decompose using nested record patterns.

record Point(int x, int y) {}
enum Color (RED, GREEN, BLUE)
record ColoredPoint (Point p, Color c) {}
record Rectangle (ColoredPoint upperLeft, ColoredPoint lowerRight) {}

static void printUpperLeftColoredPoint(Rectangle r) {
  if(r instanceof Rectangle (ColoredPoint ul, ColoredPoint lr)) {
      System.out.println(ul.c);
  }
}

By using nested patterns, we can simultaneously decompose both the outer and inner records.

record Point(int x, int y) {}
enum Color (RED, GREEN, BLUE)
record ColoredPoint (Point p, Color c) {}
record Rectangle (ColoredPoint upperLeft, ColoredPoint lowerRight) {}

static void printUpperLeftColoredPoint(Rectangle r) {
  if(r instanceof Rectangle (ColoredPoint (Point p, Color c), ColoredPoint lr)) {
      System.out.println(c);
  }
}

Pattern matching - Using var

A record pattern can use 'var' to match against the record component without specifying the type of the component.

record Point(int x, int y) {}
enum Color (RED, GREEN, BLUE)
record ColoredPoint (Point p, Color c) {}
record Rectangle (ColoredPoint upperLeft, ColoredPoint lowerRight) {}

static void printUpperLeftColoredPoint(Rectangle r) {
  if(r instanceof Rectangle (ColoredPoint (var p, Color c), var lr)) {
      System.out.println(c);
  }
}

Pattern matching in Enhanced for Statements

Now, the second preview of it has added support for record patterns to appear in the headers of enhanced for statements

record Point(int x, int y) {}
//declare and initialize points as collection or array of points

for (Point (var x, var y) : points) {
//do something with each values of x and y values of each element in points
}

In this example, if we want to iterate over a group of points and perform actions with their components, we don't have to introduce an extra variable 'points' and then read them. Instead, we can directly access the values.

Pattern Matching for Switch 

JDK 20 also introduces pattern matching on switch expressions and statements. With this feature, we can now test an expression against multiple patterns, each with a specific action. This allows for concise and safe expression of complex data-oriented queries.

Pattern Matching for a switch

In the past, switch statements used to work only with a few types such as numeric values, enums, and strings. However, in JDK 14, we introduced switch expressions, which allowed for additional case labels. Nonetheless, it was still not possible to switch and test an object against an expression to determine if it belonged to a specific type.

Before

static String formatter(Object o) {
 
  String formatted = "unknown";

  if(o instanceof Integer) {
      formatted = String.format("int %d", i);
  } else if(o instanceof Long) {
      formatted = String.format("long %d", l);
  } else if(o instanceof Double) {
      formatted = String.format("double %f", d);
  } else if(o instanceof String) {
      formatted = String.format("string %s", s);
  }
  return formatted;

}

Pattern Matching for switch extends the case labels to include patterns and null in the list of cases, allowing us to significantly simplify this code.

After

Now, as a bonus, not only is this easier to read, but it is also optimizable by the compiler. The case statement has to be exhaustive, so in most cases, it is advisable to add a default value. Additionally, since we can now test for null inside the switch, there is no need to check for the variable's null case outside of it.

static String formattedPatternSwitch(Object o) {

  return switch(o) {

      case null   ->  "null";
      case Integer i  ->  String.format("int %d", i);
      case Long l ->   String.format("long %d", l);
      case Double d ->  String.format("double %f", d);
      case String s ->    String.format("string %s", s);
      default ->  o.toString();
  };
}

Pattern Matching for a switch - Case Refinement

After a successful pattern match, it is often necessary to perform further tests.

static void test(Object o) {
      switch(o) {
          case String s:
          if(s.length() == 1) {...}
          else {...}
              break;
          ...

      };
  }

Pattern Matching for a switch - Optional When Clause

We can combine pattern matching with conditions using the 'when' clause. This allows us to include additional conditions alongside pattern matching. In this case, the first case will match strings of length 1, while the second case will cover all other strings.

static void test(Object o) {
      switch(o) {
          case String s when s.length() == 1 -> ...
          case String s                      -> ...
          ...
      };
  }

We need to be careful with the order of the expressions. If we invert the order, the 'case String s' would match every string, including those with a length of 1, which would overshadow the second pattern.

Reversing the order in this way would result in a compile-time error. As before, switch statements need to be exhaustive. Therefore, it is advisable to include a default case unless we are dealing with an enum or a sealed class.

Summary

So, that's all about the Language Features that we all care about. In conclusion, let's summarize what we discussed in this entire article:

  • Pattern Matching for Switch (2nd Preview)
  • Record Patterns (2nd Preview)

There are other features introduced in JDK 20, which I will cover in the next article.