Key Takeaways
- Java 16, and the imminent Java 17 release, come with a plethora of features and language enhancements that will help boost developer productivity and application performance
- Java 16 Stream API provides new methods for commonly used terminal operations and help reduce boilerplate code clutter
- Record is a new Java 16 language feature to concisely define data-only classes. The compiler provides implementations of constructors, accessors, and some of the common Object methods
- Pattern matching is another new feature in Java 16, which, among other benefits, simplifies the otherwise explicit and verbose casting done with instanceof code blocks
Java 16 was released in March of 2021 as a GA build meant to be used in production, and I covered the new features in my detailed video presentation. And Java 17, the next LTS build, is scheduled to be released this September. Java 17 will be packed with a lot of improvements and language enhancements, most of which are a culmination of all the new features and changes that were delivered since Java 11.
In terms of what’s new in Java 16, I am going to share a delightful update in the Stream API and then mostly focus on the language changes.
From Stream to List
List<String> features =
Stream.of("Records", "Pattern Matching", "Sealed Classes")
.map(String::toLowerCase)
.filter(s -> s.contains(" "))
.collect(Collectors.toList());
The code snippet you see above should be pretty familiar to you if you are used to working with the Java Stream API.
What we have in the code is a stream of some strings. We map a function over it and then we filter the stream.
Finally, we are materializing the stream into a list.
As you can see, we usually invoke the terminal operation collect
and pass a collector to it.
This fairly common practice of using the collect
, and passing the Collectors.toList()
to it feels like boilerplate code.
The good news is that in Java 16, a new method was added to the Stream API which enables us to immediately call toList()
as a terminal operation of a stream.
List<String> features =
Stream.of("Records", "Pattern Matching", "Sealed Classes")
.map(String::toLowerCase)
.filter(s -> s.contains(" "))
.toList();
Using this new method in the code above results in a list of strings from the stream that contain a space. Note that this list that we get back is an unmodifiable list. Which means you can no longer add or remove any elements from the list returned from this terminal operation. If you want to collect your stream into a mutable list, you will have to continue using a collector with the collect()
function. So this new toList()
method that is made available in Java 16 is really just a small delight. And this new update will hopefully make the stream pipeline code blocks a little bit easier to read.
Another update to the Stream API is the mapMulti()
method. Its purpose is a bit similar to the flatMap()
method. If you typically work with flatMap()
and you map to inner streams in the lambda that you pass to it, mapMulti()
offers you an alternative way of doing this, where you push elements to a consumer. I won't go into much detail about this method in this article as I would like to discuss the new language features in Java 16. If you're interested to learn more about mapMulti()
, I definitely recommend looking at the Java documentation for this method.
Records
The first big language feature that was delivered in Java 16 is called records. Records are all about representing data as data in Java code rather than as arbitrary classes. Prior to Java 16, when we simply needed to represent some data, we ended up with an arbitrary class as the one shown in the code snippet below.
public class Product {
private String name;
private String vendor;
Private int price;
private boolean inStock;
}
Here we have a Product
class that has four members. This should be all the information that we need to define this class. Of course, we need much more code to make this work. For instance, we need to have a constructor. We need to have corresponding getter methods to get the values of the members. To make it complete, we also need to have equals()
, hashCode()
, and toString()
implementations that are congruent with the members that we defined. Some of this boilerplate code can be generated by an IDE but doing so has some drawbacks. You can also use frameworks like Lombok but they come with some drawbacks as well.
What we really need is a mechanism within the Java language to more precisely describe this concept of having data-only classes. And so in Java 16 we have the concept of records. In the following code snippet we redefined the Product
class as a record.
public record Product(
String name,
String vendor,
int price,
boolean inStock) {
}
Note the introduction of the new keyword record
. We need to specify the name of the record type right after the keyword record
. In our example the name is Product
. And then we only have to provide the components that make up these records. Here we provided the four components by giving their types and the names. And then we are done. A record in Java is a special form of a class that only contains this data.
What does a record offer us? Once we have a record declaration, we will get a class that has an implicit constructor accepting all the values for the components of the record. We automatically get implementations for equals()
, hashCode()
, and toString()
methods based on all the records components. In addition, we also get accessor methods for every component that we have in the record. In our example above, we get a name
method, a vendor
method, a price
method, and an inStock
method that respectively return the actual values of the components of the records.
Records are always immutable. There are no setter methods. Once a record is instantiated with certain values, that is it, you cannot change it anymore. Also, record classes are final. You can implement an interface with a record, but you cannot extend any other class when defining a record. All in all, there are some restrictions here. But records offer us a very powerful way to concisely define data-only classes in our applications.
How to Think About Records
How should you think about and approach these new language elements? A record is a new and restricted form of a class used to model data as data. It is not possible to add any additional state to a record, you cannot define (non-static) fields in addition to a record’s components. Records are really about modeling immutable data. You can also think of records as being tuples, but not just tuples in a generic sense that some other languages have where you have some arbitrary components that can be referenced by index. In Java, the tuple elements have actual names, and the tuple type itself, the record, also has a name, because names matter in Java.
How Not to Think About Records
There are also some ways that we may be tempted to think about records that are not completely appropriate. First and foremost, they are not meant as a boilerplate reduction mechanism for any of your existing code. While we now have a very concise way of defining these records, it does not mean that any data like class in your application can be easily replaced by records, primarily because of the limitations that are imposed by records. This is also not really the design goal.
The design goal of records is to have a good way to model data as data. It's also not a drop-in replacement for JavaBeans, because as I mentioned earlier, the accessor methods, for example, do not adhere to the get standards that JavaBeans have. And JavaBeans are generally mutable, whereas records are immutable. Even though they serve a somewhat similar purpose, records do not replace JavaBeans in any meaningful way. You also should not think of records as value types.
Value types may be delivered as a language enhancement in a future Java release where the value types are very much about memory layout and efficient representation of data in classes. Of course, these two worlds might come together at some point in time, but for now, records are just a more concise way to express data-only classes.
More About Records
Consider the following code where we create records p1
and p2
of type Product
with the exact same values.
Product p1 = new Product("peanut butter", "my-vendor", 20, true);
Product p2 = new Product("peanut butter", "my-vendor", 20, true);
We can compare these records by reference equality and we can also compare them using the equals()
method, the one that has been automatically provided by the record implementation.
System.out.println(p1 == p2); // Prints false
System.out.println(p1.equals(p2)); // Prints true
What you will see here is that these two records are two different instances, so the reference comparison will evaluate to false. But when we use equals()
, it only looks at the values of these two records and it will evaluate to true. Because it is only about the data that is inside of the record. To reiterate, the equality and hashcode implementations are fully based on the values that we provide to the constructor for a record.
One thing to note is that you can still override any of the accessor methods, or the equality and hashcode implementations, inside a record definition. However, it will be your responsibility to preserve the semantics of these methods in the context of a record. And you can add additional methods to a record definition. You can also access the record values in these new methods.
Another important function you might want to perform in a record is validation. For example, you only want to create a record if the input provided to the record constructor is valid. The traditional way to do validation would be to define a constructor with input arguments that get validated before assigning the arguments to the member variables. But with records, we can use a new format, the so-called compact constructor. In this format we can leave off the formal constructor arguments. The constructor will implicitly have access to the component values. In our Product
example, we can say that if the price is less than zero, let's throw a new IllegalArgumentException
.
public record Product(
String name,
String vendor,
int price,
boolean inStock) {
public Product {
if (price < 0) {
throw new IllegalArgumentException();
}
}
}
As you can see from the code snippet above, if the price is above zero, we don't have to manually do any assignments. Assignments from the (implicit) constructor parameters to there record’s fields are added automatically by the compiler when compiling this record.
We can even do normalization if we want to. For example, instead of throwing an exception if the price is less than zero, we can set the price parameter, which is implicitly available, to a default value.
public Product {
if (price < 0) {
price = 100;
}
}
Again, the assignments to actual members of the record, the final fields that are part of the record definition, are inserted automatically by the compiler at the end of this compact constructor. All in all, a very versatile and very nice way to define data-only classes in Java.
You can also declare and define records locally in methods. This can be very handy if you have some intermediate state that you want to use inside of your method. For example, say that we want to define a discounted product. We can define a record which takes Product
and a boolean
that indicates whether the product is discounted or not.
public static void main(String... args) {
Product p1 = new Product("peanut butter", "my-vendor", 100, true);
record DiscountedProduct(Product product, boolean discounted) {}
System.out.println(new DiscountedProduct(p1, true));
}
As you can see from the code snippet above, we won't have to provide a body for the new record definition. And we can instantiate the DiscountedProduct
with p1
and true
as arguments. If you run the code, you will see that this behaves exactly the same way as the top level records in a source file. Records as a local construct can be very useful in situations where you want to group some data in an intermediate stage of say your stream pipeline.
Where Would You Use Records
There are some obvious places where records can be used. One such place is when we want to use Data Transfer Objects (DTOs). DTOs are by definition objects that do not need any identity or behavior. They are all about transferring data. For example, starting with version 2.12, the Jackson library supports serializing and deserializing records to JSON and other supported formats.
Records will also be very useful when you want the keys in a map to consist of multiple values that act as a composite key. Using records in this scenario will be very helpful since you automatically get the correct behavior for equals and hashcode implementations. And since records can also be thought of as nominal tuples, a tuple where each component has a name, you can easily see that it will be very convenient to use records to return multiple values from a method to the caller.
On the other hand, I think records will not be used much when it comes to the Java Persistence API. If you want to use records to represent entities, that is not really possible because entities are heavily based on the JavaBeans convention. And entities usually tend to be mutable rather than immutable. Of course, there might be some opportunities when you instantiate read-only view objects in queries where you could use records instead of regular classes.
All in all, I think it is a very exciting development that we now have records in Java. I think they will see widespread use.
Pattern Matching With instanceof
This brings us to the second language change in Java 16, and that is pattern matching with instanceof
. This is a first step in a long journey of bringing pattern matching to Java. For now, I think it's already really nice that we have the initial support in Java 16. Take a look at the following code snippet.
if (o instanceOf String) {
String s = (String) o;
return s.length();
}
You will probably recognize this pattern where some piece of code checks whether an object is an instanceof a type, in this case the String
class. If the check passes, we need to declare a new scoped variable, cast and assign the value, and only then can we start using the typed variable. In our example, we need to declare variable s
, cast o
to a String
and then call the length()
method. While this works, it is verbose, and it is not really intention revealing code. We can do better.
As of Java 16, we can use the new pattern matching feature. With pattern matching, instead of saying o
is an instance of a specific type, we can match o
against a type pattern. A type pattern consists of a type and a binding variable. Let’s see an example.
if (o instanceOf String s) {
return s.length();
}
What happens in the above code snippet is that if o
is indeed an instance of String
, then String s
will be immediately bound to the value of o
. This means that we can immediately start using s
as a string without an explicit cast inside the body of if
. The other nice thing here is that the scope of s
is limited to just the body of if
. One thing to note here is that the type of o
in source code should not be a subtype of String
, because if that is the case, the condition will always be true. And so, in general, if the compiler detects the type of an object that is being tested is a subtype of the pattern type, it will throw a compile time error.
Another interesting thing to point out is that the compiler is smart enough to infer the scope of s
based on whether the condition evaluates to true or false as you will see in the following code snippet.
if (!(o instanceOf String s)) {
return 0;
} else {
return s.length();
}
The compiler sees that if the pattern match does not succeed, then in the else
branch we would have s
in scope with the type of String
. And in the if
branch s
would not be in scope, we would only have o
in scope. This mechanism is called flow scoping where the type pattern variable is only in scope if the pattern actually matches. This is really convenient. It really helps tighten up this code. It is something that you need to be aware of and might take a little bit of getting used to.
One more example where you can very nicely see this flow typing in action is when you rewrite the following code implementation of the equals()
method. The regular implementation is to first check whether o
is an instance of MyClass
. If it is, we cast o
to MyClass
and then match the name field of o
with the current instance of MyClass
.
@Override
public boolean equals(Object o) {
return (o instanceOf MyClass) &&
((MyClass) o).name.equals(name);
}
We can simplify the implementation using the new pattern matching mechanism as demonstrated in the following code snippet.
@Override
public boolean equals(Object o) {
return (o instanceOf MyClass m) &&
m.name.equals(name);
}
Again, a nice simplification of explicit, verbose casting in the code. Pattern matching abstracts away a lot of boilerplate code when used in appropriate use cases.
Pattern Matching: Future
The Java team has sketched out some of the future directions of pattern matching. Of course, there are no promises on when or how these future directions will actually end up in the official language. In the following code snippet we will see that in the new switch expression we can use type patterns with instanceOf
like we discussed previously.
static String format(Object o) {
return switch(o) {
case Integer i -> String.format("int %d", i);
case Double d -> String.format("int %f", d);
default -> o.toString();
};
}
In the case where o
is an integer, flow scoping kicks in and we have variable i
immediately available to be used as an integer. Same holds true with the other cases and the default branch.
Another new and exciting direction is record patterns where we might be able to pattern match our records and immediately bind to the component values to fresh variables. Take a look at the following code snippet.
if (o instanceOf Point(int x, int y)) {
System.out.println(x + y);
}
We have a Point
record with x
and y
. If the object o
is indeed a point, we will immediately bind the x
and y
components to the x
and y
variables and immediately start using them.
Array patterns are another kind of pattern matching that we might get in a future version of Java. Take a look at the following code snippet.
if (o instanceOf String[] {String s1, String s2, ...}) {
System.out.println(s1 + s2);
}
If o
is an array of strings, you can immediately extract the first and the second parts of the string array to s1
and s2
. Of course, this only works if there are actually two or more elements in the string array. And we can just ignore the remainder of the array elements using the three dot notation.
To sum up, pattern matching with instanceOf
is just a nice, small feature, but it is a small step towards this new future where we may have additional kinds of patterns that can be used to write clean, simple and readable code.
Preview Feature: Sealed Class
Let’s talk about the sealed classes feature. Note that this is a preview feature in Java 16, though it will be final in Java 17. You need to pass the --enable-preview
flag to your compiler invocation and your JVM invocation in order to use this feature with Java 16. The feature allows you to control your inheritance hierarchy.
Let's say you want to model a super type Option
where you only want to have Some
and Empty
as subtypes. And you want to prevent arbitrary extensions of your Option
type. For example, you do not want to allow a Maybe
type in the hierarchy.
So you basically have an exhaustive overview of all subtypes of your Option
type. As you know, the only tool to control inheritance in Java at the moment is via the final
keyword. This means that there cannot be any subclasses at all. But that is not what we want. There are some workarounds to be able to model this feature without sealed classes, but using sealed classes, this becomes much easier.
Sealed classes feature comes with new keywords sealed
and permits
. Take a look at the following code snippet.
public sealed class Option<T>
permits Some, Empty {
...
}
public final class Some
extends Option<String> {
...
}
public final class Empty
extends Option<Void> {
...
}
We can define the Option
class to be sealed
. Then, after the class declaration, we use the permits
keyword to indicate that only Some
and Empty
classes are allowed to extend the Option
class. Then, we can define Some
and Empty
as classes as usual. We want to make these subclasses final
as we want to prevent further inheritance. No other class can now be compiled to extend the Option
class. This is enforced by the compiler through the sealed classes mechanism.
There is a lot more to say about this feature that cannot be covered in this article. If you are interested to learn more, I recommend going to the sealed classes Java Enhancement Proposal page, JEP 360, and read more about it.
And More
There are a lot of other things in Java 16 that we could not cover in this article. For instance, incubator APIs like the Vector API, the Foreign Linker API and the Foreign-Memory Access API are all very promising. And a lot of improvements have been made at the JVM level. For example, ZGC has had some performance improvements. Some Elastic Metaspace improvements have been made in the JVM. And then there is a new packaging tool for Java applications which allows you to create native installers for Windows, Mac, and Linux. Finally, and I think this will be very impactful, encapsulated types in the JDK will be strongly guarded when you run your application from the classpath
.
I highly encourage you to look into all these new features and language enhancements since some of them can have a big impact on your applications.
About the Author
Sander Mak is a Java Champion who has been active in the Java community for over a decade. Currently, he is Directory of Technology at Picnic. At the same time, Mak is also very active in terms of knowledge sharing, through conferences but also on online e-learning platforms.