1. Overview

Often we face a dilemma about whether to migrate to a newer version of Java or continue with the existing one. In other words, we need to balance the new features and enhancements against the total efforts that would be needed to migrate.

In this tutorial, we’ll walk through some extremely useful features available in the newer versions of Java. These features aren’t only easy to learn but can also be implemented quickly without much effort when planning to migrate from Java 8 to Java 17.

2. Using String

Let’s look at some of the interesting enhancements to the String class.

2.1. Compact String

Java 9 introduced Compact String, a performance enhancement to optimize the memory consumption of String objects.

In simple terms, a String object will be internally represented as byte[] instead of char[]. To explain, every char is made up of 2 bytes because Java internally uses UTF-16**.** In most cases, String objects are English words that can be represented with a single byte which is nothing but LATIN-1 representation.

Java internally handles this representation based on the actual content of the String object: a byte[] for the LATIN-1 character set and a char[] if the content contains any special chars (UTF-16). So, it’s fully transparent to the developers using String objects.

Until Java 8, the String was internally represented as char[]:

char[] value;

From Java 9, it will be a byte[]:

byte[] value;

2.2. Text Block

Before Java 15, embedding multi-line code snippets will require explicit line terminators, String concatenations, and delimiters. To address this, Java 15 introduced text blocks that allow us to embed code snippets and text sequences more or less as-is. This is particularly useful when dealing with literal fragments such as HTML, JSON, and SQL.

A text block is an alternative form of String representation that can be used anywhere a normal double-quoted String literal can be used. For instance, a multi-line String literal can be represented without explicit usage of line terminators and String concatenations:

// Using a Text Block
String value = """
            Multi-line
            Text
            """;

Prior to this feature, a multi-line text was not so readable and it was complex to represent:

// Using a Literal String
String value = "Multi-line"
                + "\n" \\ line separator
                "Text"
                + "\n";
String str = "Multi-line\nText\n";

2.3. New String Methods

When dealing with String objects, we often tend to use third-party libraries such as Apache Commons for common String operations. Specifically, this is the case for utility functions to check blank/empty values and other String operations such as repeat, indentation, etc.

Subsequently, Java 11 and Java 12 introduced many such convenient functions so that we can rely on inbuilt functions for such regular String operations: isBlank(), repeat(), indent(), lines(), strip(), and transform().

Let’s see them in action:

assertThat("  ".isBlank());
assertThat("Twinkle ".repeat(2)).isEqualTo("Twinkle Twinkle ");
assertThat("Format Line".indent(4)).isEqualTo("    Format Line\n");
assertThat("Line 1 \n Line2".lines()).asList().size().isEqualTo(2);
assertThat(" Text with white spaces   ".strip()).isEqualTo("Text with white spaces");
assertThat("Car, Bus, Train".transform(s1 -> Arrays.asList(s1.split(","))).get(0)).isEqualTo("Car");

3. Records

Data Transfer Objects (DTO) are useful when passing data between objects. However, creating a DTO comes with a lot of boilerplate code such as fields, constructors, getters/setters, equals(), hashcode(), and toString() methods:

public class StudentDTO {
    
    private int rollNo;
    private String name;
    
    // constructors
    // getters & setters
    // equals(), hashcode() & toString() methods
}

Enter record classes, which are a special kind of class that can define immutable data objects in a much more compact way and identical to Project Lombok. Originally introduced as a preview feature in Java 14, the record class is a standard feature from Java 16:

public record Student(int rollNo, String name) {
}

As we can see, record classes require only the type and name of fields. Subsequently, the compiler generates the equals()hashCode(), and toString() methods in addition to the public constructor, private and final fields:

Student student = new Student(10, "Priya");
Student student2 = new Student(10, "Priya");
        
assertThat(student.rollNo()).isEqualTo(10);
assertThat(student.name()).isEqualTo("Priya");
assertThat(student.equals(student2));
assertThat(student.hashCode()).isEqualTo(student2.hashCode());

4. Helpful NullPointerExceptions

NullPointerExceptions (NPEs) are very common exceptions that every developer faces. In most cases, the error messages thrown by the compiler aren’t useful for identifying the exact object which is null. Also, recent trends towards functional programming and method chaining to write code expressively and compactly make it more difficult to debug NPEs.

Let’s see one such example with method chaining usage:

student.getAddress().getCity().toLowerCase();

Here, if the NPE is thrown in this line, pinpointing the exact location of the null object is difficult as three possible objects can be null.

Starting with Java 14, we can now instruct the compiler with an additional VM argument to get helpful NPE messages:

-XX:+ShowCodeDetailsInExceptionMessages

With this option enabled, the error message is much more precise:

Cannot invoke "String.toLowerCase()" because the return value of "com.baeldung.java8to17.Address.getCity()" is null

Note that, starting from Java 15, this flag is enabled by default.

5. Pattern Matching

Pattern matching addresses a common logic in a program, namely the conditional extraction of components from objects, to be expressed more concisely and safely.

Let’s look at the two features that support pattern matching in Java.

5.1. Enhanced instanceOf Operator

A common logic that every program has is to check a certain type or structure and cast it to the desired type to perform further processing.  This involves a lot of boilerplate code.

Let’s look at an example:

if (obj instanceof Address) {
    Address address = (Address) obj; 
    city = address.getCity();
}

Here, we can see there are three steps involved: a test (to confirm the type), a conversion (to cast to the specific type), and a new local variable (to further process it).

Since Java 16, Pattern Matching for instanceof operator is a standard feature to address this issue. Now, we can directly access the target type in a much more readable way:

if (obj instanceof Address address) {
    city = address.getCity();
}

5.2. Switch Expressions

Switch expressions (Java 14) are like regular expressions which evaluate or return a single value and can be used in statements. In addition, Java 17 enables us to use pattern matching (preview feature) to be used in Switch expressions:

double circumference = switch(shape) {
    case Rectangle r -> 2 * r.length() + 2 * r.width();
    case Circle c -> 2 * c.radius() * Math.PI;
    default -> throw new IllegalArgumentException("Unknown shape");
};

As we can notice, there’s a new kind of case label syntax. switch expressions use “case L ->” labels instead of “case *L:*” labels. Further, there’s no need for explicit break statements to prevent fall through. Moreover, the switch selector expression can be of any type.

When using the traditional “case *L:*” labels in Switch expressions, we must use the yield keyword (instead of break statement) to return the value.

6. Sealed Classes

The primary purpose of inheritance is the reusability of code. Yet, certain business domain models may require only the predefined set of classes to extend the base class or interface. This is specifically valuable when using Domain Driven Design.

To augment this behavior, Java 17 offers Sealed classes as a standard feature. In short, a sealed class or interface can only be extended or implemented by those classes and interfaces which are permitted to do so.

Let’s see how to define a sealed class:

public sealed class Shape permits Circle, Square, Triangle {
}

Here, the Shape class permits inheritance only to a restricted set of classes. In addition, the permitted subclasses must define one of these modifiers: finalsealed, or non-sealed:

public final class Circle extends Shape {
    public float radius;
}

public non-sealed class Square extends Shape {
   public double side;
}   

public sealed class Triangle extends Shape permits ColoredTriangle {
    public double height, base;
}

7. Conclusion

Over the years, Java has introduced a bunch of new features in a phased manner from Java 8 (LTS) to Java 17 (LTS). Each of these features aims to improve multiple aspects such as productivity, performance, readability, and extensibility.

In this article, we explored a selected set of features that are quick to learn and implement. Specifically, the improvements over the String class, record types, and pattern matching will make life easier for developers who migrate from Java 8 to Java 17.

As always, all code samples used in this article are available over on GitHub.