Java 17, the latest Long-Term Support (LTS) version of Java, made its debut in the world of Java in September 2021. However, it has not yet seen widespread adoption among developers, with only about 0.37% reported using it. Meanwhile, the majority, approximately 48%, are still using Java 11, and 38% are sticking with Java 8.
According to Oracle’s Java SE Support Roadmap, Java 11’s premier support ends in September 2023, coinciding with the launch of Java 21 LTS. And extended support for Java 11 will continue till September 2026.
This means developers do not need to panic about immediate support discontinuation, but they must plan for migration to Java 17. After premier support ends, they will only receive critical security patches and no major updates.
In this article, we will explore the major enhancements and novelties that Java 17 brings to the table for Java developers.
Let's start the journey with Java 17 and its features:
In the world of Java, creating immutable classes often involves a significant amount of boilerplate code. You need to declare the class, define getters, setters, equals, and hashcode methods, which can be tedious and error-prone.
public class Person {
private String name;
private int age;
// getters, setters, equals, and hashcode methods
}
'records' empower you to define a class with minimal code. They automatically generate getter methods, constructors, and essential methods like equals and hashcode for all specified fields.
An essential feature of 'records' is their immutability. Once you declare the fields within a 'record,' they become unchangeable. You initialize these fields through the constructor arguments, as shown above. While 'records' permit static variables, it's advisable not to override the auto-generated getters and setters to maintain immutability.
Here's an example of a 'record' class with multiple constructors and static methods:
public record Product(int id, String name, double price) {
// Static variable to represent an unknown address
public static String PRODUCT_NAME = "default";
// Static variable to store the maximum allowed price
private static double maxPrice;
// Compact Constructor
public Product() {
// Validate that the product ID is greater than 0
if (id < 1) {
throw new IllegalArgumentException("Product ID must be greater than 0.");
}
// Validate that the price does not exceed the maximum allowed price
if (price > maxPrice) {
throw new IllegalArgumentException("Price exceeds the maximum allowed.");
}
}
// Alternative Constructor
public Product(int id, String name) {
this(id, name, 0.0);
}
// Static methods to set and get the maximum allowed price
public static void setMaxPrice(double maxPrice) {
Product.maxPrice = maxPrice;
}
public static double getMaxPrice() {
return maxPrice;
}
public static Product getName(String name) {
return new Product("ProdcutName", name);
}
}
Here's how you can call the methods and access the static variables of the Product record:
// Access the static variable PRODUCT_NAME
String defaultProductName = Product.PRODUCT_NAME;
// Set the maximum allowed price
Product.setMaxPrice(100.0);
// Get the maximum allowed price
double maxAllowedPrice = Product.getMaxPrice();
// Create a Product with a default name and a given name
Product productWithName = Product.createWithName("Product1");
Some noteworthy characteristics of 'record' classes include the ability to nest classes and interfaces inside them, implicit static nesting for 'records' within records, the capability to implement interfaces, support for creating generic 'record' classes, and the ease of making 'records' serializable.
Java 17 brings a helpful change by providing more informative NullPointerException messages. These improved messages pinpoint the exact method or operation that led to the NullPointerException.
Let's see this in action with an example:
public class Main {
public static void main(String[] args) {
Person person = null;
int age = person.getAge();
}
}
class Person {
private int age;
public int getAge() {
return age;
}
}
In the Previous version of Java, the exception message would typically look like this:
Exception in thread "main" java.lang.NullPointerException
at com.example.Main.main(Main.java:7)
In Java 17:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "com.example.Person.getAge()" because "person" is null
at com.example.Main.main(Main.java:7)
In Java 17, the improved message tells us exactly what went wrong: we tried to call the getAge() method on a null object retrieved from the Person. This enhanced error message makes it much easier to identify and fix the issue in our code.
In the realm of Java programming, readability often reigns supreme. Cluttered code riddled with escape characters can obscure your intent and make maintenance a nightmare. Fortunately, Java has introduced an elegant solution: text blocks. These nifty constructs not only make your code more legible but also eliminate the need for cumbersome escape characters.
Java 11 versions:
String JSON_STRING = "{\r\n" + "\"name\" : \"Brilworks\",\r\n" + "\"website\" : \"https://www.%brilworks.com/\"\r\n" + "}";
With latest Java:
String TEXT_BLOCK_JSON = """
{
"name" : "Brilworks",
"website" : "https://www.%brilworks.com/"
}
""";
```java
public class SQLExample {
public static void main(String[] args) {
String sql = """
SELECT *
FROM customers
WHERE age >= 18
ORDER BY last_name;
""";
System.out.println(sql);
}
}
```
In this example, we define a SQL query within a text block. The SQL code is neatly formatted with proper indentation, line breaks, and keywords. This makes the SQL statement highly readable and easily maintainable within your Java code.
String html = """
<html>
<head>
<title>My Web Page</title>
</head>
<body>
<h1>Welcome to My Web Page</h1>
<p>This is a simple example of HTML written in Java.</p>
</body>
</html>
""";
In this code snippet, we create an HTML string using a text block. The HTML structure is neatly formatted within the triple quotes, making it easy to read and maintain. This approach is far more readable and manageable compared to concatenating strings or using escape characters for line breaks and indentation.
Text blocks provide a cleaner and more natural way to embed multi-line content like HTML, JSON, or SQL within your Java code, enhancing code quality and reducing the chances of errors caused by formatting issues.
Java 17 switch expressions have been revamped, offering a more concise and powerful way to work with switch statements.
The Old-Style Switch Statements:
Let's start by understanding the limitations of traditional switch statements. Here's an example of an old-style switch statement:
```java
private static void oldStyleWithoutBreak(Person person) {
switch (person) {
case ADULT, SENIOR:
System.out.println("Common age group");
case TEENAGER, CHILD:
System.out.println("Young age group");
default:
System.out.println("Undefined age group");
}
}
```
When invoked with 'ADULT,' this method prints all cases due to the lack of 'break' statements:
Common age group
Young age group
Undefined age group
Adding 'break' statements makes it more complex:
private static void oldStyleWithBreak(Person person) {
switch (person) {
case ADULT, SENIOR:
System.out.println("Common age group");
break;
case TEENAGER, CHILD:
System.out.println("Young age group");
break;
default:
System.out.println("Undefined age group");
}
}
Java 17 introduces switch expressions, offering a cleaner and more concise way to write switch statements. Simply replace the colon (:) with an arrow (->) and use expressions in each case. There's no need for 'break' statements; the default behavior is no fall-through.
private static void withSwitchExpression(Person person) {
switch (person) {
case ADULT, SENIOR -> System.out.println("Common age group");
case TEENAGER, CHILD -> System.out.println("Young age group");
default -> System.out.println("Undefined age group");
}
}
This approach reduces verbosity and maintains the same functionality.
Switch expressions can also return values. For example, you can assign the results to a variable and then print it:
private static void withReturnValueEvenShorter(Person person) {
System.out.println(
switch (person) {
case ADULT, SENIOR -> "Common age group";
case TEENAGER, CHILD -> "Young age group";
default -> "Undefined age group";
});
}
Handling Multiple Actions: When you need to perform multiple actions in a case, use curly braces to indicate a case block. When returning a value, use the 'yield' keyword:
private static void withYield(Person person) {
String text = switch (person) {
case ADULT, SENIOR -> {
System.out.println("The given age group is: " + person);
yield "Common age group";
}
case TEENAGER, CHILD -> "Young age group";
default -> "Undefined age group";
};
System.out.println(text);
}
This allows for more complex logic while still benefiting from the improved switch expressions.
In Java 17, switch expressions simplify your code, making it more readable and efficient. Whether you're returning values, handling multiple actions, or just simplifying switch statements, Java 17's switch expressions offer a cleaner and more intuitive way to work with switches.
Java 17 introduces Sealed Classes, a feature that offers enhanced control over class extension, particularly useful for library owners and developers seeking stricter access control. With Sealed Classes, you can precisely define which classes can extend your superclass.
In Java 11, a class can either be declared as 'final,' making it impossible to extend or left open for extension. This open-ended approach may not suit situations where you need finer control over class extensions. Sealed Classes address this issue by providing a way to specify exactly which classes can extend a superclass while still allowing flexibility within those subclasses.
Let's start with an example using a 'Person' class:
public abstract class Person {
}
public final class Adult extends Person {
}
public final class Child extends Person {
}
In this case, 'Person' is open for extension, and we have 'Adult' and 'Child' classes that extend it.
Now, let's leverage Sealed Classes to gain more control. In the 'com.brilwokrs.java17planet.sealed' package, we create sealed versions of 'Person,' 'Adult,' and 'Child':
public abstract sealed class PersonSealed permits AdultSealed, ChildSealed {
}
public final class AdultSealed extends PersonSealed {
}
public final class ChildSealed extends PersonSealed {
}
To make a class sealed, we add the 'sealed' keyword to the superclass and use the 'permits' keyword to list the permitted subclasses. In this case, 'AdultSealed' and 'ChildSealed' are allowed to extend 'PersonSealed.'
With Sealed Classes, you can maintain control while still allowing some flexibility. For instance:
private static void sealedClasses() {
AdultSealed adult = new AdultSealed();
ChildSealed child = new ChildSealed();
PersonSealed person = adult;
// Attempting to create a class extending PersonSealed is not allowed.
// However, extending AdultSealed (marked as non-sealed) is allowed.
class Student extends AdultSealed {};
}
In this example, we can assign an 'AdultSealed' instance to a 'PersonSealed' variable, demonstrating control over extensions. Attempting to create a class extending 'PersonSealed' is disallowed, as we've specified that only 'AdultSealed' and 'ChildSealed' can extend it. However, extending 'AdultSealed' is permitted because it's marked as non-sealed.
Sealed Classes empower you with precise control over class extension, ensuring your code's integrity and security.
In Java programming, it's a common task to check whether an object belongs to a certain type and then perform operations accordingly. Traditionally, this involved checking the type using 'instanceof' and then casting the object to the desired type. However, Java 17 introduces pattern matching for 'instanceof,' which simplifies this process and eliminates the need for explicit casting.
Consider the old way of checking and casting an object:
private static void oldStyle() {
Object o = new Person("John", 30);
if (o instanceof Person) {
Person person = (Person) o;
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
}
}
This approach works, but it requires an additional step of casting the object to the desired type.
Pattern matching for 'instanceof' simplifies this process:
private static void patternMatching() {
Object o = new Person("Alice", 25);
if (o instanceof Person person) {
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
}
}
With pattern matching, you can create the variable 'person' directly within the 'instanceof' check, eliminating the need for explicit casting. The output remains the same, but the code is more concise and easier to read.
When using pattern matching, it's essential to understand the scope of the variable. The variable created within the 'instanceof' check is only accessible within its scope. For example:
private static void patternMatchingScope() {
Object o = new Person("Bob", 40);
if (o instanceof Person person && person.getAge() == 40) {
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
}
}
In this case, 'person' is accessible within the 'if' block, and you can safely use it. However, changing '&&' to '||' in the condition would result in a compilation error because the variable's scope would become ambiguous.
Pattern matching also aids in handling exceptions more effectively. If the object isn't of the expected type, you can throw an exception without worrying about the scope:
private static void patternMatchingScopeException() {
Object o = new Person("Eve", 35);
if (!(o instanceof Person person)) {
throw new RuntimeException("Invalid object type");
}
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
}
In this example, if 'o' is not an instance of 'Person,' a 'RuntimeException' is thrown, ensuring that the print statement is never executed.
Pattern matching for 'instanceof' streamlines type checks, enhances code readability and helps handle exceptions gracefully.
When working with Java Streams, it's often necessary to convert the stream into a more manageable data structure, such as a List. Traditionally, this required calling the 'collect' method with 'Collectors.toList()', which could be verbose. However, Java 17 introduces a new 'toList' method, making the process more concise and readable.
In earlier versions of Java, converting a Stream to a List involved the following steps:
private static void oldStyle() {
Stream<Person> personStream = Stream.of(
new Person("Alice", 25),
new Person("Bob", 30),
new Person("Eve", 35)
);
List<Person> personList = personStream.collect(Collectors.toList());
for (Person person : personList) {
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
}
}
With Java 17's 'toList' method, the code becomes much cleaner:
private static void streamToList() {
Stream<Person> personStream = Stream.of(
new Person("Alice", 25),
new Person("Bob", 30),
new Person("Eve", 35)
);
List<Person> personList = personStream.toList();
for (Person person : personList) {
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
}
}
Let's consider a more practical example. Suppose you have a Stream of 'Person' objects, and you want to filter and collect only those above a certain age:
private static void filterAndToList() {
Stream<Person> personStream = Stream.of(
new Person("Alice", 25),
new Person("Bob", 30),
new Person("Eve", 35)
);
List<Person> seniors = personStream
.filter(person -> person.getAge() >= 30)
.toList();
for (Person senior : seniors) {
System.out.println("Name: " + senior.getName() + ", Age: " + senior.getAge());
}
}
In this example, we filter the 'Person' objects based on age, and then we use 'toList()' to collect them into a List. The resulting code is both concise and expressive, showcasing the power of the 'toList' method in Java 17.
The most recent version of Spring frameworks, Spring Boot 3 and Spring 6, will now require Java 17 or higher version as a baseline. This is because Java 17 offers significant benefits in API design and integration efforts. So, if you are engaged in developing with Spring, you should definitely consider moving to Java 17.
Java 17 has demonstrated significant performance improvements compared to Java 11, particularly in memory usage and time complexity. In benchmark tests, Java 17 outperforms Java 11, and even Java 16.
These performance enhancements make Java 17 an attractive choice for developers, not only for its new features but also for its optimization, making it a pragmatic step towards more efficient and high-performing Java applications.
Java 11's transition towards the end of support marks a significant juncture for developers, where decisions made today will impact the stability and security of our projects in the future. As we've explored in this blog, the countdown to Java 11's end of support doesn't have to be a cause for concern but rather an opportunity for growth.
With Java 17, we've witnessed a host of compelling features that not only simplify our code but also enhance our development experience. From the concise 'record' types, improved NullPointerException messages, and the game-changing text blocks, to the streamlined switch expressions, precise control offered by Sealed Classes, and the elegant pattern matching for 'instanceof,' Java 17 empowers developers to write more readable, efficient, and secure code.
Moreover, Java 17 has made stream-to-list conversion a breeze with the introduction of the 'toList' method, simplifying an operation that was once verbose.
It's worth noting that these are just a few highlights from Java 17, and there are many more features like Compact Number Formatting Support, Day Period Support Added, and many more waiting to be explored.
So, as we prepare to bid farewell to Java 11's Premier Support, let's embrace change, seize these opportunities, and steer our Java applications toward a secure and prosperous future with Java 17. It's a journey well worth embarking upon.
You might also like
Get In Touch
Contact us for your software development requirements