Meet Java 24

Java 24 is not a LTS version. The last LTS version was Java 21, released in September 2023.

In this post, I will tackle some of the new features and improvements that Java 24 brings to the table. Note that some of them might have been introduced in previous versions, but for the sake of completeness, I will include them here.

In my post about Java 17, I mentioned some of the noteworthy features introduced in that version, in this post I will focus on the new features introduced since then.

Note that I will not cover all the features, but only the ones I find interesting. For a complete list of features feel free to check the JDK Project page.

Java 18

UTF-8 by default

This can be summarized as:

Specify UTF-8 as the default charset of the standard Java APIs. With this change, APIs that depend upon the default charset will behave consistently across all implementations, operating systems, locales, and configurations.

JEP 400: UTF-8 by Default

The implementation of Charset.defaultCharset() will return the UTF-8 charset by default, unless overridden with the system property file.encoding. If the value is COMPAT, the default charset is derived from the native.encoding system property, which depends on the locale and charset of the underlying operating system.

A quick way to see the default charset of the current JDK is with the following command:

java -XshowSettings:properties -version 2>&1 | grep file.encoding

Simple Web Server

The Simple Web Server is a minimal HTTP server for serving a single directory hierarchy, and it can be used via the dedicated command-line tool jwebserver or programmatically via its API.

jwebserver
Binding to loopback by default. For all interfaces use "-b 0.0.0.0" or "-b ::".
Serving <directory> and subdirectories on 127.0.0.1 port 8000
URL http://127.0.0.1:8000/

The same can be achieved programmatically with the following code:

var server = SimpleFileServer.createFileServer(new InetSocketAddress(8080),
        Path.of("/"),
        OutputLevel.VERBOSE);
server.start();

For more information, check the JEP 408: Simple Web Server.

Code Snippets in JavaDoc

JEP 413: Code Snippets in Java API Documentation introduces a new tag, @snippet, that allows you to include code snippets in your JavaDoc comments.

Here are some examples of how to use it. The JavaDoc comment of the class A includes an inline code snippet, and two external code snippets. The external code snippets are the entire class B and the named region of the class B called say-howdy.

/**
 *
 * {@snippet :
 *
 * System.out.println("This is a polite class.");
 *
 * }
 *
 * {@snippet class = B}
 *
 * {@snippet class = B region = say-howdy}
 *
 */
public class A {
    public static void main(String[] args) {
        System.out.println("Hello, A!");
    }
}
public class B {
    public static void main(String[] args) {
        System.out.println("Hello, B!");
        // @start region = say-howdy
        System.out.println("Howdy, B!");
        // @end
    }
}

In order to generate the JavaDoc with the code snippets, we need to run the following command:

javadoc -d docs --snippet-path . A.java

This will generate the JavaDoc in the docs directory. Note the --snippet-path option, which specifies the path to the code snippets, in this case the current source directory.

This is how it looks like:

Java Doc of class A

Java 19

Not to be dismissive of the work done in Java 19, but I will not cover it here, as it does not introduce any final features and of personal interest. Feel free to check the Java 19 release notes for more information.

Java 20

Idem. Please check the Java 20 release notes for more information.

Java 21

This is a LTS version, released in September 2023. Check the Java 21 release notes.

Sequenced Collections

JEP 431: Sequenced Collections introduces new interfaces to represent collections with a defined encounter order. This means that each such collection has a well-defined first element, second element, and so on, up to the last element.

The new interfaces are:

  • SequencedCollection -> a collection with a defined encounter order
  • SequencedSet -> a set with a defined encounter order
  • SequencedMap -> a map with a defined encounter order

The diagrams below show how the new interfaces fit into the existing collections type hierarchy.

graph TD
    Collection --- Set
    Collection --- SequencedCollection
    Collection --- Queue

    Set --- SequencedSet
    SequencedSet --- SortedSet
    SortedSet --- NavigableSet
    SequencedSet --- LinkedHashSet
    Set --- SortedSet

    SequencedCollection --- List
    SequencedCollection --- Deque
    SequencedCollection --- SequencedSet

    Queue --- Deque

    Map --- SequencedMap
    SequencedMap --- SortedMap
    SequencedMap --- LinkedHashMap
    SortedMap --- NavigableMap

    classDef highlight stroke:orange,stroke-width:2px;
    class SequencedCollection highlight;
    class SequencedSet highlight;
    class SequencedMap highlight;

Record Patterns

Record patterns enable the deconstruction of records into their components. This allows you to extract the values of a record in a more concise way. Check the following example:

record Point(int x, int y) {}
record Circle(Point center, int radius) implements Shape {}
record Line(Point start, Point end) implements Shape {}
sealed interface Shape permits Circle, Line {}

private void printShape(Shape s) {
    // the old way
    if (s instanceof Circle) {
        var circle = (Circle) s;
        var txt = "Center: (%d, %d) Radius: %d".formatted(
                circle.center().x(),
                circle.center().y(),
                circle.radius());
        System.out.println(txt);
    }

    // the new way using record patterns
    if (s instanceof Circle(Point(int x, int y), int radius)) {
        var txt = "Center: (%d, %d) Radius: %d".formatted(x, y, radius);
        System.out.println(txt);
    }

    // same as before, but using `var`
    if (s instanceof Circle(Point(var x, var y), var radius)) {
        var txt = "Center: (%d, %d) Radius: %d".formatted(x, y, radius);
        System.out.println(txt);
    }

    // an exaustive(note that `Shape` is sealed) switch expression that makes use of record patterns
    var txt1 = switch (s) {
        case Circle(Point(var x, var y), var radius) -> "Center: (%d, %d) Radius: %d".formatted(x, y, radius);
        case Line(Point(var startX, var startY), Point(var endX, var endY)) ->
            "Start: (%d, %d) End: (%d, %d)".formatted(startX, startY, endX, endY);
    };
    System.out.println(txt1);
}

var circle = new Circle(new Point(1, 2), 3);
var line = new Line(new Point(7, 7), new Point(77, 77));
printShape(circle);
printShape(line);

Running the above code will produce the following output:

Center: (1, 2) Radius: 3
Center: (1, 2) Radius: 3
Center: (1, 2) Radius: 3
Center: (1, 2) Radius: 3
Start: (7, 7) End: (77, 77)

Pattern Matching for Switch

The example above, besides showing the new record patterns, also shows the new pattern matching for switch expressions. This allows you to use patterns in switch expressions, making them more powerful and expressive.

var txt1 = switch (s) {
    case Circle(Point(var x, var y), var radius) -> "Center: (%d, %d) Radius: %d".formatted(x, y, radius);
    case Line(Point(var startX, var startY), Point(var endX, var endY)) ->
        "Start: (%d, %d) End: (%d, %d)".formatted(startX, startY, endX, endY);
};

Let’s see another example:

import java.util.regex.Pattern;

public class AboutSwitchPatternMatching {
    public static void main(String[] args) {
        var colors = "red, blue, green,yellow, RED, Blue, grEEn, YELLow";
        var pattern = Pattern.compile(",\\s*");
        pattern.splitAsStream(colors).map(AboutSwitchPatternMatching::translateColorToGerman)
                .forEach(System.out::println);
        System.out.println(translateColorToGerman(null));
        System.out.println(translateColorToGerman("purple"));
    }

    private static String translateColorToGerman(String colorInEnglish) {
        return switch (colorInEnglish) {
            // string matching ignoring case
            case String s when s.equalsIgnoreCase("red") -> "rot";
            case String s when s.equalsIgnoreCase("blue") -> "blau";
            case String s when s.equalsIgnoreCase("green") -> "gruen";
            case String s when s.equalsIgnoreCase("yellow") -> "gelb";
            // `null` case must be handled separately
            case null -> "Unsupported color:" + colorInEnglish;
            // to make `switch` exhaustive, we need a `default` case
            default -> "Unsupported color:" + colorInEnglish;
            // Note: the two lines above can be combined into one:
            // case null, default -> "Unsupported color:" + colorInEnglish;
        };
    }
}

While the example above works fine, using enums will improve the code substantially.

enum Color {
    RED, BLUE, GREEN, YELLOW, PURPLE

}
// the main code changes slightly
var colors = "red, blue, green,yellow, RED, Blue, grEEn, YELLow";
var pattern = Pattern.compile(",\\s*");
pattern.splitAsStream(colors)
        // otherwise mapping to `Color` will fail for some values
        .map(String::toUpperCase)
        .map(Color::valueOf)
        .map(AboutSwitchPatternMatching::translateColorToGerman)
        .forEach(System.out::println);
System.out.println(translateColorToGerman(null));
System.out.println(translateColorToGerman(Color.PURPLE));

private static String translateColorToGerman(Color colorInEnglish) {
    return switch (colorInEnglish) {
        // this is now an exhaustive `switch`
        case Color.RED -> "rot";
        case Color.BLUE -> "blau";
        case Color.GREEN -> "gruen";
        case Color.YELLOW -> "gelb";
        case Color.PURPLE -> "lila";
        case null -> "Unsupported color:" + colorInEnglish;
    };
}

Another important point to keep in mind is case label dominance:

Object str = "this is a string";
var res = switch (str) {
    case CharSequence cs -> "A sequence of length: " + cs.length();
    case String s -> "A string: " + s; // COMPILATION ERROR: this case label is dominated by one of the preceding case labels, i.e. a `String` is a `CharSequence`
    default -> "Something else";
};
System.out.println(res);

Switching the order of the case labels fixes the compilation error.

Note that changing the definition of str, from Object to String, leads to other compilation errors, duplicate unconditional pattern, for the second case label, and switch has both an unconditional pattern and a default label, for the default label. We now need to remove the default label, and one of the remaining two case labels. By this time, our switch would have only one case label.

Virtual Threads

JEP 444: Virtual Threads introduces Virtual Threads, maybe the most important feature in Java 21. Make sure to go through the JEP, as I will not cover all the aspects.

Virtual threads are lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications.

JEP 444: Virtual Threads

Before seeing some code let’s go through some buzzwords related to this feature:

  • virtual thread -> an instance of java.lang.Thread that is not tied to a particular OS thread
  • M:N scheduling -> virtual threads employ M:N scheduling, where a large number (M) of virtual threads is scheduled to run on a smaller number (N) of OS threads. Other types of scheduling include 1:1, for platform threads, and M:1 for Java’s green threads, that all shared one OS thread
  • structured concurrency -> tries to simplify multithreading programming, by treating multiple tasks running in different threads as a single unit of work
  • platform thread -> an instance of java.lang.Thread implemented in the traditional way, as a thin wrapper around an OS thread
  • carrier thread -> the platform thread to which the scheduler assigns a virtual thread is called the virtual thread’s carrier. A VT can be scheduled on different carriers over the course of its lifetime
  • mounting and unmounting -> to run code in a virtual thread, the JDK’s virtual thread scheduler assigns the virtual thread for execution on a platform thread by mounting the virtual thread on a platform thread. A VT can unmount from its carrier, at which point the platform thread is free and can carry a different VT
  • pinning -> a VT is pinned to its carrier when it executes code insider a synchronized block or method, or when it executes a native method or a foreign function. This can hinder scalability, but it does not make an application incorrect
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;

public class AboutVirtualThreads {
    public static void main(String[] args) throws InterruptedException {

        // the work to be done
        Runnable r = () -> {
            var tName = Thread.currentThread().getName();
            var isVirtual = Thread.currentThread().isVirtual();
            System.out.println("Hello from " + tName + ". Am I virtual? " + isVirtual);
        };

        // using a builder via the `.ofVirtual()`
        var t1 = Thread.ofVirtual().name("my-virtual-thread-1").start(r);
        t1.join();

        // convenient method for creating a VT and scheduling it to execute
        var t2 = Thread.startVirtualThread(r);
        t2.setName("my-virtual-thread-2");
        t2.join();

        // using a thread factory to create a VT for each task submitted
        ThreadFactory tf = Thread.ofVirtual().name("my-virtual-thread-", 3).factory();
        try (var executor = Executors.newThreadPerTaskExecutor(tf)) {
            executor.submit(r);
        }

        // executor that starts a new VT for each task
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            executor.submit(r);
        }

        // creates a platform thread using the new API
        Thread t4 = Thread.ofPlatform().name("my-platform-thread-1").start(r);
        t4.join();
    }
}

The code from above produces the following output:

Hello from my-virtual-thread-1. Am I virtual? true
Hello from my-virtual-thread-2. Am I virtual? true
Hello from my-virtual-thread-3. Am I virtual? true
Hello from . Am I virtual? true
Hello from my-platform-thread-1. Am I virtual? false

The main API differences between virtual and platform threads are:

  • the public Thread constructors cannot create virtual threads
  • virtual threads are always daemon threads, they cannot be changed to be non-daemon
  • virtual threads have a fixed priority, Thread.NORM_PRIORITY, this priority cannot be changed
  • virtual threads are not active members of thread groups

Here are some guidelines to keep in mind when using virtual threads:

  • one thread per task: as virtual threads are lightweight, there is no need to reuse of pool them, just create a new one for each task
  • blocking I/O inside a synchronized block can pin a virtual thread - this limitation has been removed in Java 24, via JEP 491: Synchronize Virtual Threads without Pinning
  • minimize thread-local usage: as VTs can be in the millions, excessive use of thread-local variables can lead to memory issues
  • VTs are most useful when blocking I/O or some other blocking operations, BlockingQueue.take(), are involved, i.e. the workload is not CPU-bound

Java 22

Foreign Function & Memory API

JEP 454: Foreign Function & Memory API introduces a new API that allows Java programs to interoperate with code and data outside of the Java runtime. This includes calling native functions and accessing native memory.

There is a lot going on in this JEP, so I will not cover it all. Instead, I will show you a simple example of how to use it. Feel free to check the JEP’s page.

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;

public class StrlenExample {
    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();
        SymbolLookup stdlib = linker.defaultLookup();

        MemorySegment strlenFunc = stdlib.find("strlen")
                .orElseThrow(() -> new RuntimeException("strlen not found"));

        FunctionDescriptor strlenDesc = FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS);

        MethodHandle strlen = linker.downcallHandle(strlenFunc, strlenDesc);

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateFrom("Hello, FFM!");

            long length = (long) strlen.invoke(cString);
            System.out.println("Length: " + length);
        }
    }
}

The code uses Java 22’s Foreign Function & Memory (FFM) API to call the native C strlen function, which calculates the length of a C-style string. It first obtains a linker and locates the strlen symbol from the standard C library. Then, it defines the function signature using a FunctionDescriptor, creates a method handle to invoke it, and allocates native memory for the input string using arena.allocateFrom("Hello, FFM!"). Finally, it calls the native strlen function and prints the resulting length. The use of Arena ensures safe memory management by automatically freeing the memory when done.

Running the code with the command below will print the length of the string:

java --enable-native-access=ALL-UNNAMED StrlenExample.java
Length: 11

The --enable-native-access=ALL-UNNAMED flag is necessary to allow the unnamed module (i.e., code not in a named module) to access native code.

Unnamed Variables and Patterns

JEP 456: Unnamed Variables & Patterns summarizes this enhancement as follows:

Enhance the Java programming language with unnamed variables and unnamed patterns, which can be used when variable declarations or nested patterns are required but never used. Both are denoted by the underscore character, _.

JEP 456: Unnamed Variables & Patterns

Let’s see some examples:

import java.util.List;

public class AboutUnnamedVarsAndPatterns {
    public static void main(String[] args) {

        var words = List.of("one", "two", "three");
        var counter = 0;
        // we do not do anything with a word
        for (String _ : words) {
            counter++;
        }
        System.out.println(counter);

        var a = 1;
        var b = 2;
        // normally, the next line would be removed, in this case it illustrates a point
        var _ = 3;
        System.out.println(a + b);

        var notANumber = "boom";
        try {
            int _ = Integer.parseInt(notANumber);
        } catch (NumberFormatException _) {
            System.out.println("Bad number: " + notANumber);
        }

        System.out.println(generateText(new Circle(new Point(0, 0), 1)));
        System.out.println(generateText(new Line(new Point(1, 1), new Point(2, 2))));

    }

    private static String generateText(Shape s) {
        return switch (s) {
            // an example of unnamed pattern variable: `var _`, as we are not interested in
            // the circle's radius
            case Circle(Point(var x, var y), var _) -> "Center: (%d, %d)".formatted(x, y);

            // as we are not interested in the second point we can ignore it completely,
            // this is an example of a unnnamed pattern
            case Line(Point(var startX, var startY), _) ->
                "Start: (%d, %d)".formatted(startX, startY);
        };
    }

    record Point(int x, int y) {
    }

    sealed interface Shape permits Circle, Line {
    }

    record Circle(Point center, int radius) implements Shape {
    }

    record Line(Point start, Point end) implements Shape {
    }
}

To summarize, we have:

  • for (String _ : words) { -> unnamed variable
  • var _ = 3; -> unnamed variable
  • catch (NumberFormatException _) { -> unnamed variable
  • case Circle(Point(var x, var y), var _) -> -> unnamed pattern variable
  • case Line(Point(var startX, var startY), _) -> -> unnamed pattern

Launch Multi-File Source-Code Programs

JEP 458: Launch Multi-File Source-Code Programs enhances the java application launcher to be able to run a program supplied as multiple files of Java source code.

// File: First.java
public class First {
    void run() {
        System.out.println(First.class.getName());
    }
}

// File: Second.java
public class Second {
    void run() {
        System.out.println(Second.class.getName());
    }
}

// File: JEP458.java
public class JEP458 {
    public static void main(String[] args) {

        var first = new First();
        first.run();
        var second = new Second();
        second.run();
    }
}

Running java JEP458.java will print:

First
Second

Java 23

Markdown Documentation Comments

JEP 467: Markdown Documentation Comments introduces a new way to write documentation comments using Markdown syntax.

Let’s have a look at the following example:

public class JEP467 {
    /// ## Start of documentation
    ///
    /// This _documentation_ was written in Markdown.
    ///
    /// - `This`
    /// - is
    /// - cool.
    ///
    /// Simple tables are supported:
    ///
    /// | Latin | Greek |
    /// |-------|-------|
    /// | a     | alpha |
    /// | b     | beta  |
    ///
    /// This is some Java code:
    ///
    /// ```java
    /// System.out.println("Mind blowing");
    /// ```
    ///
    /// ## End of documentation
    public static void main(String[] args) {
        System.out.println(greet("Vlad"));
    }

    /// This method builds a greeting and gives it back.
    ///
    /// @param name the name to use in the greeting
    /// @return a greeting as a string
    public static String greet(String name) {
        return "Hello, %s!".formatted(name);
    }
}

Note the use of the /// prefix for the documentation comments.

Give it a run with the following command:

javadoc -d docs JEP467.java

Java 24

Class-File API

JEP 484: Class-File API provides a standard API for parsing, generating, and transforming Java class files. Take a look at the JEP for more information, as I will not cover it here.

Stream Gatherers

JEP 485: Stream Gatherers enhances the Stream API to support custom intermediate operations, allowing stream pipelines to transform data in ways that are not easily achievable with the existing operations.

The core concept is a gatherer, which represents a transform of the elements of a stream. Gatherers can be used to transform elements in a one-to-one, one-to-many, many-to-one, or many-to-many fashion. They can also track previously seen elements in order to influence the transformation of subsequent elements.

From a technical point of view, there are three elements involved in this API:

  • the java.util.stream.Gatherer interface, which models a gatherer
  • the gather method in the java.util.stream.Stream interface, which takes a Gatherer as an argument
  • the java.util.stream.Gatherers class, which provides a set of built-in gatherers

The java.util.stream.Gatherers class introduces the following built-in gatherers:

  • fold - performs an ordered reduction-like transformation
  • mapConcurrent - executes a mapping function with a configured level of max concurrency, using virtual threads, preserving the ordering of the stream
  • scan - performs an incremental accumulation starting from an initial value, and each subsequent value being obtained by applying the given function to the current value and the next input element
  • windowFixed - gathers elements into a window of a fixed size
  • windowSliding - gathers elements into windows of a given size, where each subsequent window includes all elements of the previous window, except for the least recent

Let’s see now some examples:

import java.util.List;
import java.util.stream.Gatherers;
import java.util.stream.Stream;

public class AboutGatherers {
    public static void main(String[] args) {
        List<List<Integer>> result1 =
                Stream.of(1, 2, 3, 4, 5, 6, 7, 8).gather(Gatherers.windowFixed(3)).toList();
        System.out.println("result1 = " + result1);

        List<List<Integer>> result2 =
                Stream.of(1, 2, 3, 4, 5, 6, 7, 8).gather(Gatherers.windowSliding(2)).toList();
        System.out.println("result2 = " + result2);

        List<String> result3 =
                Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
                        .gather(
                                Gatherers.scan(() -> "", (string, number) -> string + number)
                        )
                        .toList();
        System.out.println("result3 = " + result3);

        var result4 = Stream.iterate(1, n -> n + 1).limit(9).gather(
                Gatherers.mapConcurrent(3, n -> n * n)
        ).toList();
        System.out.println("result4 = " + result4);

        String result5 =
                Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
                        .gather(
                                Gatherers.fold(() -> "", (string, number) -> string + number)
                        ).findFirst().orElse("");
        System.out.println("result5 = " + result5);
    }
}

The output of the code above is:

result1 = [[1, 2, 3], [4, 5, 6], [7, 8]]
result2 = [[1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 8]]
result3 = [1, 12, 123, 1234, 12345, 123456, 1234567, 12345678, 123456789]
result4 = [1, 4, 9, 16, 25, 36, 49, 64, 81]
result5 = 123456789

We can also write our own gatherers. The following example shows how to implement a custom gatherer that implements the distinct by length operation. This means that we will only keep the first occurrence of each element, based on its length.

public static Gatherer<String, Set<Integer>, String> distinctByLength() {
    return Gatherer.ofSequential(
            HashSet::new,
            (seen, element, downstream) -> {
                if (downstream.isRejecting()) {
                    return false;
                }
                if (seen.add(element.length())) {
                    downstream.push(element);
                }
                return true;
            }
    );
}

Using the custom gatherer is as simple as:

var distinctByLength = Stream.of("a", "bc", "de", "fgh").gather(distinctByLength()).toList();
System.out.println(distinctByLength);

The output of the code above is:

[a, bc, fgh]

Have a look at the Gatherer interface, especially at the ofSequential and of methods, to see in more detail how to implement your own gatherers. In the example above, we used the ofSequential method, which is a convenience method, that calls the of method, passing in the initializer and the integrator. The combiner and the finisher are the default ones.

Synchronized Virtual Threads without Pinning

JEP 491: Synchronize Virtual Threads without Pinning fixes the issue of pinning virtual threads when using synchronized blocks or methods. This means that we can now use synchronized blocks and methods without pinning the virtual thread to its carrier. Check the JEP’s page for the nitty-gritty details.

Conclusion

The Java language is evolving like never before, and the features I touched upon in this post are just a small part of the whole picture. Make sure to check the release notes for each version for a complete picture. The language itself is becoming more expressive and powerful and tackles areas that were previously the main domain of other languages. This is a good thing! It’s a good time to be a Java developer.

References