The 14th of September Java 17 was released. Time to take a closer look at the changes since the last LTS release, which is Java 11. A short introduction is given about the licensing model and after that, some of the changes between Java 11 and Java 17 are highlighted, mainly by means of examples. Enjoy!

1. Introduction

First, let’s take a close look at the Java licensing and support model. Java 17 is an LTS (Long Term Support) version just like Java 11. With Java 11 a new release cadence started. Java 11 came with support up to September 2023 and with an extended support up to September 2026. Also, with Java 11, the Oracle JDK was not free anymore for production and commercial use. Every 6 months a new Java version is released, the so-called non-LTS releases Java 12 up to and including Java 16. These are, however, production-ready releases. The only difference with an LTS release is that the support ends when the next version is released. E.g. the support of Java 12 ends when Java 13 is released. You are more or less obliged to upgrade to Java 13 when you want to keep support. This can cause some issues when some of your dependencies are not yet ready for Java 13. Most of the time, for production-use, companies will wait for the LTS releases. But even then, some companies are reluctant for upgrading. A recent survey of Snyk showed that only 60% is using Java 11 in production and this is 3 years after Java 11 was released! Java 8 is also still being used by 60% of the companies. Another interesting thing to notice is that the next LTS release will be Java 21 which will be released in 2 years. A nice overview whether libraries have issues or not with Java 17, can be found here.

The Oracle licensing model has changed with the introduction of Java 17. Java 17 is issued under the new NFTC (Oracle No-Fee Terms and Conditions) license. It is therefore again allowed to use the Oracle JDK version for free for production and commercial use. In the same Snyk survey, it was noted that the Oracle JDK version was only used by 23% of the users in a production environment. You should note that the support for the LTS version will end one year after the next LTS version is released. It will be interesting to see how this will influence upgrading to next LTS versions.

What has changed between Java 11 and Java 17? A complete list of the JEP’s (Java Enhancement Proposals) can be found at the OpenJDK website. Here you can read the nitty gritty details of each JEP. For a complete list of what has changed per release since Java 11, the Oracle release notes give a good overview.

In the next sections, some of the changes are explained by example, but it is mainly up to you to experiment with these new features in order to get acquainted with them. All sources used in this post are available at GitHub.

Last thing for this introduction is that Oracle released dev.java, so do not forget to check this out.

2. Text Blocks

A lot of improvements have been made in order to make Java more readable and less verbose. Text Blocks definitely make code more readable. First, let’s take a look at the problem. Assume that you need some JSON string into your code and you need to print it. There are several issues with this code:

  • Escaping of the double quotes;
  • String concatenation in order to make it more or less readable;
  • Copy-paste of JSON is labour intensive (probable your IDE will help you with that issue).
private static void oldStyle() {
    String text = "{\n" +
                  "  \"name\": \"John Doe\",\n" +
                  "  \"age\": 45,\n" +
                  "  \"address\": \"Doe Street, 23, Java Town\"\n" +
                  "}";
    System.out.println(text);
}

The output of the code above is well-formatted JSON.

{
  "name": "John Doe",
  "age": 45,
  "address": "Doe Street, 23, Java Town"
}

Text Blocks are defined with three double quotes where the ending three double quotes may not be at the same line as the starting one. First, just print an empty block. In order to visualize what happens, the text is printed between two double pipes.

private static void emptyBlock() {
    String text = """
            """;
    System.out.println("||" + text + "||");
}

The output is:

||||

The problematic piece of JSON can now be written as follows, which is much better readable. No need for escaping the double quotes and it looks just like it will be printed.

private static void jsonBlock() {
    String text = """
            {
              "name": "John Doe",
              "age": 45,
              "address": "Doe Street, 23, Java Town"
            }
            """;
    System.out.println(text);
}

The output is of course identical.

{
  "name": "John Doe",
  "age": 45,
  "address": "Doe Street, 23, Java Town"
}

In the previous output, no preceding spaces are present. In the code however, there are preceding spaces. How is stripping preceding spaces determined? First, move the ending three double quotes more to the left.

private static void jsonMovedBracketsBlock() {
    String text = """
              {
                "name": "John Doe",
                "age": 45,
                "address": "Doe Street, 23, Java Town"
              }
            """;
    System.out.println(text);
    System.out.println("123");
}

The output now prints two spaces before each line. This means that the ending three double quotes indicate the beginning of the Text Block.

  {
    "name": "John Doe",
    "age": 45,
    "address": "Doe Street, 23, Java Town"
  }
123

What happens when you move the ending three double quotes more to the right?

private static void jsonMovedEndQuoteBlock() {
    String text = """
              {
                "name": "John Doe",
                "age": 45,
                "address": "Doe Street, 23, Java Town"
              }
                   """;
    System.out.println(text);
}

The preceding spacing is now determined by the first non-space character in a Text Block.

{
  "name": "John Doe",
  "age": 45,
  "address": "Doe Street, 23, Java Town"
}

3. Switch Expressions

Switch Expressions will allow you to return values from the switch and use these return values in assignments, etc. A classic switch is shown here, where, dependent on a given Fruit enum value, some action needs to be done. On purpose the break is left out.

private static void oldStyleWithoutBreak(Fruit fruit) {
    switch (fruit) {
        case APPLE, PEAR:
            System.out.println("Common fruit");
        case ORANGE, AVOCADO:
            System.out.println("Exotic fruit");
        default:
            System.out.println("Undefined fruit");
    }
}

Invoke the method with APPLE.

oldStyleWithoutBreak(Fruit.APPLE);

This prints every case because without the break statement, the case falls through.

Common fruit
Exotic fruit
Undefined fruit

It is therefore necessary to add a break statement within each case in order to prevent this fall-through.

private static void oldStyleWithBreak(Fruit fruit) {
    switch (fruit) {
        case APPLE, PEAR:
            System.out.println("Common fruit");
            break;
        case ORANGE, AVOCADO:
            System.out.println("Exotic fruit");
            break;
        default:
            System.out.println("Undefined fruit");
    }
}

Running this method gives you the desired result but the code is a bit less readable now.

Common fruit

This can be solved by using Switch Expressions. Replace the colon (:) with an arrow (->) and ensure that an expression is used in the case. The default behaviour of Switch Expressions is no fall-through, so no break is needed.

private static void withSwitchExpression(Fruit fruit) {
    switch (fruit) {
        case APPLE, PEAR -> System.out.println("Common fruit");
        case ORANGE, AVOCADO -> System.out.println("Exotic fruit");
        default -> System.out.println("Undefined fruit");
    }
}

This is already less verbose and the result is identical.

A Switch Expression can also return a value. In the above example, you can return the String values and assign them to a variable text. After this, the text variable can be printed. Do not forget to add a semi-colon after the last case bracket.

private static void withReturnValue(Fruit fruit) {
    String text = switch (fruit) {
        case APPLE, PEAR -> "Common fruit";
        case ORANGE, AVOCADO -> "Exotic fruit";
        default -> "Undefined fruit";
    };
    System.out.println(text);
}

And, even shorter, the above can be rewritten in just one statement. It is up to you whether this is more readable than the above.

private static void withReturnValueEvenShorter(Fruit fruit) {
    System.out.println(
        switch (fruit) {
            case APPLE, PEAR -> "Common fruit";
            case ORANGE, AVOCADO -> "Exotic fruit";
            default -> "Undefined fruit";
        });
}

What do you do when you need to do more than only 1 thing in the case? In this case, you can use brackets to indicate a case block and when returning a value, you use the keyword yield.

private static void withYield(Fruit fruit) {
    String text = switch (fruit) {
        case APPLE, PEAR -> {
            System.out.println("the given fruit was: " + fruit);
            yield "Common fruit";
        }
        case ORANGE, AVOCADO -> "Exotic fruit";
        default -> "Undefined fruit";
    };
    System.out.println(text);
}

The output is now a little bit different, two print statements are executed.

the given fruit was: APPLE
Common fruit

It is also cool that you can use the yield keyword in the ‘old’ switch syntax. No break is needed here.

private static void oldStyleWithYield(Fruit fruit) {
    System.out.println(switch (fruit) {
        case APPLE, PEAR:
            yield "Common fruit";
        case ORANGE, AVOCADO:
            yield "Exotic fruit";
        default:
            yield "Undefined fruit";
    });
}

4. Records

Records will allow you to create immutable data classes. Currently, you need to e.g. create a GrapeClass using the autogenerate functions of your IDE to generate constructor, getters, hashCode, equals and toString or you can use Lombok for this purpose. In the end, you end up with some boilerplate code or you end up with a dependency on Lombok in your project.

public class GrapeClass {

    private final Color color;
    private final int nbrOfPits;

    public GrapeClass(Color color, int nbrOfPits) {
        this.color = color;
        this.nbrOfPits = nbrOfPits;
    }

    public Color getColor() {
        return color;
    }

    public int getNbrOfPits() {
        return nbrOfPits;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        GrapeClass that = (GrapeClass) o;
        return nbrOfPits == that.nbrOfPits && color.equals(that.color);
    }

    @Override
    public int hashCode() {
        return Objects.hash(color, nbrOfPits);
    }

    @Override
    public String toString() {
        return "GrapeClass{" +
                "color=" + color +
                ", nbrOfPits=" + nbrOfPits +
                '}';
    }

}

Execute some tests with the above GrapeClass class. Create two instances, print them, compare them, create a copy and compare this one also.

private static void oldStyle() {
    GrapeClass grape1 = new GrapeClass(Color.BLUE, 1);
    GrapeClass grape2 = new GrapeClass(Color.WHITE, 2);
    System.out.println("Grape 1 is " + grape1);
    System.out.println("Grape 2 is " + grape2);
    System.out.println("Grape 1 equals grape 2? " + grape1.equals(grape2));
    GrapeClass grape1Copy = new GrapeClass(grape1.getColor(), grape1.getNbrOfPits());
    System.out.println("Grape 1 equals its copy? " + grape1.equals(grape1Copy));
}

The output of the test is:

Grape 1 is GrapeClass{color=java.awt.Color[r=0,g=0,b=255], nbrOfPits=1}
Grape 2 is GrapeClass{color=java.awt.Color[r=255,g=255,b=255], nbrOfPits=2}
Grape 1 equals grape 2? false
Grape 1 equals its copy? true

The GrapeRecord has the same functionality of the GrapeClass but it is much less verbose. You create a record and indicate what the fields should be and you are done.

public record GrapeRecord(Color color, int nbrOfPits) {
}

A record can be defined in its own file, but because it is so compact, it is also ok to define it where you need it. The above test rewritten with records becomes the following:

private static void basicRecord() {
    record GrapeRecord(Color color, int nbrOfPits) {}
    GrapeRecord grape1 = new GrapeRecord(Color.BLUE, 1);
    GrapeRecord grape2 = new GrapeRecord(Color.WHITE, 2);
    System.out.println("Grape 1 is " + grape1);
    System.out.println("Grape 2 is " + grape2);
    System.out.println("Grape 1 equals grape 2? " + grape1.equals(grape2));
    GrapeRecord grape1Copy = new GrapeRecord(grape1.color(), grape1.nbrOfPits());
    System.out.println("Grape 1 equals its copy? " + grape1.equals(grape1Copy));
}

The output is identical as above. It is important to notice that copies of records should end up in identical copies. Adding extra functionality in e.g. grape1.nbrOfPits() in order to do some processing and returning a different value than the initial nbrOfPits is a bad practice. It is allowed, however, but you should not do this.

The constructor can be extended with some field validation. Note that the assignment of the parameters to the record fields occur at the end of the constructor.

private static void basicRecordWithValidation() {
    record GrapeRecord(Color color, int nbrOfPits) {
        GrapeRecord {
            System.out.println("Parameter color=" + color + ", Field color=" + this.color());
            System.out.println("Parameter nbrOfPits=" + nbrOfPits + ", Field nbrOfPits=" + this.nbrOfPits());
            if (color == null) {
                throw new IllegalArgumentException("Color may not be null");
            }
        }
    }
    GrapeRecord grape1 = new GrapeRecord(Color.BLUE, 1);
    System.out.println("Grape 1 is " + grape1);
    GrapeRecord grapeNull = new GrapeRecord(null, 2);
}

The output of the above test shows you this functionality. Inside the constructor, the field values are still null, but when printing the record, they are assigned a value. The validation also does what it should be doing and throws an IllegalArgumentException when the color is null.

Parameter color=java.awt.Color[r=0,g=0,b=255], Field color=null
Parameter nbrOfPits=1, Field nbrOfPits=0
Grape 1 is GrapeRecord[color=java.awt.Color[r=0,g=0,b=255], nbrOfPits=1]
Parameter color=null, Field color=null
Parameter nbrOfPits=2, Field nbrOfPits=0
Exception in thread "main" java.lang.IllegalArgumentException: Color may not be null
	at com.mydeveloperplanet.myjava17planet.Records$2GrapeRecord.<init>(Records.java:40)
	at com.mydeveloperplanet.myjava17planet.Records.basicRecordWithValidation(Records.java:46)
	at com.mydeveloperplanet.myjava17planet.Records.main(Records.java:10)

5. Sealed Classes

Sealed Classes will give you more control about which classes may extend your class. Sealed Classes is probably more a feature useful for library owners. A class is in Java 11 final or it can be extended. If you want to control which classes can extend your super class, you can put all classes in the same package and you give the super class package visibility. Everything is under your control now, however, it is not possible anymore to access the super class from outside the package. Let’s see how this works by means of an example.

Create an abstract class Fruit with public visibility in package com.mydeveloperplanet.myjava17planet.nonsealed. In the same package, the final classes Apple and Pear are created which both extend Fruit.

public abstract class Fruit {
}
public final class Apple extends Fruit {
}
public final class Pear extends Fruit {
}

Create in package com.mydeveloperplanet.myjava17planet a SealedClasses.java file with a problemSpace method. As you can see, instances can be created for an Apple, a Pear and an Apple can be assigned to a Fruit. Besides that, it is also possible to create a class Avocado which extends Fruit.

private static void problemSpace() {
    Apple apple = new Apple();
    Pear pear = new Pear();
    Fruit fruit = apple;
    class Avocado extends Fruit {};
}

Assume that you do not want someone to extend a Fruit. In that case, you can change the visibility of the Fruit to the default visibility (remove the public keyword). The above code will not compile anymore at the assignment of Apple to Fruit and at the Avocado class creation. The latter is wanted but we do want an Apple to be able to be assigned to a Fruit. This is can be solved in Java 17 with Sealed Classes.

In package com.mydeveloperplanet.myjava17planet.sealed the sealed versions of Fruit, Apple and Pear are created. The only thing to do is to add the sealed keyword to the Fruit class and indicate with the permits keyword which classes may extend this Sealed Class. The subclasses need to indicate whether they are final, sealed or non-sealed. The super class cannot control whether a subclass may be extended and how it may be extended.

public abstract sealed class FruitSealed permits AppleSealed, PearSealed {
}
public non-sealed class AppleSealed extends FruitSealed {
}
public final class PearSealed extends FruitSealed {
}

In the sealedClasses method, it is still possible to assign an AppleSealed to a FruitSealed, but the Avocado is not allowed to extend FruitSealed. It is however allowed to extend AppleSealed because this subclass is indicated as non-sealed.

private static void sealedClasses() {
    AppleSealed apple = new AppleSealed();
    PearSealed pear = new PearSealed();
    FruitSealed fruit = apple;
    class Avocado extends AppleSealed {};
}

6. Pattern matching for instanceof

It is often necessary to check whether an object is of a certain type and when it is, the first thing to do is to cast the object to a new variable of that certain type. An example can be seen in the following code:

private static void oldStyle() {
    Object o = new GrapeClass(Color.BLUE, 2);
    if (o instanceof GrapeClass) {
        GrapeClass grape = (GrapeClass) o;
        System.out.println("This grape has " + grape.getNbrOfPits() + " pits.");
    }
}

The output is:

This grape has 2 pits.

With pattern matching for instanceof, the above can be rewritten as follows. As you can see, it is possible to create the variable in the instanceof check and the extra line for creating a new variable and casting the object, is not necessary anymore.

private static void patternMatching() {
     Object o = new GrapeClass(Color.BLUE, 2);
     if (o instanceof GrapeClass grape) {
         System.out.println("This grape has " + grape.getNbrOfPits() + " pits.");
     }
}

The output is of course identical as above.

It is important to take a closer look at the scope of the variable. It should not be ambiguous. In the code below, the condition after && will only be evaluated when the instanceof check results to true. So this is allowed. Changing the && into || will not compile.

private static void patternMatchingScope() {
    Object o = new GrapeClass(Color.BLUE, 2);
    if (o instanceof GrapeClass grape && grape.getNbrOfPits() == 2) {
        System.out.println("This grape has " + grape.getNbrOfPits() + " pits.");
    }
}

Another example concerning scope is shown in the code below. If the object is not of type GrapeClass, a RuntimeException is thrown. In that case, the print statement will never be reached. In this case, it is also possible to use the grape variable because the compiler knows for sure that grape exists.

private static void patternMatchingScopeException() {
    Object o = new GrapeClass(Color.BLUE, 2);
    if (!(o instanceof  GrapeClass grape)) {
        throw new RuntimeException();
    }
    System.out.println("This grape has " + grape.getNbrOfPits() + " pits.");
}

7. Helpful NullPointerExceptions

Helpful NullPointerExceptions will save you some valuable analyzing time. The following code results in a NullPointerException.

public static void main(String[] args) {
    HashMap<String, GrapeClass> grapes = new HashMap<>();
    grapes.put("grape1", new GrapeClass(Color.BLUE, 2));
    grapes.put("grape2", new GrapeClass(Color.white, 4));
    grapes.put("grape3", null);
    var color = ((GrapeClass) grapes.get("grape3")).getColor();
}

With Java 11, the output will show you the line number where the NullPointerException occurs, but you do not know which chained method resolves to null. You have to find out yourself by means of debugging.

Exception in thread "main" java.lang.NullPointerException
        at com.mydeveloperplanet.myjava17planet.HelpfulNullPointerExceptions.main(HelpfulNullPointerExceptions.java:13)

With Java 17, the same code results in the following output which shows exactly where the NullPointerException occured.

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "com.mydeveloperplanet.myjava17planet.GrapeClass.getColor()" because the return value of "java.util.HashMap.get(Object)" is null
	at com.mydeveloperplanet.myjava17planet.HelpfulNullPointerExceptions.main(HelpfulNullPointerExceptions.java:13)

8. Compact Number Formatting Support

A factory method is added to NumberFormat in order to format numbers in compact, human-readable form according to the Unicode standard. The SHORT format style is shown in the code below:

NumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.ENGLISH, NumberFormat.Style.SHORT);
System.out.println(fmt.format(1000));
System.out.println(fmt.format(100000));
System.out.println(fmt.format(1000000));

The output is:

1K
100K
1M

The LONG format style:

fmt = NumberFormat.getCompactNumberInstance(Locale.ENGLISH, NumberFormat.Style.LONG);
System.out.println(fmt.format(1000));
System.out.println(fmt.format(100000));
System.out.println(fmt.format(1000000));

The output is:

1 thousand
100 thousand
1 million

The LONG format in Dutch instead of in English:

fmt = NumberFormat.getCompactNumberInstance(Locale.forLanguageTag("NL"), NumberFormat.Style.LONG);
System.out.println(fmt.format(1000));
System.out.println(fmt.format(100000));
System.out.println(fmt.format(1000000));

The output is:

1 duizend
100 duizend
1 miljoen

9. Day Period Support Added

A new pattern B is added for formatting a DateTime which indicates a day period according to the Unicode standard.

With default English Locale, print several moments of the day:

DateTimeFormatter dtf = DateTimeFormatter.ofPattern("B");
System.out.println(dtf.format(LocalTime.of(8, 0)));
System.out.println(dtf.format(LocalTime.of(13, 0)));
System.out.println(dtf.format(LocalTime.of(20, 0)));
System.out.println(dtf.format(LocalTime.of(23, 0)));
System.out.println(dtf.format(LocalTime.of(0, 0)));

The output is:

in the morning
in the afternoon
in the evening
at night
midnight

And now with Dutch Locale:

dtf = DateTimeFormatter.ofPattern("B").withLocale(Locale.forLanguageTag("NL"));
System.out.println(dtf.format(LocalTime.of(8, 0)));
System.out.println(dtf.format(LocalTime.of(13, 0)));
System.out.println(dtf.format(LocalTime.of(20, 0)));
System.out.println(dtf.format(LocalTime.of(0, 0)));
System.out.println(dtf.format(LocalTime.of(1, 0)));

The output is the following. Notice the English night starts at 23h and the Dutch night at 01h. Cultural differences probably ;-).

’s ochtends
’s middags
’s avonds
middernacht
’s nachts

10. Stream.toList()

In order to convert a Stream to a List, you need to call the collect method with Collectors.toList(). This is quite verbose as can be seen in the example below.

private static void oldStyle() {
    Stream<String> stringStream = Stream.of("a", "b", "c");
    List<String> stringList =  stringStream.collect(Collectors.toList());
    for(String s : stringList) {
        System.out.println(s);
    }
}

In Java 17, a toList method is added which replaces the old behaviour.

private static void streamToList() {
    Stream<String> stringStream = Stream.of("a", "b", "c");
    List<String> stringList =  stringStream.toList();
    for(String s : stringList) {
        System.out.println(s);
    }
}

11. Conclusion

In this blog, you took a quick look at some features added since the last LTS release Java 11. It is now up to you to start thinking about your migration plan to Java 17 and a way to learn more about these new features and how you can apply them into your daily coding habits. Tip: IntelliJ will help you with that!