18 minutes
Java > 17 && <= 24
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.
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 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 orderSequencedSet
-> a set with a defined encounter orderSequencedMap
-> 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 enum
s 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.
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, _.
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 variablevar _ = 3;
-> unnamed variablecatch (NumberFormatException _) {
-> unnamed variablecase Circle(Point(var x, var y), var _) ->
-> unnamed pattern variablecase 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 thejava.util.stream.Stream
interface, which takes aGatherer
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.