Get Even More Visitors To Your Blog, Upgrade To A Business Listing >>

New language features since Java 8 to 18

(Read this article on the blog)

Last updated on 2022/04/26 to include changes up to JDK 18.

This article is also available in Chinese by Alex Tan.

When Java 8 introduced Streams and Lambdas it was a big change, enabling functional programming style to be expressed with much less boilerplate. Since then Java switched to a more rapid release cadence, making a new Java version appear every six months. These versions constantly bring new features to the language. Among the recent features probably the most important ones are Records, Pattern matching and Sealed Classes that makes it easier to program with plain data.

Language enhancements after Java 8

Java 18

  • Pattern Matching for switch (Preview 🔍)

Java 17

  • Sealed Classes
    • Tip: Consider sealed classes vs Enums

Java 16

  • Record Classes
    • Tip: Use Local Records to model intermediate transformations
    • Tip: Check your libraries
  • Pattern Matching for instanceof
    • Tip: Stay tuned for updates

Java 15

  • Text Blocks
    • Tip: Preserve trailing spaces
    • Tip: Produce the correct newline characters for Windows
    • Tip: Pay attention to consistent indentation
  • Helpful NullPointerExceptions
    • Tip: Check your tooling

Java 14

  • Switch Expressions
    • Tip: Use arrow syntax

Java 11

  • Local-Variable Type Inference
    • Tip: Keep readability in mind
    • Tip: Pay attention to preserve important type information
    • Tip: Make sure to read the official style guides

Java 9

  • Allow private methods in interfaces
  • Diamond operator for anonymous inner classes
  • Allow effectively-final variables to be used as resources in try-with-resources statements
    • Tip: Watch out for released resources
  • Underscore is no longer a valid identifier name
  • Improved Warnings

For an overview of all the JEPs shaping the new platform, including API, performance and security improvements check the curated list of all enhancements since Java 8.

Pattern Matching for switch (Preview 🔍)

Available since: Preview in JDK 18 JDK 17

Previously, switch was very limited: the cases could only test exact equality, and only for values of a few types: numbers, Enum types and Strings.

This preview feature enhances switch to work on any type and to match on more complex patterns.

These additions are backwards compatible, switch with the traditional constants work just as before, for example, with Enum values:

var symbol = switch (expression) {
  case ADDITION       -> "+";
  case SUBTRACTION    -> "-";
  case MULTIPLICATION -> "*";
  case DIVISION       -> "/";
};

However, now it also works with type patterns introduced by JEP 394: Pattern Matching for instanceof:

return switch (expression) {
  case Addition expr       -> "+";
  case Subtraction expr    -> "-";
  case Multiplication expr -> "*";
  case Division expr       -> "/";
};

A pattern supports guards, written as type pattern && guard expression:

String formatted = switch (o) {
    case Integer i && i > 10 -> String.format("a large Integer %d", i);
    case Integer i           -> String.format("a small Integer %d", i);
    default                  -> "something else";
};

This makes a very nice symmetry with the type patterns used in if statements, because similar patterns can be used as conditionals:

if (o instanceof Integer i && i > 10) {
  return String.format("a large Integer %d", i);
} else if (o instanceof Integer i) {
  return String.format("a large Integer %d", i);
} else {
  return "something else";
}

Similarly to the type patterns in if conditions, the scope of the pattern variables are flow sensitive. For example, in the case below the scope of i is the guard expression and the right hand side expression:

case Integer i && i > 10 -> String.format("a large Integer %d", i);

Generally it works just as you’d expect, but there are many rules and edge cases involved. If you are intereseted, I recommend to read the corresponding JEPs or see the Pattern matching for instanceof chapter.

Switch can now also match null values. Traditionally, when a null value was supplied to a switch, it threw a NullPointerException. This is still the case when a null is attempted to be matched on a constant. However, now an explicit case for null can be added:

switch (s) {
  case null  -> System.out.println("Null");
  case "Foo" -> System.out.println("Foo");
  default    -> System.out.println("Something else");
}

The Java compiler emits an error when the switch is incomplete or a case completely dominates the other:

Object o = 1234;

// OK
String formatted = switch (o) {
    case Integer i && i > 10 -> String.format("a large Integer %d", i);
    case Integer i           -> String.format("a small Integer %d", i);
    default                  -> "something else";
};

// Compile error - 'switch' expression does not cover all possible input values
String formatted = switch (o) {
    case Integer i && i > 10 -> String.format("a large Integer %d", i);
    case Integer i           -> String.format("a small Integer %d", i);
};

// Compile error - the second case is dominated by a preceding case label
String formatted = switch (o) {
    case Integer i           -> String.format("a small Integer %d", i);
    case Integer i && i > 10 -> String.format("a large Integer %d", i);
    default                  -> "something else";
};

In Java 18 Pattern Matching remains in preview, incorporating some adjustments base on the feedback given for Java 17:

  • For readability reasons, the dominance checking was updated to force constant case labels to appear before the corresponding type-based pattern. The goal is to always have the more specific cases first. As an example, the cases in the following snippet are only valid in this exact order. If you’d try to rearrange them, you would get a compilation error.
switch(o) {
    case -1, 1 -> "special case"
    case Integer i && i > 0 -> "positive number"
    case Integer i -> "other integer";
}
  • Exhaustiveness checking is now more precise with sealed hierarchies when it comes to generic sealed classes. Consider the example code from the JEP:
sealed interface IT> permits A, B {}
final class AX> implements IString> {}
final class BY> implements IY> {}

static int testGenericSealedExhaustive(IInteger> i) {
    return switch (i) {
        // Exhaustive as no A case possible!
        case BInteger> bi -> 42;
    }
}
  • JEP 405, adding array and record patterns was also targeted to Java 18, but unfortunately it was postponed to Java 19. Hopefully we can get a taste of that next time.

This feature is in preview 🔍, it has to be explicitly enabled with the --enable-preview flag.

Sealed Classes

Available since: JDK 17 (Preview in JDK 15 JDK 16)

Sealed classes and interfaces can be used to restrict which other classes or interfaces may extend or implement them. It gives a tool to better design public APIs, and provides an alternative to Enums to model fixed number of alternatives.

Older Java versions also provide some mechanisms to achieve a similar goal. Classes marked with the final keyword can not be extended at all, and with access modifiers it’s possible to ensure that classes are only extended by members of the same package.

On top of these existing facilities Sealed classes add a fine-grained approach, allowing the authors to explicitly list the subclasses.

public sealed class Shape
    permits Circle, Quadrilateral {...}

In this case, it’s only permitted to extend the Shape class with the Circle and the Quadrilateral classes. Actually, the term permits might be a bit misleading, since it not only permits, but it is required that the listed classes directly extend the sealed class.

Additionally, as one might expect from such a grant, it’s a compile error if any other classes try to extend the sealed class.

Classes that extend a sealed class have to conform to a few rules.

Authors are forced to always explicitly define the boundaries of a sealed type hierarchy by using exactly one of the following modifiers on the permitted subclasses:

  • final: the subclass can not be extended at all
  • sealed: the subclass can only be extended by some permitted classes
  • non-sealed: the subclass can be freely extended

Because the subclasses can also be sealed it means that it’s possible to define whole hierarchies of fixed alternatives:

public sealed class Shape
    permits Circle, Quadrilateral, WeirdShape {...}

public final class Circle extends Shape {...}

public sealed class Quadrilateral extends Shape
    permits Rectangle, Parallelogram {...}
public final class Rectangle extends Quadrilateral {...}
public final class Parallelogram extends Quadrilateral {...}

public non-sealed class WeirdShape extends Shape {...}

«Sealed»Shape«Final»Circle«Sealed»Quadrilateral«Final»Rectangle«Final»ParallelogramWeirdShape

If these classes are short and mostly about data it might make sense to declare all of them in the same source file in which case the permits clause can be omitted:

public sealed class Shape {
  public final class Circle extends Shape {}

  public sealed class Quadrilateral extends Shape {
    public final class Rectangle extends Quadrilateral {}
    public final class Parallelogram extends Quadrilateral {}
  }

  public non-sealed class WeirdShape extends Shape {}
}

Record classes can also be part of a sealed hierarchy as leafs because they are implicitly final.

Permitted classes must be located in the same package as the superclass — or, in case of using java modules, they have to reside in the same module.

⚠️ Tip: Consider using Sealed classes over Enums

Before Sealed classes, it was only possible to model fixed alternatives using Enum types. E.g.:

enum Expression {
  ADDITION,
  SUBTRACTION,
  MULTIPLICATION,
  DIVISION
}

However, all variations need to be in the same source file, and Enum types doesn’t support modelling cases when an instance is needed instead of a constant, e.g. to represent individual messages of a type.

Sealed classes offer a nice alternative to Enum types making it possible to use regular classes to model the fixed alternatives. This will come to full power once Pattern Matching for switch becomes production ready, after that Sealed classes can be used in switch expressions just like enums, and the compiler can automatically check if all cases are covered.

Enum values can be enumerated with the values method. For Sealed classes and interfaces, the permitted subclasses can be listed with getPermittedSubclasses.

Record Classes

Available since: JDK 16 (Preview in JDK 14 JDK 15)

Record Classes introduce a new type declaration to the language to define immutable data classes. Instead of the usual ceremony with private fields, getters and constructors, it allows us to use a compact syntax:

public record Point(int x, int y) { }

The Record Class above is much like a regular class that defines the following:

  • two private final fields, int x and int y
  • a constructor that takes x and y as a parameter
  • x() and y() methods that act as getters for the fields
  • hashCode, equals and toString, each taking x and y into account

They can be used much like normal classes:

var point = new Point(1, 2);
point.x(); // returns 1
point.y(); // returns 2

Record Classes intended to be transparent carriers of their shallowly immutable data. To support this design they come with a set of restrictions.

Fields of a Record Class are not only final by default, it’s not even possible to have any non-final fields.

The header of the definition has to define everything about the possible states. It can’t have additional fields in the body of the Record Class. Moreover, while it’s possible to define additional constructors to provide default values for some fields, it’s not possible to hide the canonical constructor that takes all record fields as arguments.

Finally, Record Classes can’t extend other classes, they can’t declare native methods, and they are implicitly final and can’t be abstract.

Supplying data to a record is only possible through its constructor. By default a Record Class only has an implicit canonical constructor. If the data needs to be validated or normalized, the canonical constructor can also be defined explicitly:

public record Point(int x, int y) {
  public Point {
    if (x  0) {
      throw new IllegalArgumentException("x can't be negative");
    }
    if (y  0) {
      y = 0;
    }
  }
}

The implicit canonical constructor has the same visibility as the record class itself. In case it’s explicitly declared, its access modifier must be at least as permissive as the access modifier of the Record Class.

It’s also possible to define additional constructors, but they must delegate to other constructors. In the end the canonical constructor will always be called. These extra constructors might be useful to provide default values:

public record Point(int x, int y) {
  public Point(int x) {
    this(x, 0);
  }
}

Getting the data from a record is possible via its accessor methods. For each field x the record classes has a generated public getter method in the form of x().

These getters can also be explicitly defined:

public record Point(int x, int y) {
  @Override
  public int x() {
    return x;
  }
}

Note, that the Override annotation can be used in this case to make sure that the method declaration explicitly defines an accessor and not an extra method by accident.

Similarly to getters, hashCode, equals and toString methods are provided by default considering all fields; these methods can also be explicitly defined.

Finally, Record Classes can also have static and instance methods that can be handy to get derived information or to act as factory methods:

public record Point(int x, int y) {
  static Point zero() {
    return new Point(0, 0);
  }
  
  boolean isZero() {
    return x == 0 && y == 0;
  }
}

To sum it up: Record Classes are only about the data they carry without providing too much customization options.

Due to this special design, serialization for records are much easier and secure than for regular classes. As written in the JEP:

Instances of record classes can be serialized and deserialized. However, the process cannot be customized by providing writeObject, readObject, readObjectNoData, writeExternal, or readExternal methods. The components of a record class govern serialization, while the canonical constructor of a record class governs deserialization.

Because serialization is based exactly on the field state and deserialization always calls the canonical constructor its impossible to create a Record with invalid state.

From the user point of view, enabling and using serialization can be done as usual:

public record Point(int x, int y) implements Serializable { }

public static void recordSerializationExample() throws Exception {
  Point point = new Point(1, 2);

  // Serialize
  var oos = new ObjectOutputStream(new FileOutputStream("tmp"));
  oos.writeObject(point);

  // Deserialize
  var ois = new ObjectInputStream(new FileInputStream("tmp"));
  Point deserialized = (Point) ois.readObject();
}

Note that it’s no longer required to define a serialVersionUID, as the requirement for matching serialVersionUID values is waived for the Record Classes.

Resources:

  • Inside Java Podcast Episode 4: “Record Classes” with Gavin Bierman
  • Inside Java Podcast Episode 14: “Records Serialization” with Julia Boes and Chris Hegarty
  • Towards Better Serialization - Brian Goetz, June 2019
  • Record Serialization

⚠️ Tip: Use Local Records to model intermediate transformations

Complex data transformations require us to model intermediate values. Before Java 16, a typical solution was to rely on Pair or similar holder classes from a library, or to define your own (maybe inner static) class to hold this data.

The problem with this is that the former one quite often proves to be inflexible, and the latter one pollutes the namespace by introducing classes only used in context of a single method. It’s also possible to define classes inside a method body, but due to their verbose nature it was rarely a good fit.

Java 16 improves on this, as now it’s also possible to define Local Records in a method body:

public ListProduct> findProductsWithMostSaving(ListProduct> products) {
  record ProductWithSaving(Product product, double savingInEur) {}

  products.stream()
    .map(p -> new ProductWithSaving(p, p.basePriceInEur * p.discountPercentage))
    .sorted((p1, p2) -> Double.compare(p2.savingInEur, p1.savingInEur))
    .map(ProductWithSaving::product)
    .limit(5)
    .collect(Collectors.toList());
}

The compact syntax of the Record Classes are a nice match for the compact syntax of the Streams API.

In addition to Records, this change also enables the use Local Enums and even Interfaces.

⚠️ Tip: Check your libraries

Record Classes do not adhere to the JavaBeans conventions:

  • They have no default constructor.
  • They do not have setter methods.
  • The accessor methods does not follow the getX() form.

For these reasons, some tools that expect JavaBeans might not fully work with records.

One such case is that records can’t be used as JPA (e.g. Hibernate) entities. There’s a discussion about aligning the specification to Java Records on the jpa-dev mailinglist, but so far I did not find news about the state of the development process. It worth to mention however that Records can be used for projections without problems.

Most other tools I’ve checked (including Jackson, Apache Commons Lang, JSON-P and Guava) support records, but since it’s pretty new there are also some rough edges. For example, Jackson, the popular JSON library was an early adopter of records. Most of its features, including serialization and deserialization work equally well for Record Classes and JavaBeans, but some features to manipulate the objects are yet to be adapted.

Another example I’ve bumped into is Spring, which also support records out of the box for many cases. The list includes serialization and even dependency injection, but the ModelMapper library — used by many Spring applications — does not support mapping JavaBeans to Record Classes.

My advice is to upgrade and check your tooling before adopting Record Classes to avoid surprises, but generally it’s a fair to assume that popular tools already have most of their features covered.

Check out my experiments with the tool integration for Record Classes on GitHub.

Pattern Matching for instanceof

Available since: JDK 16 (Preview in JDK 14 JDK 15)

In most cases, instanceof is typically followed by a cast:

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

At least, in the old days, because Java 16 extends instanceof to make this typical scenario less verbose:

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

The pattern is a combination of a test (obj instanceof String) and a pattern variable (s).

The test works almost like the test for the old instanceof, except it results in a compile error if it is guaranteed to pass all the time:

// "old" instanceof, without pattern variable:
// compiles with a condition that is always true
Integer i = 1;
if (i instanceof Object) { ... } // works

// "new" instanceof, with the pattern variable:
// yields a compile error in this case
if (i instanceof Object o) { ... } // error

Note, that the opposite case, where a pattern match will always fail, is already a compile-time error even with the old instanceof.

The pattern variable is extracted from the target only if the test passes. It works almost like a regular non-final variable:

  • it can be modified
  • it can shadow field declarations
  • if there’s a local variable with the same name, it will result in a compile error

However, special scoping rules are applied to them: a pattern variable is in scope where it has definitely matched, decided by flow



This post first appeared on Blog - Advanced Web Machinery, please read the originial post: here

Share the post

New language features since Java 8 to 18

×

Subscribe to Blog - Advanced Web Machinery

Get updates delivered right to your inbox!

Thank you for your subscription

×