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

New language features since Java 8 to 21

(Read this article on the blog)

Last updated on 2023/09/22 to include changes up to JDK 21.

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 21

  • Pattern Matching for switch
  • Record Patterns
  • Unnamed Patterns and Variables (Preview 🔍)
  • String Templates (Preview 🔍)
  • Unnamed Classes and Instance Main Methods (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

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

Available since: JDK 21 (Preview in JDK 18 JDK 19 JDK 20)

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 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 when guard expression:

String formatted = switch (o) {
    case Integer i when 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 when 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 interested, 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. For backwards compatibility, this is still the case when there’s no explicit null pattern defined. 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");
}

Switch expressions always had to be exhaustive, in other words, they have to cover all possible input types. This is still the case with the new patterns, the Java compiler emits an error when the switch is incomplete:

Object o = 1234;

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

// Compile error - 'switch' expression does not cover all possible input values
// Since o is an Object, the only way to fix it is to add a default case
String formatted = switch (o) {
    case Integer i             -> String.format("a small Integer %d", i);
};

This feature synergizes well with enums, Sealed Classes and generics. If there’s only a fixed set of possible alternatives that exist, the default case can be omitted. Also, this feature helps a lot to maintain the integrity of the codebase when the domain is extended — for example, with a new constant in the enum. Due to the exhaustiveness check, all related switch expressions will yield a compiler error where the default case is not handled. For another example, 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) {
        case BInteger> bi -> 42;
    };
}

This code compiles because the compiler can detect that only A and B are the valid subtypes of I, and that due to the generic parameter Integer, the parameter can only be an instance of B.

Exhaustiveness is checked at compile time but if at runtime a new implementation pops up (e.g. from a separate compilation), the compiler also inserts a synthetic default case that throws a MatchException.

The compiler also performs the opposite of the exhaustiveness check: it emits an error when a case completely dominates another.

Object o = 1234;

// 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 when i > 10 -> String.format("a large Integer %d", i);
    default                    -> "something else";
};

This makes default cases to emit a compile time error if all possible types are handled otherwise.

For readability reasons, the dominance checking forces 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(num) {
    case -1, 1 -> "special case";
    case Integer i && i > 0 -> "positive number";
    case Integer i -> "other integer";
}

Resources:

  • Inside Java Podcast Episode 17 “Pattern Matching for switch” with Gavin Bierman
  • Inside Java Podcast Episode 26: “Java 19 is Here!” with Brian Goetz and Ron Pressler
  • Inside Java Podcast Episode 28: “Java Language - State of the Union” with Gavin Bierman

Record Patterns

Available since: JDK 21 (Preview in JDK 19 JDK 20)

With Switch and instanceof it’s possible match on patterns. The first kind of supported pattern was the type pattern that tests if the parameter is of a given type, and if so, it captures it to a new variable:

// Pattern matching with a type pattern using instanceof
if (obj instanceof String s) {
  // ... use s ...
}

// Pattern matching with a type pattern using switch
switch (obj) {
    case String s -> // ... use s ...
    // ... other cases ...
};

Record Patterns extends the pattern matching capabilities of Java beyond simple type patterns to match and deconstruct Record values. It supports nesting to enable declarative, data focused programming.

For a demonstration, consider the following ColoredPoint, which is composed of a Point2D and a Color:

interface Point { }
record Point2D(int x, int y) implements Point { }
enum Color { RED, GREEN, BLUE }
record ColoredPoint(Point p, Color c) { }

Object r = new ColoredPoint(new Point2D(3, 4), Color.GREEN);

Note, that while we use a Point2D to construct r, ColoredPoint accepts other Point implementations as well.

Without pattern matching, we’d need two explicit type checks to detect if object r is a ColoredPoint that holds Point2D, and also quite some work to extract the values x, y, and c:

if (r instanceof ColoredPoint) {
  ColoredPoint cp = (ColoredPoint) r;
  if (cp.p() instanceof Point2D) {
    Point2D pt = (Point2D) cp.p();
    int x = pt.x();
    int y = pt.y();
    Color c = cp.c();

    // work with x, y, and c
  }
}

Using type patterns for instanceof makes things a bit better as there’s no longer a need for the casts, but conceptually the same thing needs to be done as previously:

if (r instanceof ColoredPoint cp && cp.p() instanceof Point2D pt) {
    int x = pt.x();
    int y = pt.y();
    Color c = cp.c();

    // work with x, y, and c
}

However this can be greatly simplified with Record Patterns that offer a concise way to test the shape of the data and to deconstruct it:

if (r instanceof ColoredPoint(Point2D(int x, int y), Color c)) {
  // work with x, y, and c
}

Patterns can be nested, which allows easy navigation in the objects, allowing the programmer to focus on the data expressed by those objects. Also it centralizes error handling. A nested pattern will only match if all subpatterns match. There’s no need to manually handle each individual branch manually.

It is worth noting the symmetry between the construction and the deconstruction of the record values:

// construction
var r = new ColoredPoint(new Point2D(3, 4), Color.GREEN);

// deconstruction
if (r instanceof ColoredPoint(Point2D(int x, int y), Color c)) { }

Record patterns also support not just instanceof but the Switch expression as well:

var length = switch (r) {
	case ColoredPoint(Point2D(int x, int y), Color c) -> Math.sqrt(x*x + y*y);
	case ColoredPoint(Point p, Color c) -> 0;
}

Resources:

  • Data Oriented Programming in Java by Brian Goetz

Unnamed Patterns and Variables (Preview 🔍)

Available since: Preview in JDK 21

In Java 8, the compiler emitted a warning when ‘_’ was used as an identifier. Java 9 took this a step further making the underscore character illegal as an identifier, reserving this name to have special semantics in the future. The goal of these actions was to prepare the stage for unnamed variables, which is a preview feature in Java 21.

With unnamed variables, now it’s possible to use the underscore character wherever a variable name would be used, for example at variable declaration, catch clause, or the parameter list of a lambda. However, it’s not a regular variable name: it means “don’t care” as underscore can be redeclared, and can not be referenced. It does not pollute the scope with an unnecessary variable (potentially causing trouble and leakage), and never shadows any other variable. Simply put, it enforces and communicates the intention of the programmer much better than the old alternatives, of using variable names such as ignore.

var _ = mySet.add(x); // ignore the return value

try {
   // ...
} catch (Exception _) { // ignore the exception object
   // ...
}

list.stream()
  .map((_) -> /* ... */) // ignore the parameter
  .toList();

A related feature is called unnamed patterns, which can be used in pattern matching to ignore subpatterns.

Let’s say, we have the following pattern, where we don’t need the Color information:

if (r instanceof ColoredPoint(Point(int x, int y), Color c)) {
  // do something with x and y, but c is not needed
}

With var, it’s possible to ignore the type of c, but the match will make c available in the scope:

if (r instanceof ColoredPoint(Point(int x, int y), var c)) {
  // ... x ... y ...
}

Using underscore, marking it an unnamed pattern however ignores not just the type, but the name as well:

if (r instanceof ColoredPoint(Point(int x, int y), _)) {
  // ... x ... y ...
}

Note, that underscore may only be used in a nested position, but not on the top level.

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

String Templates (Preview 🔍)

Available since: Preview in JDK 21

String Templates are an extension to the single-line String literals and Text Blocks, allowing String interpolation and much more.

In previous Java versions String interpolation was a lot of manual work. For example, one could do it with the + operator or the StringBuilder. It’s also possible to do with String::format but with that it is very easy to accidentally have the incorrect number of arguments.

The main use-case for String Templates is to make this use-case easier:

// string interpolation in Java
var name = "Duke";
var info = STR."My name is \{name}";

Let’s compare this with a similar feature, Template literals in JavaScript:

// string interpolation in JavaScript
var name = "Duke";
var info = `My name is ${name}`;

The most obvious difference is that the new syntax introduced in Java for string interpolation is not a new kind of literal (e.g. a backtick \ as in Javascript), but the programmer must explicitly use a *template processor* — STR` in this case.

The template processor is responsible including the expression results into the template, performing any validation and escaping if needed. The evaluation of the embedded expressions happens eagerly when the template is constructed. Templates can’t exist on their own, they can only be used in combination with a template processor.

STR is a template processor instance is used for the string interpolation of a supplied template. It is a public static final field which is automatically imported to all java source files for convenience.

In case of simple String interpolation with STR, the processor returns a String, but it’s not a requirement. Other processors might emit objects of different types. Also, for STR not much validation and escaping happens, but other processors might choose to do something more complex.

Allowing the programmer to choose and implement template processors as they wish make this feature very flexible and robust.

For example, as mentioned in the JEP, it’s possible to easily create a JSON processor, that can be used to turn a templated String into a JSONObject.

var JSON = StringTemplate.Processor.of(
  (StringTemplate st) -> new JSONObject(st.interpolate())
);

JSONObject doc = JSON."""
{
   "name":   "\{name}",
   "phone":  "\{phone}",
   "adress": "\{address}"
};
""";

Because the template processor has fine-grained access to the evaluation mechanism (like text fragments and embedded expressions), it’s also possible to create a processor to produce prepared statements, XMLs, or localization strings, performing all the necessary validation and escaping.

Note, that by convention the name of template processors are always written in uppercase, even in case of local variables. A processor may also throw a checked exception in which case the usual rules apply: the caller has to handle or propagate the exception.

In addition to STR, there are a few more built-in processors.

One is FMT, which is just like STR but accepts format specifiers that appear left to the embedded expression as specified by java.util.Formatter:

double value = 17.8D;
var result = FMT."Value is %7.2f\{value}";
// => "Value is 17.80"

Finally, there’s the RAW processor which can be used to create StringTemplate objects, to be processed later by an other processor:

StringTemplate template = RAW."Hello \{name}";
// ...
String result = STR.process(template);

Java uses backslash (\) instead of the usual dollar sign ($) for embedded expressions in templates for backwards compatibility reasons. In previous Java versions "${...}" is a valid String, however “{}” result in a compilation error, complaining about an illegal escape character. By choosing a new symbol, it is guaranteed that no existing program will break with the addition of String Templates.

To summarize, Java aims to have a string interpolation that is a bit different from similar features in other languages, trading verboseness for flexibility and robustness, and for backwards compatibility.

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

Resources:

  • Inside Java Podcast Episode 26: “Java 19 is Here!” with Brian Goetz and Ron Pressler
  • Inside Java Podcast Episode 28: “Java Language - State of the Union” with Gavin Bierman

Unnamed Classes and Instance Main Methods (Preview 🔍)

Available since: Preview in JDK 21

In old Java versions, one needed write quite some boilerplate code even for the simplest of the applications:

public class HelloWorld {
  public static void main(String[] args) {
    System.out.println("Hello, World!");
  }
}

Usually, this only matters at project setup, but there are cases where it might be problematic:

  • Newcomers to the language have to learn quite some extra keywords and concepts before they can try anything.
  • It’s tedious to write small programs such as shell scripts.

The introduction of unnamed classes and instance main methods makes life a bit easier in these two cases.

Instance main methods makes the Java launch protocol more flexible, by making some aspects of the main method optional.

From now on, visibility does not matter as long as the main method is non-private, the String[] parameter can also be ommitted, and it can be an instance method.

Because it’s possible to define main methods in multiple ways, the new launch protocol defines priorities to choose which one to use:

  1. static main with args
  2. static main without args
  3. instance main with args
  4. instance main without args

In case of the non-static main method, an instance of the enclosing class will be automatically created at startup. The introduction of instance main methods brings a slight breaking change: if there’s an instance main method present, then static main methods from superclasses won’t be considered. In these cases the JVM emits a warning.

class HelloWorld {
  void main() {
    System.out.println("Hello, World!");
  }
}

This is already quite some improvement, but there’s more. Methods to exist outside of an enclosing class in which case they are automatically wrapped into a synthetic unnamed class.

Unnamed classes work similarly to unnamed packages and unnamed modules. If a class does not have a package declaration, it will be part of the unnamed package, in which case they can not be referenced by classes from named packages. If a package is not part of a module, it will be part of the unnamed module, so packages from other modules can’t refer them.

Unnamed classes are the extension of this idea to one more micro level: when the source file has no explicit class declaration, then the contents will become a member of an unnamed class. The unnamed class is always a member of the unnamed package and the unnamed module. It is final, can not implement any interface or extend any class (other than Object), and can not be referenced. This also means that it’s not possible to create instances of an unnamed class, so it is only useful as an entry point to a program. For this reason the compiler even enforces that unnamed classes must have a main method. Otherwise, an unnamed class can be used as a regular class, for example it can have instance and static methods and fields.

So putting it together, the main method can be written as follows:

void main() {
  System.out.println("Hello, World!");
}

Which makes it very convenient to write short shell scripts:

(The warnings regarding the preview features sadly can not be turned off, but once this feature is production ready, it will go away.)

Resources and potential future work:

  • Paving the on-ramp by Brian Goetz
  • Script Java Easily in 21 and Beyond - Inside Java Newscast #49
  • JEP draft: Launch Multi-File Source-Code Programs

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



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 21

×

Subscribe to Blog - Advanced Web Machinery

Get updates delivered right to your inbox!

Thank you for your subscription

×