Most Java programmers are familiar with Java 8 and probably sticked with it – at least that’s the case for me. With the release of Java’s newest LTS version (Java 17), it has become time to dive into this new version and list the most important changes when coming from Java 8. In this blogpost, I will go through each Java version, starting from 9, and describe the most important features per version.

Java 9

Private interface methods

Among other new features, Java 9 introduces private interface methods. For example, the following was previously not valid:

public interface PrivateInterfaceMethod {

    // Private interface methods were not allowed
    private boolean doesThisWork() {
        return true;
    }
}

Private interface methods may be useful when combining it with a default implementation. This would allow implementations of an interface to share the same code, without exposing it to the interface.

Try-with-resources improvements

Java 9 further builds on Java 8’s try-with-resources feature by dropping the requirement to declare a new variable if the resource variable is final or effectively final, i.e., a variable that is not changed after declaration. If we have the following Resource class:

class Resource implements AutoCloseable {
    public void oops() throws IOException {
        throw new IOException();
    }

    @Override
    public void close() throws Exception {
        System.out.println("Closing...");
    }
}

we had to do the following in Java 8:

Resource r = new Resource();
try (Resource r2 = r) {
    r2.oops();
} catch (Exception e) {
    System.out.println("Oops");
}

Java 9 allows us to do that in a more elegant way:

Resource r = new Resource();
// No `try (Resource r2 = r)` needed anymore
try (r) {
    r.oops();
} catch (Exception e) {
    System.out.println("Oops");
}

Java 11

Local Type Variable Inference

Java 11 introduces one radical new feature: Local Type Variable Inference. This feature allows the compiler to automatically infer the type of a variable when declaring it. Instead of specifying the exact type of a declared variable, the new identifier var can be used. This is especially useful for situations that contain a lot of duplicate type declarations. For example, the following two statements achieve the same, although the second statement is easier to read:

List<Integer> list1 = new ArrayList<>();
var list2 = new ArrayList<Integer>();

The Java compiler is able to infer that list2 has the type of ArrayList<Integer>, and thus it is not necessary to specify the type again, as was required previously.

Local Type Variable Inference can be used in a couple of cases. We have already applied it to local variable declarations. It can also be used in:

  • Classic for loops
for (var i = 0; i < 10; i++) {
    System.out.println(i);
}
  • Foreach loops
for (var element : list) {
    System.out.println(element);
}
  • Try-with-resources
try (var r = new Resource()) {
    r.oops();
} catch (Exception e) {
    System.out.println("Oops");
}
  • Lamdba expressions
// Previously types required, i.e., (Integer a, Integer b) -> ...
BiFunction<Integer, Integer, Integer> func = (a, b) -> 2*a + b;

Although the var identifier can be used in all of the aforementioned cases, it can still be beneficial to use the old style type declaration for better readability. Oracle has written some guidelines about when to use and when not to use the new var identifier.

Java 14

Switch expressions

Before Java 14, a switch was a statement and could thus not yield a value. Java 14 introduces switch expressions, which do allow to yield a value. Next to that, new syntax allows better readability of switch expressions.

Because switch is now an expression, the following is now valid:

enum Day {
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY
}

var day = Day.FRIDAY;

boolean isFirstHalf = switch (day) {
    case MONDAY:
    case TUESDAY:
    case WEDNESDAY:
        yield true;
    default:
        yield false;
};

// isFirstHalf == false;

where the yield statement is used to ‘return’ the value to the isFirstHalf variable.

Also new is the arrow syntax in in cases. This allows us to match on multiple values more easily:

boolean isWeekend = switch (day) {
    case SATURDAY, SUNDAY -> true;
    default -> false;
};

Because a switch expression always need to yield a value, a default case is required. Also note that break statements are not required anymore when using this arrow syntax.

Finally, cases can still have blocks, but must then end with a yield:

// yield can also be used when a case has a block
boolean startsWithT = switch (day) {
    case TUESDAY, THURSDAY -> {
        // Do something
        yield true;
    }
    default -> false;
};

Java 15

Text blocks

Perhaps inspired by Python’s multiline strings, Java 15 introduces text blocks. Text blocks are multiline strings that can be used anywhere a normal string can be used and are especially useful for multiline strings. Instead of the standard double quotes around a string ("this is a string"), three double quotes are used ("""this is a text block string"""). Previously, a multiline string needed to be written using newline characters, like this:

String html2 = "<html>\n" +
    "    <body>\n" +
    "        <h1>Hello World!</h1>\n" +
    "    </body>\n" +
    "</html>";

With text blocks, this can be greatly simplified:

String html = """
    <html>
        <body>
            <h1>Hello World!</h1>
        </body>
    </html>""";

Text blocks always start with three double quotes, followed by an enter. So, the actual string always starts on the second line.

Under the hood, text block strings are similar to “normal” strings. For example, the following prints true:

var string1 = "a";
var string2 = """
    a""";

// Prints true
System.out.println(string1.equals(string2));

An additional newline can be added at the end of the string by positioning the closing """ on a new line:

var string1 = "Hello there\n";
var string2 = """
    Hello there
    """;

// Prints true
System.out.println(string1.equals(string2));

As can also be seen in the above example, the whitespaces in front of Hello there are discarded. This can be prevented by placing the closing """ to the far left:

var string1 = "    Now there are three of them\n";
var string2 = """
    Now there are three of them
    """;
var string3 = """
    Now there are three of them
""";

// Prints false
System.out.println(string1.equals(string2));

// Prints true
System.out.println(string1.equals(string3));

Finally, a new way of formatting a string has been added. Previously, the static method String::format had to be used, but now also the String::formatted method on a String instance can be used:

var suffix = ", right?";

// Old way
String format = String.format("This is the old way of formatting%s", suffix);

// New way
String formatted = "This is a nicer way of formatting%s".formatted(suffix);

Java 16

Pattern matching for instanceof

One of the features that Java 16 introduced is pattern matching for instanceof. Previously, after confirming that an object is an instance of another type, the object still needed to be cast to that other type. Java 16 improves this by enabling pattern matching for instanceof:

// Old way
public int getSpecialNumberOld(Object o) {
    if (o instanceof Integer) {
        Integer i = (Integer) o;
        return i;
    } else if (o instanceof String) {
        String s = (String) o;
        return s.length();
    } else if (o instanceof Boolean) {
        Boolean b = (Boolean) o;
        return b ? 1 : 0;
    }

    return 0;
}

// New way
public int getSpecialNumberNew(Object o) {
    if (o instanceof Integer i) {
        return i;
    } else if (o instanceof String s) {
        return s.length();
    } else if (o instanceof Boolean b) {
        return b ? 1 : 0;
    }

    return 0;
}

This feature thus allows us to write the instanceof check and cast in one line, improving readability.

Record classes

Many classes in Java are data classes that do nothing more than storing data. For exactly these cases, record classes were introduced in Java 16. It allows to create data classes with much less code than before:

// Old way
class Rectangle {
    private double length;
    private double width;

    public Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }

    public double length() {
        return length;
    }

    public double width() {
        return width;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null) return false;
        if (!(o instanceof Rectangle r)) return false;
        return r.length == this.length && r.width == this.width;
    }

    @Override
    public int hashCode() {
        return Objects.hash(length, width);
    }

    @Override
    public String toString() {
        return "Rectangle{" +
            "length=" + length +
            ", width=" + width +
            '}';
    }
}

// New way
record Rectangle(double length, double width) { }

As can be seen, a record class requires far less code than the equivalent “classic” class. A record class is equivalent to a class with instance variables, setters and the equals, hashCode and toString methods. However, record classes have some limitations:

  • Record classes do not have setters and are thus immutable.
  • No additional instance variables then the ones specified in the record can be defined.

A record class can have a constructor, static variables and methods:

record Circle(double diameter) {
    // Only static fields are allowed, non-static not
    static double pi = 3.14;

    // Can also use Circle(double diameter), but then this.diameter = diameter should be added at the end of the constructor
    public Circle {
        if (diameter <= 0) {
            throw new IllegalArgumentException("diameter <= 0");
        }
    }

    public double area() {
        return 2 * pi * Math.pow(diameter/2, 2);
    }
}

Record classes also support generics:

record Triangle<T>(T a, T b, T c) { }

and can implement one or more interfaces:

interface Cornered {
    int getNumberOfCorners();
}

record Hexagon() implements Cornered {
    @Override
    public int getNumberOfCorners() {
        return 6;
    }
}

Java 17

Sealed classes

Java 17 introduces sealed classes. A sealed class can limit with other classes and interfaces may extend from it. A sealed class uses the new sealed keyword in front of the class keyword and has a permits clause after the class name. The following example defines a Shape class that can only be extended by the Circle, Rectangle and Cornered classes:

sealed class Shape permits Circle, Rectangle, Cornered { }

A class that occurs in the permits clause should be either final, sealed or non-sealed:

final class Circle extends Shape {}

non-sealed class Rectangle extends Shape {}

sealed class Cornered extends Shape permits Hexagon {}

final class Hexagon extends Cornered {}

The following would thus be illegal, as Triangle does not occur in the permits clause of Shape:

// This will not compile
final class Triangle extends Shape {}

Next to classes, interfaces can be sealed as well:

sealed interface Animal permits Reptile, Doggo, Duck {}

non-sealed interface Reptile extends Animal {}

final class Doggo implements Animal { }

// Records can be used in the `permits` clause
record Duck(String name) implements Animal {}

Conclusion

In this blog post I have enumerated the most important features of Java 17, compared to Java 8. Note that there are more new features than I listed here. In addition, there are more details to discuss for each feature. For the complete overview, I refer the reader to Oracle’s page on Java Language Changes, which discusses each feature more extensively and which I also used for this post.