Revisiting Java in 2021 - I

Cover image by Michael Dziedzic on Unsplash.

Introduction

September marks the release of Java 17, the latest LTS Java release. Java 17 is also the culmination of many language and platform improvements that have steadfastly been introduced with every Java release since Java 11.

I've been working on the JVM platform for well over a decade at this stage, and, as I am sure is the case for many others, this does not necessarily mean I have been programming Java.

I fully embraced Scala, an excellent language with a flawed developer experience, when it came along and drastically improved my functional and reactive programming skills in the process.

Recently, however, Kotlin has been my go-to language on the JVM. Although synonymous with Android development, Kotlin has also been embraced by the JVM backend community and works well with several popular frameworks such as Spring Boot, Quarkus, and Micronaut.

With many excellent languages to choose from on the JVM (shoutout to Clojure), where does Java itself fit in? If you haven't been keeping an eye on Java, what have you missed? Is Java still a viable, modern option when programming on the JVM?

The Java 17 language

To many (if not most) developers, Java still refers to Java 8. So let's quickly recap the major features added in Java 9, 10, and 11 (the previous LTS release) for those that may not be familiar.

Catching-up: Java 8 to 11

  • Java 9 brought modularity to the Java platform. The Modules system (aka Project Jigsaw) was a significant change and affected much of the underlying systems and middleware running Java applications. I encourage you to dive deeper for yourself.
  • Java 10 brought local type inference, allowing the use of var in declaring local variables. There are some examples shown in the code below.
  • Java 11 didn't introduce any major language features but came along with the notorious Oracle Java license change.

The above list is by no means comprehensive, and a plethora of JDK improvements, including new GCs, and more minor language changes, were also introduced. Complete lists are widely available.

Java 17

Back to Java 17 then, as mentioned above, Java 17 really brings several related major features together. These being:

  • Switch expressions (in preview since 12, standard since 14)
  • Switch with yield (since 13)
  • Text blocks (since 12, standardized in 15)
  • Records (since 14, standardized in 16)
  • Sealed classes (since 15, standardized in 17)
  • Pattern matching using instanceof (since 14, standard in 16)
  • Pattern matching in switch statements (still in preview in 17).

Again, the list is not comprehensive; notably, the ZGC and Shenandoah garbage collectors have also since been standardized (since 15), but the features listed above have a significant impact on how we will program Java going forward, which is why I want to focus on them. Let's discuss each of these in more detail.

The code examples shown below are also available on Github.

Switch Expression
The switch can now be used as both a statement and an expression (meaning it returns a value).  case labels are now also available in two forms: case VALUE: ... which works the same as always (allowing fall through) and the new arrow syntax: case VALUE -> ... which does not fall through.
yield has also been introduced as a new keyword. yield allows you to specify the 'return' value of a case block but is not required if the case is a single expression. The code sample below illustrates the new switch expression.

public static String switchExpression(String day) {
  var dayType = switch (day) {
    case "MON", "TUE", "WED", "THUR", "FRI" -> { // the arrow means we need no break, it will only match this case.
      System.out.println("Checking Week Day");
      yield "Work day";  // if the case is a block, we can use yield to supply the value.
    }
    case "SAT", "SUN" -> "Weekend day"; // yield is not required for a single expression.
    default -> throw new IllegalArgumentException("Unknown day");
  };
  return dayType;
}
A Java Switch Expression, yielding a value. Also shown is local type inference (the var keyword) introduced in Java 10.

Text Blocks
Text Blocks are a straightforward but exceedingly helpful feature. Text Blocks allow you to specify Strings over multiple lines, avoiding the need to awkwardly concatenate single-line strings and improving readability.

var json = """
    {
      "name": "ext",
      "systemKey": "1234568",
      "owner": {
        "name": "admin",
        "adminCredentials": "abcdef"
      }
    }
    """;
A Java Text Block. The JSON is now easy to read and update. Note, there is no need to escape quotation marks within the string.

Records
Records are immutable data classes. Using the new record keyword, only the data type and name of fields are specified, and Java generates a constructor, accessors, equals/hashcode, and toString methods. This should be familiar to you if you know Kotlin or Python data classes, Scala case classes, or use Lombok's @Data annotation.

Records remove the need to code boilerplate, verbose immutable data classes by hand, are especially well suited as DTOs and, work well to represent JSON objects.

record Admin(String name, String adminCredentials) implements Principal { }

record ExternalSystem(String name, String systemKey, Admin owner) implements Principal { }

static ExternalSystem readJSON() throws JsonProcessingException {
  var objectMapper = new ObjectMapper();
  var json = """
      {
        "name": "ext",
        "systemKey": "1234568",
        "owner": {
          "name": "admin",
          "adminCredentials": "abcdef"
        }
      }
      """;
  return objectMapper.readValue(json, ExternalSystem.class); // Record support needs Jackson 2.12+
}

// ...

final var externalSystem = readJSON();
System.out.println("System: " + externalSystem.name()); // System: ext
System.out.println("Owner: " + externalSystem.owner()); // Owner: Admin[name=admin, adminCredentials=abcdef]
Java Records implementing an interface Principal . Constructors, accessors, equals, hashcode and toString are generated by the compiler. Records make it trivial to write JSON DTOs.

Sealed Classes and Interfaces
Sealed Classes introduce the concept of fixed-sized class hierarchies to Java. Previously, similar functionality could have been achieved using Enum classes and package-private class hierarchies, both with their own caveats. Sealed Interfaces and Classes specify permitted sub-classes using the new permits keyword. Three constraints are imposed on Sealed Classes:

  1. The sealed class and permitted sub-classes must be in the same module or, if declared in an unnamed module, in the same package.
  2. Permitted sub-classes must directly extend the sealed class.
  3. Every permitted sub-class must include a modifier that specifies how the seal is propagated:
    • final to terminate the hierarchy.
    • sealed thereby permitting further sub-classes.
    • non-sealed thereby again opening up the sub-hierarchy for extension by unknown classes; the superclass cannot prevent this.

Records also work well as leaf nodes (values) in sealed hierarchies as they are implicitly final, terminating the hierarchy. An example is given below.

public sealed interface Principal permits User, Admin, ExternalSystem { // Sealed interface
  String name();
}

record Admin(String name, String adminCredentials) implements Principal { } // Records are implicitly final (required to implement sealed interface)

record ExternalSystem(String name, String systemKey, Admin owner) implements Principal { } // Implicitly implements name() from interface

abstract sealed class User implements Principal permits AnonymousUser, RegisteredUser {

  private final String username;

  protected User(String username) {
    this.username = username;
  }

  @Override
  public String name() {
    return username;
  }
}

final class AnonymousUser extends User {

  public static final String ANONYMOUS = "ANONYMOUS";

  AnonymousUser() {
    super(ANONYMOUS);
  }
}
A sealed Java interface and class hierarchy. Sealed interfaces and classes limit the possible sub-classes: the permitted sub-classes are explicitly listed (`RegisteredUser` is omitted for brevity).

Pattern Matching for instanceof
Java 16 introduces pattern matching via instanceof. In its current state, pattern matching is convenient but basic. However, it lays the foundation for more sophisticated pattern matching in future releases. With instanceof, Pattern Matching tests a type pattern against an object and automatically casts to the type while introducing a local variable. The code below illustrates this and highlights the convenience. I'll discuss Pattern Matching further in the context of switch.

if (p instanceof Admin a) { // matches type pattern (Admin a), and casts with a local variable
  return checkCredentials(a);
}

// ...
@Override
public boolean equals(Object o) {
  return (o instanceof RegisteredUser r) &&
      name().equals(r.name()) &&
      password().equals(r.password());
}
Pattern Matching for instanceof. The second example illustrates the convenience of the cast and local variable that is automatically created.

Pattern Matching for switch expressions
Finally, we have pattern matching for switch expressions. Pattern matching for switch allows checking expressions against multiple patterns similar to those seen with instanceof. Two examples are given below, one of which uses the sealed class hierarchy defined above to illustrate that the default case is not required since the hierarchy can be listed exhaustively.  The feature is still in preview in Java 17 and is also limited in scope; future work includes the possibility of introducing destructuring in patterns and case guards akin to what's possible with Scala's match based pattern matching.

public static String switchPatterns(Object o) {  // matching type patterns
  return switch (o) {
    case Integer i -> "Integer type: " + i;
    case Boolean b -> "Boolean type: " + b;
    case String s -> "String type: " + s;
    default -> "Unknown type";
  };
}

static boolean authenticate(Principal p) {
  return switch (p) { // pattern matching switch
    case AnonymousUser u -> true;
    case RegisteredUser r -> checkCredentials(r); // no casting required
    case Admin a -> checkCredentials(a);
    case ExternalSystem s -> checkCredentials(s);
  }; // compiler knows cases are exhaustive due to sealed interface/class
}
Pattern Matching in Java switch expressions.

How far have we come with Java 17?

Clearly, Java has made significant progress with the road to Java 17. The features shown above are certainly useful, well-engineered additions to the language. Records alone will save thousands of lines of code, and the enhancements to the switch statement is immediately useful and clearly lays the foundation for further, powerful data-driven programming via pattern matching.

However, the natural question is whether it has been enough to allow Java to "catch up" to its more feature-rich JVM competitors, aka Kotlin and Scala.
That answer would clearly be no in terms of language features, but are we asking the right question?

Instead, I would like to note two things that are clear to me with the changes between 11 and 17.

First, Java is absolutely leveraging its last-movers advantage: the features shown above are well understood in other languages and have a proven track record of being useful. Developers already know how to use them effectively, with minimal foot-gunning risks. This keeps the learning curve low and gives Java projects an easy upgrade path. Java projects have powerful new tools without new threats to productivity.

Second, if Java seemed stagnant, it should be clear that the language is indeed moving forward, but in a predictable, well-considered way. The 6-monthly release cycle is working and is certainly a vast improvement on the multi-year releases of years past.

In Part II (coming soon), I take a harder look at where Java fits in relative to Scala and Kotlin, delve into the other half of the equation: the JDK platform, and attempt to summarize where I see Java in 2021.