Skip to content

From Java 8 to Java 21, New Language Features & Highlights

Published:

In this blog post we will find out the major new language features and other important changes introduced since Java 8 and up to Java 21 while revisiting LTS versions in-between. We go through the important JEPs (JDK Enhancement Proposals) and illustrate the changes with code snippets and examples. Read on and get a taste of what’s in it for you when you upgrade!

Table of Contents

From Java 8 To Java 11

For a complete list check out the documentation for Java 9, Java 10 and Java 11.

Private Methods in Interfaces

With the finalization of Project Coin in Java 9, five small amendments were introduced, the most practically useful of which is the support for private methods in interfaces. For the rest of the smaller changes, read here.

public interface MyInterface {

    // Must be implemented by the concrete class
    boolean implementMe();

    // Can be used by the concrete class
    default void myDefaultMethod() {
        System.out.println("I am already implemented (default)");
    }

    // Can only be used within the interface
    private void myPrivateMethod() {
        System.out.println("I am already implemented (privately)");
    }
}

Local-Variable Type Inference

Since Java 10, developers can declare local variables using the var keyword, allowing the compiler to infer the type from the assigned value, thereby enhancing code readability and reducing verbosity while maintaining static type safety.

var instanceCounter = new HashMap<String, Integer>(); // inferred HashMap<String, Integer>
var keys = instanceCounter.keySet();                  // inferred Set<String>
var counts = instanceCounter.values();                // inferred Collection<Integer>

Local-Variable Syntax for Lambda Parameters

Building upon the previous improvement, support for the var type inference was added to lambda parameters in Java 11. Here is a practical example:

// The following definitions are (almost) equivalent
x -> 2 * x;
(Integer x) -> 2 * x;
(var x) -> 2 * x;
(@NonNull var x) -> 2 * x;

Removal of the Java EE and CORBA Modules

To support the evolution and modularity of the Java ecosystem, the Java EE and CORBA Modules were removed from Java 11 onwards. In practice, if you use any of the modules below you will now have to introduce them as dependencies (e.g. in Maven).

From Java 11 To Java 17

The complete list of features added up until Java 17 since Java 11 can be found here.

Switch Expressions

Finalized in Java 14, switch expressions make switch statements more powerful, enabling them to return values, use lambda-like cases and more. This change paved the way for pattern matching for switch, later introduced in Java 21.

int code = switch (color) {
    case "Red", "Green", "Blue" -> {
        System.out.println("RGB!");
        yield 1;
    }
    case "White" -> 2;
    case "Black" -> 3;
    default -> 0;
};

Helpful NullPointerExceptions

Also part of Java 14 was the much anticipated helpful NPEs, which now provide additional information in the stack trace.

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toLowerCase()"
        because the return value of "Demo.getNullString()" is null
        at Demo.main(Demo.java:15)

Text Blocks

Since Java 15 we can finally natively define strings with multiple lines without using \n, StringBuilder or other techniques. This is achieved with text blocks, using """.

var str = """
    I am
    multi
    line!
    """;

Pattern Matching for instanceof

Starting with Java 16 we can avoid casting after using instanceof to check the type in an if branch by combining both requirements in one, as follows.

if (animal instanceof Cat kitty) {
    System.out.println(kitty.meow());
}

Records

Also part of Java 16 was the introduction of Records, which are classes that act as immutable data containers. Developers simply need to define the contents, the rest of the usual POJO methods, such as a constructor, getters, equals, hashCode, toString are provided automatically. Static variables and static methods can still be included in Records for additional flexibility (e.g. builders or defaults).

public record Department (String name, String address) {
    public static Department HQ = new Department("HQ", "1st Street");
}
public record Employee (String name, int id, Department department) {}

...

var employee = new Employee("Joe", 1, Department.HQ);
System.out.println(employee); // Employee[name=Joe, id=1, department=Department[name=HQ, address=1st Street]]
System.out.println(employee.department()); // Department[name=HQ, address=1st Street]

Sealed Classes

In Java 17, sealed classes and interfaces became a finalized feature. They are used to restrict which other classes or interfaces may extend or implement them by using the sealed and permits keyword. Further down the hierarchy, a subclass can also use non-sealed, effectively allowing again extension by unknown subclasses.

public sealed interface Shape permits Square, Triangle {...} // sealed interface
public abstract sealed class Tool permits Hammer, Screwdriver {...} // sealed class
public non-sealed class UnsealedHammer extends Hammer {...} // unsealed class based on Hammer

From Java 17 To Java 21

For a more in-depth Java 21 breakdown, check out “What is New in Java 21 (LTS), with Practical Examples”. The complete list of features added up until Java 21 since Java 17 can be found here.

Pattern Matching for switch

Since Java 21, we can use type matching instead of instanceof, including for null checks. You can also expand composite types, such as the Point record in the example below. For blocks of code you can use yield. A switch statement can immediately return a result.

String formatUsingPatternMatching(Object obj) {
    return switch (obj) {
        case null -> "Null!";
        case Integer i -> {
            System.out.println("Test extra line!");
            yield String.format("int %d", i);
        }
        case String s -> String.format("String %s", s);
        case Point(int x, int y) -> String.format("Point(%d,%d)", x, y);
        case "foo", "FOO" -> "Foo!";
        default -> "Unknown!";
        // could also do: case null, default -> "Unknown or null!";
    };
}

Record Patterns

Also in Java 21, record patterns were finally delivered. Records can be deconstructed by listing their components as follows. This can be done in many nested levels of records.

int recordPattern(Point point) {
    return switch (point) {
        case Point(int x, int y) -> x + y;
        case null -> 0;
    };
}

Virtual Threads

A major feature in Java 21 is the finalization of Virtual Threads, enabling developers to develop more performant solutions but still using the familiar thread-per-request model. Check out this article for more details. Using virtual threads is as easy as changing the Executor in use.

void run(List<Runnable> runnables) {
    // Before: Execute runnables with platform threads (bounded)
    int threads = Runtime.getRuntime().availableProcessors();
    try (var executor = Executors.newFixedThreadPool(threads)) {
        runnables.forEach(runnable -> executor.submit(runnable));
    }
    // Java 21: Execute runnables with virtual threads (unbounded)
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        runnables.forEach(runnable -> executor.submit(runnable));
    }
}

Sequenced Collections

Lastly Java 21 also introduced the SequencedCollection interface in order to “fix” inconsistencies such as the ones below across Collection APIs regarding accessing elements in a well defined order. Read more here.

interface SequencedCollection<E> extends Collection<E> {
    // new method
    SequencedCollection<E> reversed();
    // methods promoted from Deque
    void addFirst(E);
    void addLast(E);
    E getFirst();
    E getLast();
    E removeFirst();
    E removeLast();
}

interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {
    SequencedSet<E> reversed();    // covariant override
}

interface SequencedMap<K,V> extends Map<K,V> {
    // new methods
    SequencedMap<K,V> reversed();
    SequencedSet<K> sequencedKeySet();
    SequencedCollection<V> sequencedValues();
    SequencedSet<Entry<K,V>> sequencedEntrySet();
    V putFirst(K, V);
    V putLast(K, V);
    // methods promoted from NavigableMap
    Entry<K, V> firstEntry();
    Entry<K, V> lastEntry();
    Entry<K, V> pollFirstEntry();
    Entry<K, V> pollLastEntry();
}