What’s New Between Java 11 and Java 17?
Learn the differences between Java 11 and Java 17 with this post. Get an overview of the licensing model and examples of the latest Java 17 features.
Join the DZone community and get the full member experience.
Join For FreeOn 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 of 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 the 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 on 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.
The 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)));
he 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!
Published at DZone with permission of Gunter Rotsaert, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments