Dieser Artikel ist auch auf Deutsch verfügbar

The time has finally come, on September 19th, JDK 21, the newest long-term support (LTS) release after JDK 17, has come forth into the light of the world. This also means that the features and changes from JDK 18, JDK 19 and JDK 20 will now be increasingly incorporated into our applications.

But wait a minute, why is there another LTS release after just two years? Wasn’t the plan every three years? Yes, that was the plan until Oracle proposed along with the release of JDK 17 to adopt a two year cadence. Since all other relevant developers have agreed to follow this proposal, we now have a new release with at least five years of support after just two years, even though there will be yet another new version in two years time in the form of JDK 25.

It is, in fact, possible to update to the newest version of the JDK every six months, but it often makes sense to move from LTS to LTS release in order to somewhat slow the frequency of new features and enjoy greater stability. For precisely this reason, the present article offers an overview of the new features added since JDK 17 to show why it is worth upgrading to the new LTS release. This is not strictly necessary, however, since support for JDK 17 will continue for several more years.

Before we get started, it is worth noting that alongside final, and therefore stable, features, we now also have incubator (JEP11) and preview features (JEP12). Both are less stable and could change significantly in their final version. In the case of incubator features, there is even the risk that they may be removed before ever making it to a final version. Plus, to ensure that we do not unintentionally make use of unstable preview features, we must activate these both during compilation and at runtime by additionally including --enable-preview.

But that is plenty of introduction. Now let’s dive straight into the pool of new features.

Standard use of UTF-8

The class library contains a great many long-standing methods and constructors dealing with reading and writing data that can be called without specifying a character encoding. These make internal use of the standard encoding, which is determined by calling Charset.defaultCharset().

Prior to JDK 18, a different encoding was used here depending on the operating system and the value set for the system variable file.encoding. With JEP 400, this has been standardized to UTF-8. If this causes problems, it is still possible to utilize the previous logic for determining the encoding by specifying -Dfile.encoding=COMPAT at launch.

Because this change could sometimes produce errors only discoverable at runtime, it is a good idea before updating to JDK 18 or later to test whether your application still functions properly by specifying -Dfile. encoding=UTF-8. Similar to the runtime situation, the compiler javac also expects the source files to be in UTF-8 encoding as of JDK 18. Here as well, specifying -encoding UTF-8 allows you to test whether you will encounter problems upon updating.

The only two places where the switch to UTF-8 has not been implemented are System.out and System.err. Because these interact directly with the operating system, the encoding of Console.charset() is used here.

A simple web server

JDK 18 also added a simple web server that can be used for development purposes in order to serve up static files from the file system, similar to the simple HTTP server available in Python, which can be started in version 2 with python -m SimpleHTTPServer.

The variant developed in JEP 408 for the JVM can be started by calling jwebserver. It then serves up the current directory on port 8000 via the loopback interface using HTTP 1.1. These standard settings can also be changed by specifying options when starting the server. For example, -d can be used to select a different directory and -p to change the port. For a list of all options, specify -h or --help.

Alternatively, the web server can also be accessed programmatically in your code via an API. For this purpose, the existing HttpServer has been extended with the classes SimpleFileServer, HttpHandlers and Request. Listing 1 shows how it might look to use this simple web server.

public static void main(String[] args) throws IOException {
    var server = HttpServer.create(new InetSocketAddress(8000), 0);
    server.createContext("/dir",
        SimpleFileServer.createFileHandler(Path.of("/some/path")));
    server.createContext("/ping",
        HttpHandlers.of(200, new Headers(), "Pong"));
    server.createContext("/echo", exchange -> {
        exchange.sendResponseHeaders(200, 0);
        try (var out = exchange.getResponseBody()) {
            out.write(exchange.getRequestMethod().getBytes(UTF_8));
        }
    });
    server.start();
}
Listing 1: Using the simple web server API

Neither the API nor the server, which is intended as a tool for serving static files, is designed for production use. Both are intended only as quick and easy options for testing or development purposes. For production applications, it is still recommended to use an established server such as Jetty, Netty or Tomcat.

Code snippets in Javadoc

Previously, to have code snippets in Javadoc we had to use a combination of the HTML tag pre and the @code doclet (see Listing 2). This is no longer necessary. As of JDK 18, we only need the @snippet doclet defined by JEP 413. In addition to simpler usage, this also makes it possible to refer to regions in files (see Listing 3).

/**
 * Was used like
 * <pre>{@code
 *   var x = "Michael";
 *   System.out.println(x.toUpperCase());
 * }</pre>
 */
Listing 2: Javadoc snippets prior to JDK 18
/**
 * Can be used like
 * {@snippet :
 *   var x = "Michael";
 *   System.out.println(x.toUpperCase());
 * }
 * or
 * {@snippet file="de/mvitz/Javadoc.java" region="example"}
 */
Listing 3: Javadoc snippets after JDK 18

The most obvious advantage is that referencing a region makes the Javadoc itself more compact. But if we now compile this file and perhaps even execute it as a test, then we can always be sure that the example is correct and functions properly.

However, both aspects – compilation and execution – are not part of the JEP, meaning that we are responsible for ensuring this. One option is to accomplish this with Maven, as demonstrated by Nicolai Parlog in his blog post “Configuring Maven For Compiled And Tested Code in Javadoc”.

Sequenced collections

The collections API contained in the JDK is very powerful and contains diverse types in the form of List, Set, Queue and Map, but it previously had no dedicated type for indicating that a collection has a defined order. It is true that List and Queue have a defined order, namely the order of insertion, but the shared supertype Collection does not make this clear. In the case of Set, this type lacks a defined order, but there are subtypes such as SortedSet or LinkedHashSet that do. This becomes problematic when we want to indicate in our API that we have a sorted Collection but that it does not necessarily have to be a List. An associated issue is that, depending on the type, it can be difficult to obtain the last element or to iterate backwards.

To solve precisely this problem, JDK 21 introduces sequenced collections with JEP 431. These consist primarily of the three new interfaces SequencedCollection, SequencedSet and SequencedMap, which are implemented from the corresponding existing interfaces and classes. SequencedCollection adds the methods shown in Listing 4, while SequencedSet defines only the return type of reversed() for SequencedSet.

public interface SequencedCollection<E>
        extends Collection<E> {

    SequencedCollection<E> reversed();

    void addFirst(E e);
    void addLast(E e);

    E getFirst();
    E getLast();

    E removeFirst();
    E removeLast();
}
Listing 4: SequencedCollection

Alongside the new method reversed(), the rest come from the Deque interface and have now been moved to here. For unmodifiable implementations, the add and remove methods throw an UnsupportedOperationException, just like all other methods. The same thing also happens if we use addFirst() or addLast() on a SortedSet. Since the order cannot be specified from the outside, an UnsupportedOperationException is thrown here as well. For other sets, such as the LinkedHashSet, using addFirst() or addLast() will place the element at the corresponding location. If the element was already present, the old one is removed. This therefore corresponds semantically to moving the element. In the event of an empty collection, the get and remove methods throw a NoSuchElementException.

Similar to SequencedCollection, the interface SequencedMap contains methods for identical use, as shown in Listing 5. Here as well, the methods may throw an UnsupportedOperationException or a NoSuchElementException depending on the type.

public interface SequencedMap<K,V> extends Map<K,V> {

    SequencedMap<K,V> reversed();

    Map.Entry<K, V> firstEntry();
    Map.Entry<K, V> lastEntry();
    Map.Entry<K, V> pollFirstEntry();
    Map.Entry<K, V> pollLastEntry();

    V putFirst(K k, V v);
    V putLast(K k, V v);

    SequencedSet<K> sequencedKeySet();
    SequencedCollection<V> sequencedValues();
    SequencedSet<Map.Entry<K,V>> sequencedEntrySet();
}
Listing 5: SequencedMap

In parallel to this, the Collections class was extended with methods for changing an existing SequencedCollection into an unmodifiable one.

Pattern matching in switch and record patterns

Because it is possible since JDK 16 and JEP 394 when using instanceof to simultaneously bind the object being tested to a variable with the specific type (see Listing 6), this functionality is now being extended in stages and carried over to other places.

if (x instanceof String s && s.length() > 4) {
    System.out.println(
        "String %s is longer than 4 chars".formatted(s));
}
Listing 6: Pattern matching in instanceof with a guard clause

One example is the ability to use this pattern in the switch statement. This feature was already present in JDK 17 as an initial preview, and it has now passed through three additional previews (JEP 420 in JDK 18, JEP 427 in JDK 19 and JEP 433 in JDK 20) to become a final feature in JDK 21 with JEP 441.

Essentially, the idea is to create a switch block that selects the case label which specifies a class that is implemented or extended by the tested instance (see Listing 7). If multiple case labels match an instance, only the first one is ever evaluated. Because our instance x in this example is a String, only “Michael is a String” is output even though String also implements CharSequence. If we swapped the case labels here so that the CharSequence check came first, we would get a compilation error because the case label specifying String can never be reached. The latter case would be dominated by the former.

Object x = "Michael";
switch (x) {
    case String s:
        System.out.println("%s is a String".formatted(s));
        break;
    case CharSequence cs:
        System.out.println("%s is a CharSequence".formatted(cs));
        break;
    default:
        break;
}
Listing 7: Pattern Matching in switch

In addition, switch has been expanded for the null case so we no longer have to check for null before the switch statement, as shown in Listing 8. Furthermore, it is now also possible to use a when expression in conjunction with the case pattern matching to respond only to specific conditions (see Listing 9).

Object x = null;
switch (x) {
    case String s:
        System.out.println("%s is a String".formatted(s));
        break;
    case null:
        System.out.println("Null it is");
        break;
    default:
        break;
}
Listing 8: Pattern matching in switch with null
Object x = "Michael";
switch (x) {
    case CharSequence cs when cs.length() > 4:
        System.out.println("%s is a CharSequence".formatted(cs));
        break;
    case String s:
        System.out.println("%s is a String".formatted(s));
        break;
    default:
        break;
}
Listing 9: Pattern matching in switch with when

Alongside this new pattern matching option, the ability to destructure an object during the matching has been added in connection with records. This feature, known as record patterns, is now final in JDK 21 with JEP 440, having passed through two previews (JEP 405 in JDK 19 and JEP 432 in JDK 20). This destructuring is possible not only at the first level. It can also be used in more deeply nested records, as shown in Listing 10.

public static void main(String[] args) {
    Object o = new Point(0, 0, new Color(255, 255, 255));
    var value = switch (o) {
        case Point(int x, int y, Color(int r, int g, int b))
            -> x + y + r + g + b;
        default
            -> 0;
    };
    System.out.println(value);
}

record Color(int r, int g, int b) {}
record Point(int x, int y, Color color) {}
Listing 10: Record patterns

As a final addition on the topic of patterns, JEP 443 also introduced a preview feature in JDK 21 for including an underscore as the variable name for patterns that are not used, as seen in Listing 11. At the same time, it is now also possible to utilize the underscore for unused variables in a number of places. This includes, in particular, for loops, local assignments, exceptions in catch blocks, variables from try-with-resources and unused parameters of a lambda expression. Listing 12 shows how this works.

public static void main(String[] args) {
    Object o = new Point(0, 0, new Color(255, 255, 255));
    var value = switch (o) {
        case Point(_, _, Color(int r, _, _)) -> r;
        default -> 0;
    };
    System.out.println(value);
}

record Color(int r, int g, int b) {}
record Point(int x, int y, Color color) {}
Listing 11: Underscore for unused patterns
public static void main(String[] args) {
    try (var _ = new Bar(1); var _ = new Bar(2)) {
        var _ = new Object();
        System.out.println("Work");
    }
}

record Bar(int number) implements AutoCloseable {

    @Override
    public void close() {
        System.out.println("Bar[%s].close".formatted(number));
    }
}
Listing 12: Underscore for unused variables

The main advantages of this usage are that it is immediately clear on reading the code that the variable is not used elsewhere and that we can use the underscore multiple times in the same code. Previously, we had to make use of names such as ignored and then follow this with ignored2, and so on, if necessary.

Virtual threads and concurrency

In addition to pattern matching, a second highlight is virtual threads, which is now final in JDK 21 with JEP 444 after two previews (JEP 425 in JDK 19 and JEP 436 in JDK 20).

These now offer us a very lightweight alternative to the familiar classic threads. In this case, there is no one-to-one relationship to a native operating system thread as is the case with a Thread; rather, the thread is represented and scheduled only within the JVM.

This makes it possible to create thousands of threads (the JEP even speaks of millions) without hitting any limits. With the thread management handled by the JVM, it is possible for the JVM to act in a quasi non-blocking fashion, such as executing other virtual threads while one virtual thread is waiting for the network, which results in a better utilization of resources. This also means it is unnecessary to manage virtual threads in a pool. They are so lightweight that reuse is unnecessary, and a new virtual thread should be created for each task instead.

To start a virtual thread, you can use either a TaskExecutor or the new Thread.Builder API, as shown in Listing 13. Of course, it is also possible to debug the code in these threads, and they will show up in a thread dump as well. You won’t see the same level of detail here as for native threads, however, because thread dumps with several thousand virtual threads would quickly become too large.

// Thread.Builder API

Thread.ofVirtual()
    .name("ABC")
    .start(() -> System.out.println("Hello"));

// Executor

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> System.out.println("Hello"));
}
Listing 13: Creating virtual threads

During the implementation of these virtual threads, the question quickly arose as to how the previously used ThreadLocal mechanism should be handled within these threads. The current situation is that ThreadLocal can be used as before. Values set in the virtual thread are then visible to this thread but isolated from the native thread that is executing the virtual thread. Conversely, values set in the native thread are not visible from the virtual thread.

However, since the entire mechanism of ThreadLocal was never conceived for such a large quantity of threads and because the concept that they are alterable from anywhere and can have an infinite lifespan, especially for pooled threads, is not so easy to change, JDK 21 includes another preview feature for scoped values in JEP 446, which passed through the incubator stage in JDK 20 (JEP 429). As shown in Listing 14, scoped values allow us to set values which are then visible to all methods that are called within the scope. However, these are not carried over into new virtual threads. For this reason, Listing 15 throws a NoSuchElementException.

public class Scopes {

    static final ScopedValue<Integer> FOO = ScopedValue.newInstance();

    public static void main(String[] args) {
        ScopedValue.where(FOO, 1).run(() -> {
            log(); // 1

            ScopedValue.where(FOO, 2).run(() -> {
                log(); // 2

            });
            log(); // 1

        });
    }

    private static void log() {
        System.out.println(FOO.get());
    }
}
Listing 14: Using scoped values
ScopedValue.where(FOO, 1).run(() -> {
    log(); // 1

    ScopedValue.where(FOO, 2).run(() -> {
        log(); // 2

        try {
            Thread.ofVirtual().start(() -> {
                log(); // throws NoSuchElementException

            }).join();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    });
    log(); // 1

});
Listing 15: Scoped values in new threads

If we would like to obtain the set value in this scenario, we either have to bind it again or use the next preview feature, namely the structured concurrency API. After passing through two incubator versions, JEP 428 in JDK 19 and JEP 437 in JDK 20, this is now available as an initial preview with JEP 453.

The goal of this API is to use virtual threads with the assistance of scoped values to offer a simpler option for parallel programming than was previously available in the JDK. The core of the new API is the StructuredTaskScope, which we can use to create and coordinate subtasks, as demonstrated in Listing 16.

ScopedValue.where(FOO, 1).run(() -> {
    log(); // 1

    ScopedValue.where(FOO, 2).run(() -> {
        log(); // 2

        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            scope.fork(() -> { log(); return null; });
            scope.join().throwIfFailed(); // 2

        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    });
    log(); // 1

});
Listing 16: Using structured concurrency

The advantage here is that all coordination takes place at the point where we are waiting for both subtasks with join(). If this point executes without an exception, it is certain that both values will be available afterwards. If the calling thread is terminated or one of the two subtasks throws an exception, it is guaranteed that all subtasks still running will be terminated.

This termination is ensured by the ShutdownOnFailure shutdown policy we have selected. Alternatively, it is also possible to select ShutdownOnSuccess, in which case all subtasks will be terminated as soon as one of the subtasks has been completed successfully, or to create custom policies.

Because this structure means that the subtasks are also still connected, thread dumps can also include this connection, which will be helpful in a potential analysis.

What else?

In addition to the major topics discussed above, a number of intriguing JEPs have accumulated over the course of the last four releases that we won’t examine here in great detail, but which are still worth mentioning.

String templates were implemented in JDK 21 within the scope of JEP 430. These allow us to not only access variables within Strings but also to subject them to validation or escaping.

JEP 445, which is also present in JDK 21 as a preview feature, supports the goal of making it easier to get started with the language. This makes it possible to dispense with declaring the main method as static and even to leave out the arguments if they are not required. Furthermore, this JEP permits us, with some restrictions, to even dispense with the class of the main method, making void main() { System.out. println("Hallo"); } a valid Java program.

Since JDK 18, JEP 418 enables the use of alternative implementations for DNS resolution. The JDK still provides the identical implementation, however. Only the service provider interface (SPI) was defined within this JEP.

JDK 18 also deprecates the topic of “finalization” within JEP 421, which will be completely eliminated in the future. Anyone interested in testing whether that will lead to problems can launch their application with --finalization=disabled. No finalizers will then be executed at all. In the future, this option will initially become standard, and then the functionality will be entirely removed at a later stage.

JEP 439 in JDK 21 adds generations to the Z Garbage Collector (ZGC). This means that the ZGC can now collect objects with a short lifespan earlier and more frequently, allowing it to function more efficiently.

To prevent the dynamic loading of Java agents in the future, JDK 21 lays the initial groundwork with JEP 451. This change should improve security by ensuring that the code is not suddenly changed at runtime. Currently, however, this can still lead to problems, especially since tools for mocking still make use of the ability to dynamically load java agents at runtime in some places.

In JDK 21, the new vector API has entered its sixth incubator version with JEP 448. The goal is to optimally perform vector calculations on the CPU.

Finally, JDK 21 also contains JEP 442, the third preview version for accessing native functions and memory ranges. The objective here is to provide an improved successor to the Java Native Interface (JNI).

Conclusion

In this article, we have taken a detailed look at version 21 of the JDK, which was released on September 19th and is considered by many developers to be a long-term support release.

We examined a number of new features that have been added since the last LTS release, JDK 17. In addition to the switch to UTF-8 as the standard character encoding, the simple web server to assist with development and the option to use code snippets in Javadoc, three other major topics were covered.

With SequencedCollections, the collection API now contains types for expressing that a collection is expected to have or be returned with a defined order.

Pattern matching is now also supported in switch blocks, and the destructuring of records is possible here and within instanceof. In addition, the option to use an underscore to indicate unused patterns or variables will be available in the future. All of this supports the paradigm that Brian Goetz calls “data oriented programming”.

With virtual threads, scoped values and structured concurrency, JDK 21 also contains a slew of new features dedicated to the topic of parallelism. This allows us to better utilize existing hardware without switching over to a reactive model.

And the additional, still not entirely finalized topics, such as String templates, unnamed classes and instance main methods, the vector API and the new access to native functions and memory ranges, also show that the JDK is far from finished and that much is still happening from release to release.

We should also not forget to mention the many other small API improvements. The easiest way to discover these is to use the compare function offered by the Java Version Almanac for JDK 17 to 21. This will introduce you to concepts such as Character#isEmoji(int) and Duration#isPositive().