The Latest from the Java World: JDK 22 Hands-On Insights for Developers

15.05.2024Tom Trapp
Tech Java JVM openjdk Cloud Computing DevOps Stay Tuned

Banner

After 21 comes? Exactly, 22! 💡 JDK 22 is the latest JDK update, which has been available since March 19, 2024.

In this TechUp we want to take a look at what’s new and what has changed.

Of course, you can find all code examples in our TechHub GitHub JDK 22 Repository.

If you don’t know the last LTS (Long Term Support) release JDK 21 yet, then take a look at my TechUp on JDK 21.

Now let’s take a look at some of the exciting JEP - JDK Enhancement Proposals - that are included in JDK 22.

Preview Features

Below we will look at some preview features that are included in JDK 22. These special features are not yet complete and may change or even be removed in future versions. They can be activated with the flag -enable-preview. Alternatively, IntelliJ, for example, recognizes the use of preview features and suggests setting the flag.

img.png

423: Region Pinning for G1

A very technical feature that is supposed to improve garbage collection. At this point I don’t want to go into it further, as it is very specific and not relevant for every developer. You can find more information here.

447: Statements before super(…) (Preview)

JEP-447 is a preview feature to place logic before the call to the super constructor.

In Java 8, the ability was introduced for constructors of subclasses to call the constructor of the superclass. However, this had to be done as the first statement in the constructor. With JEP-447, this restriction is lifted and it is now possible to place statements before the call to the super constructor.

Previously, it always had to look like this:

1
2
3
4
5
6
public class MyClass extends SuperClass {
    public MyClass() {
        super();
        // do something
    }
}

This results in some cumbersome calls, such as:

1
2
3
4
5
6
7
8
public class MyClass extends SuperClass {
    public MyClass(String name) {
        super();
        if (name == null) {
            throw new IllegalArgumentException("Name must not be null");
        }
    }
}

In this example, it can be seen that the superclass is unnecessarily initialized even though the name has not been checked yet. If the name is null, an exception would be thrown. The initialization of the superclass was unnecessary in this case.

With JEP-447, this can now look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class MyClass extends SuperClass {
	public MyClass(String name) {
		if (name == null) {
			throw new IllegalArgumentException("Name must not be null");
		} else {
			System.out.println(STR."Hello \{name}");
		}
		super();
	}
}

This was previously possible in a slimmed-down way, with so-called helper methods, but only on one line within the super constructor call.

This is not only practical for input validation, but also when the parameters need to be prepared for the super constructor.

For example, such logic would be easier to implement:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public FileReader(File file) throws IOException {
	String content = readFileAsString(file.getPath());
	if (content.isEmpty())
		throw new IllegalArgumentException("File content cannot be empty");

	int processedData = switch (content)
	{
		case String data when data.startsWith("XML") -> processXML(data);
		case String data when data.startsWith("JSON") -> processJSON(data);
		default -> handleDefaultFormat(content);
	};
	super(processedData);
}

Here it is nice to see that we first check the input parameter, then react accordingly and then, with this result, call the super constructor.

You can find the whole program in the repo!

It is important to note that only static fields and static methods can be called before the super constructor call. Actually logical, since the instance has not been created yet.

454: Foreign Function & Memory API

This new API allows you to call native functions and manage memory from within a Java application.

Again, we don’t want to go into JEP-454 in more detail, as it is a very specific feature and not relevant for every developer.

456: Unnamed Variables & Patterns

With JEP-456, unknown variables are introduced. 👻

This is about unused variables that can be marked with an underscore _. These variables can be used in a pattern matching statement without having to be explicitly used.

Let’s first look at an old example:

1
2
3
4
5
6
try {
    int i = Integer.parseInt(numberString);
    System.out.println(STR."Number: \{i}");
} catch (NumberFormatException ex) {
    System.out.println(STR."Bad number: \{numberString}");
}

In the code it can be seen that the exception ex is not used. With JEP-456 this can now look like this:

1
2
3
4
5
6
try {
    int i = Integer.parseInt(numberString);
    System.out.println(STR."Number: \{i}");
} catch (NumberFormatException _) {
    System.out.println(STR."Bad number: \{numberString}");
}

Admittedly, this is not the world now, but it shows that in the future it will be possible to mark unused variables and thus make the code more readable.

And it improves the Sonar rating if unused variables are no longer displayed as errors. 😉

This should now also be possible in other building blocks of Java, such as in switch statements or try-with-resources statements.

457: Class-File API (Preview)

JEP-457 is a preview feature that allows you to read and write the contents of class files. Specifically, it is also about parsing, generating and transforming Java class files.

The question arises for me, if and when do you want that? Dynamically generate new Java files at runtime? Or change Java files at runtime? Doesn’t that contradict the principle of Java that it is a statically typed language? And that the code is checked at compile time?

This is a preview feature, should this play a bigger role in future versions of Java, it will certainly be looked at again in more detail.

458: Launch Multi-File Source-Code Programs

This is about the Java Launcher automatically detecting which dependencies a multi-file source code program has and loading them automatically.

Thus, with JEP-458, a program that imports other classes can be started directly. The imported classes are then automatically compiled and loaded.

459: String Templates (Second Preview)

Not much has changed here since JDK 21. From my point of view still a cool feature, you can find all the information here in my TechUp on JDK 21.

The preview features are always implemented in different releases, so it’s not surprising that not much has happened here.

460: Vector API (Seventh Incubator)

JEP-460, the Vector API, is the seventh incubator release, believe it or not. This means that the release candidate is not yet finished and work is still being done on it. The Vector API was first introduced in JDK 16 and has been continuously developed since then.

What is it about? The API provides a standard for formulating vector calculations. This is particularly useful because special vector processors provide parallel processing of vector operations. In contrast to classical, scalar computation, this can lead to a massive performance increase, especially for data-intensive tasks.

According to the documentation, x64 and AArch64 CPUs are currently supported.

461: Stream Gatherers (Preview)

The Stream API, which was introduced 10 years ago with Java 8, is a very powerful tool in Java and has been continuously developed since then. With JEP-461, the Stream API is extended with so-called Gatherers.

Gatherers are a type of Collectors that allow streams to be collected and processed. Previously, it was only possible to collect and process streams when all elements of the stream were available. For example, different, custom-implemented GroupBy operations can be performed.

Let’s look at an example. We have a list of words and we want to always group them in groups of 4.

Previously, this could have been done as follows, for example:

1
2
3
4
5
6
7
List<String> words = List.of("Hello", "World", "Java", "Is", "Awesome", "And", "So", "Are", "You");

List<List<String>> groupedWords = words.stream()
		.collect(Collectors.groupingBy(word -> words.indexOf(word) / 4))
		.values()
		.stream()
		.toList();

Not really elegant and not directly obvious what happens here. With JEP-461 this will now be possible as follows:

1
2
3
4
5
List<String> words = List.of("Hello", "World", "Java", "Is", "Awesome", "And", "So", "Are", "You");

List<List<String>> groupedWords = words.stream()
		.gather(Gatherers.windowFixed(4))
        .toList();

Much simpler, with Gatherers.windowFixed(4) the list is grouped into groups of 4.

The output is the same for both examples:

1
[[Hello, World, Java, Is], [Awesome, And, So, Are], [You]]

It is important to mention here that Gathers are intermediate operations that can only be used in streams.

Basically, a Gather consists of four parts:

  • Integrator - the main function that takes a state, an element and a DownStream and returns a boolean
  • Initializer - the function that defines the initial state of the Gatherer, simply put, this is a Supplier
  • Finishers - the function that completes the state of the Gatherer and returns the result, technically a BiConsumer
  • Combiner - only needed if the Gatherer is used in parallel, here the state is merged

The Gatherers class offers other methods besides windowFixed.

Let’s look at a few examples of self-implemented Gatherers.

Simple Gatherer

Let’s look at a simple Gatherer where we only use an Integrator. Of course, we could also use a normal map operation for this, but it shows how Gatherer works.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void main()
{
	List<String> strings = List.of("a", "b", "c", "d", "e", "f", "g", "h", "i");

	System.out.println(
			strings.stream().gather(printUpper())
					.toList());
}

public static Gatherer<? super String, ?, String> printUpper()
{
	Gatherer.Integrator<Void, ? super String, String> integrator =
			(state, element, downstream) -> {
				downstream.push(element.toUpperCase());
				return true;
			};
	return Gatherer.ofSequential(integrator);
}

It can be seen that within our Integrator the elements are converted to uppercase and written to the stream. With return true we indicate that we want to continue. As soon as we return false, the stream is aborted.

Stateful Streams

We now have a list of unsorted numbers and we only want to have numbers in our stream that are larger than the numbers we have already seen.

The following program outputs [1, 2, 5, 6, 7, 11, 20].

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void main()
{
	List<Integer> numbers = List.of(1, 2, 5, 3, 2, 3, 4, 5, 6, 7, 5, 11, 8, 9, 10, 20);

	System.out.println(
			numbers.stream().gather(getOnlyIncreasingNumbers(Comparator.comparingInt(a -> a)))
					.toList());
}

public static <T> Gatherer<T, ?, T> getOnlyIncreasingNumbers(
		Comparator<T> comparator)
{
	Supplier<AtomicReference<T>> initializer = AtomicReference::new;
	Gatherer.Integrator<AtomicReference<T>, T, T> integrator =
			(state, element, downstream) -> {
				T largest = state.get();
				var isLarger = largest == null
						|| comparator.compare(element, largest) > 0;
				if (isLarger)
				{
					downstream.push(element);
					state.set(element);
				}
				return true;
			};
	return Gatherer.ofSequential(initializer, integrator);
}
  • First we create our list
  • Then we initialize a stream (Source), call our Gatherer (Intermediate) and collect the result (Terminal) and output it.
  • The Gatherer takes a Comparator that compares the numbers.
  • Then we create our Initializer, which returns an AtomicReference. This allows us to store the state.
  • Inside our Gatherer Integrator we check if the number is larger than the largest number so far. If this is the case, the number is written to the stream and stored as the new largest number.
  • The Gatherer always returns true because we always want to continue. If we returned false here, the stream would no longer process any further elements.
  • We create the Gatherer with Gatherer.ofSequential(initializer, integrator)

Not a simple example, but it shows how powerful Gatherer can be.

Stateful with Finisher

Let’s take the previous example again, we want to multiply the last number that is in the list by 2. We can’t do that in the Integrator because we don’t know if it’s the last number. So we have to do this in the Finisher.

The following program outputs [1, 2, 5, 6, 7, 11, 20, 40].

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static Gatherer<? super Integer, ?, Integer> getOnlyIncreasingNumbersWithFinisher(
		Comparator<Integer> comparator)
{
	Supplier<AtomicReference<Integer>> initializer = AtomicReference::new;
	Gatherer.Integrator<AtomicReference<Integer>, ? super Integer, Integer> integrator =
			(state, element, downstream) -> {
				Integer largest = state.get();
				var isLarger = largest == null
						|| comparator.compare(element, largest) > 0;
				if (isLarger)
				{
					downstream.push(element);
					state.set(element);
				}
				return true;
			};

	BiConsumer<AtomicReference<Integer>, Gatherer.Downstream<? super Integer>> finisher = (state, downstream) -> {
		Integer lastElement = state.get();
		downstream.push(lastElement * 2);
	};

	return Gatherer.ofSequential(initializer, integrator, finisher);
}

For this we now use a fully typed Gatherer, which takes a state, an element and a Downstream.

In the Finisher we see that we can access the last element and process it. We can also change the downstream if we want.

Exciting feature that extends the Stream API again and offers new possibilities.

462: Structured Concurrency (Second Preview)

JEP-462 is a preview feature that allows you to manage asynchronous operations in a structured way. This allows related tasks running in parallel in different threads to be grouped and managed together. This makes error handling and task cancellation easier, especially.

Let’s first look at a classic example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void main()
{
	ExecutorService executor = Executors.newFixedThreadPool(2);

	try
	{
		System.out.println("Starting the search for Tom");
		Future<String> futureTom = executor.submit(UserSearcher::findTom);

		System.out.println("Starting the search for Tim");
		Future<String> futureTim = executor.submit(UserSearcher::findTim);

		System.out.println("Something is running in the background...");

		String result = futureTom.get() + ", " + futureTim.get();

		System.out.println(result);
	}
	catch (Exception e)
	{
		e.printStackTrace();
	}
	finally
	{
		executor.shutdown();
	}
}

Both threads are started independently and run in parallel. The result is only merged when both threads are finished. There is no logical link between the two threads, if one thread fails, the other thread will continue to run.

New JEP-462 introduces the StructuredTaskScope class, which can be used in a try-with-resources block.

When starting, a Supplier is now defined and no longer a Future object.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void main()
{
	ExecutorService executor = Executors.newFixedThreadPool(2);

	try (var scope = new StructuredTaskScope.ShutdownOnFailure())
	{
		System.out.println("Starting the search for Tom");
		Supplier<String> tom = scope.fork(UserSearcher::findTom);

		System.out.println("Starting the search for Tim");
		Supplier<String> tim = scope.fork(UserSearcher::findTim);

		System.out.println("Something is running in the background...");

		scope.join()            // Join both subtasks
				.throwIfFailed();  // ... and propagate errors

		System.out.println("Something is running still in the background...");

		String result = tom.get() + ", " + tim.get();
		System.out.println(result);
	}
	catch (Exception e)
	{
		e.printStackTrace();
	}
	finally
	{
		executor.shutdown();
	}
}

It’s nice to see that with scope.join() both subtasks are merged and with throwIfFailed() errors are propagated. So an error in one of the subtasks would cause the other subtask to be aborted if it is still running.

The output of the logs is identical, it gets exciting when our subtasks fail.

Error Handling

Now let’s assume that our two methods are implemented as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	public static String findTom() throws InterruptedException
	{
		Thread.sleep(5000);
		System.out.println("Tom found");
		return "Tom";
	}

	public static String findTim()
	{
		throw new RuntimeException("Tim not found");
	}

Tom is found after 5 seconds, Tim is not found and throws an exception.

Our output in the old variant is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Starting the search for Tom
Starting the search for Tim
Something is running in the background...
Tom found
java.util.concurrent.ExecutionException: java.lang.RuntimeException: Tim not found
	at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
	at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191)
	at OldStyle.main(OldStyle.java:21)
Caused by: java.lang.RuntimeException: Tim not found
	at com.bnova.techhub.jep462.UserSearcher.findTim(UserSearcher.java:14)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1570)

It’s nice to see that Tom was found, Tim wasn’t, and the error is output correctly. However, the program takes the full 5 seconds to detect the error.

With the new variant, the error is detected immediately and the program aborts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Starting the search for Tom
Starting the search for Tim
Something is running in the background...
java.util.concurrent.ExecutionException: java.lang.RuntimeException: Tim not found
	at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1323)
	at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1300)
	at NewStyle.main(NewStyle.java:23)
Caused by: java.lang.RuntimeException: Tim not found
	at com.bnova.techhub.jep462.UserSearcher.findTim(UserSearcher.java:14)
	at java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:892)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

Process finished with exit code 0

Here it is nice to see that Tom is not found because the previously linked task fails and therefore the thread is aborted immediately.

However, the feature offers even more possibilities, such as certain custom shutdown policies or the processing of parallel results in a stream.

Cool feature that improves error handling in parallel Java programs again. 🚀 I’m curious to see if and how this feature will find its way into libraries like Spring or Quarkus.

463: Implicitly Declared Classes and Instance Main Methods (Second Preview)

Nothing has changed here since JDK 21. From my point of view still a helpful feature, you can find all the information here in my TechUp on JDK 21.

464: Scoped Values (Second Preview)

And last but not least, JEP-464 - Scoped Values. This is the second preview version, which allows you to store and retrieve values in a specific scope. Specifically, a new ScopedValue type is introduced, which is immutable and thus makes it easier to share information with child frames in the same thread or even with sub-threads.

In the past, you had to use ThreadLocal variables to access, for example, Request information if it was not part of the method signature.

Now this should be possible via ScopedValue, which can be created as an instance variable. Let’s look at a simple example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private final static ScopedValue<String> NAME
		= ScopedValue.newInstance();

void main()
{
	ScopedValue.where(NAME, "Tom").run(() -> greet());
}

void greet()
{
	System.out.println(STR."Hello \{NAME.get()}!");
}

First we create a ScopedValue that stores the name. In our main method we set the value to Tom and then call the greet method. Here it is nice to see that the call to greet takes place in the same scope and can therefore access the value Tom. In the method we can then access the ScopedValue and read the value.

Now it gets exciting, we adjust the ScopedValue!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private final static ScopedValue<String> NAME
		= ScopedValue.newInstance();

void main()
{
	ScopedValue.where(NAME, "Tom").run(() -> greet());
}

void greet()
{
	System.out.println(STR."Hello \{NAME.get()}!");
	ScopedValue.where(NAME, "Tim").run(() -> goodbye());
	goodbye();
}

void goodbye()
{
	System.out.println(STR."GoodBye \{NAME.get()}!");
}

What do you think will come out? We have a new method goodbye that reads the value from the ScopedValue and outputs it. In the greet method, after greeting Tom, we set the value to Tim and then call the goodbye method. Then we call the goodbye method again.

The output is quite exciting, and actually quite logical:

1
2
3
Hello Tom!
GoodBye Tim!
GoodBye Tom!

First Hello Tom! is output because we set the value to Tom. The value is valid for the scope we are in. But then we start a new scope and set this value to Tim. In this scope the value Tim is valid. Then we call goodbye again without starting a new scope. Therefore, the value Tom is output here.

Another possibility that the ScopedValue API offers us is to receive values from the corresponding, scoped call.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private final static ScopedValue<String> NAME = ScopedValue.newInstance();

void main() throws Exception
{
	var name = ScopedValue.where(NAME, "Tom").call(() -> getName());
	System.out.println(STR."The name is \{name}");
}

String getName()
{
	return NAME.get().toUpperCase();
}

Here we see that our getName method reads the value from the ScopedValue and returns it in uppercase. In our main method we call the getName method and store the result in a variable, this variable is then output.

Again, we have the possibility to start and end multiple scopes to set different values.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private final static ScopedValue<String> NAME = ScopedValue.newInstance();

void main() throws Exception
{
	var name = ScopedValue.where(NAME, "Tom").call(() -> getName());
	var anotherName = ScopedValue.where(NAME, "Tim").call(() -> getName());
	System.out.println(STR."The name is \{name}");
	System.out.println(STR."The another name is \{anotherName}");
}

String getName()
{
	return NAME.get().toUpperCase();
}

Of course, we can also use more than just one ScopedValue.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private final static ScopedValue<String> NAME = ScopedValue.newInstance();
private final static ScopedValue<String> AGE = ScopedValue.newInstance();

void main() throws Exception
{
	ScopedValue.where(NAME, "Tom").where(AGE, "27").run(() -> getInfo());
}

void getInfo()
{
	System.out.println(STR."Hello \{NAME.get()}!");
	System.out.println(STR."Age \{AGE.get()}!");
}

Cool, right? This is a very powerful feature that makes working with threads and scopes even easier. 🌶️

Conclusion

JDK 22 is the first release after an LTS release and as expected brings a few helpful features that make developing in Java even easier. Initially I was skeptical whether the preview features are really that helpful, but I have to say that I am positively surprised. Especially the Stream Gatherer and the Scoped Values are very powerful features that simplify developing in Java again. But also the Structured Concurrency and the statements before the super constructor are not to be underestimated and can prove to be helpful.

The next TechUp on JDK 23 will certainly be exciting, I’m curious to see what awaits us there, stay tuned!

This techup has been translated automatically by Gemini

Tom Trapp

Tom Trapp – Problemlöser, Innovator, Sportler. Am liebsten feilt Tom den ganzen Tag an der moderner Software und legt viel Wert auf objektiv sauberen, leanen Code.