JDK 21, released on September 19th, 2023, is the latest long-term support (LTS) release, following the previous LTS release, JDK 17. In this article, we will explore the newly introduced features of JDK 21.
Here is the list of new capabilities and interesting features of JDK 21:
Virtual Threads — Project Loom
Sequenced Collections
Record Patterns — Project Amber
Pattern Matching for switch
String Templates (Preview)
Unnamed Patterns and Variables (Preview)
Unnamed Classes and Instance Main Methods (Preview)
Scoped Values (Preview)
Structured Concurrency (Preview)
Virtual Threads
Virtual threads feel like normal threads from a Java code perspective, but they are not mapped 1:1 to OS/platform threads. It is M:N mapping from virtual threads to carrier threads and thus to OS threads.
There is a pool of so-called carrier threads onto which a virtual thread is temporarily mapped (“mounted”). As soon as the virtual thread encounters a blocking operation, the virtual thread is removed (“unmounted”) from the carrier thread, and the carrier thread can execute another virtual thread (a new one or a previously blocked one).
(The carrier thread pool is a ForkJoinPool)
Some of advantageous of virtual threads:
- Improves application throughput
- Improves application availability
- Reduces memory consumption
Creating a Virtual Thread
To create a virtual thread, we can use the Thread.ofVirtual() factory method with passing a runnable.
1. Thread.ofVirtual().start(Runnable);
2. Thread.ofVirtual().unstarted(Runnable);
If you want the virtual thread to start immediately, you can use start()
method, and it will execute the Runnable passed to the start()
immediately.
If you do not want the virtual thread to start immediately, you can use the unstarted()
method instead.
Create ExecutorService that uses virtual threads
we only need to replace newFixedThreadPool
with newVirtualThreadPerTaskExecutor
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
System.out.println(Thread.currentThread().getName())
});
executor.shutdown();
}
}
SequencedCollection
Sequenced collection provides us with methods having a defined encounter order for accessing first and last elements and iterating in
reverse order.
It means we can add, retrieve, or remove elements at both ends of the collection.
public interface SequencedCollection<E> extends Collection<E> {
default void addFirst(E e) { ... }
default void addLast(E e) { ... }
default E getFirst() { ... }
default E getLast() { ... }
default E removeFirst() { ... }
default E removeLast() { ... }
SequencedCollection<E> reversed();
}
As we can see, all methods except reversed() are default methods and provide a default implementation.
This means that existing collection classes such as ArrayList and LinkedList can implement this interface without changing their code.
ArrayList<Integer> list = new ArrayList<>();
list.add(1); // [1]
list.addFirst(0); // [0, 1]
list.addLast(2); // [0, 1, 2]
list.getFirst(); // 0
list.getLast(); // 2
list.reversed(); // [2, 1, 0]
SequencedSet
The SequencedSet interface can be useful for a Set with ordered elements, especially if you have to perform certain operations such as retrieving or removing an element at the first or last index.
It also provides a method for reversing the elements as well.
You also need to know that the comparison of two SequencedSet objects is the same as other types of Set which doesn’t depend on the element order.
interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {
SequencedSet<E> reversed();
}
The LinkedHashSet is an implementation of Set that implements the SequencedSet interface.
Therefore, you can use a LinkedHashSet to create a SequencedSet.
Other implementations of Set such as HashSet and TreeSet do not implement the interface.
Let’s explore some examples to demonstrate how we can access the first and last elements, as well as how to use the reverse function:
SequencedSet<String> values = new LinkedHashSet<>();
values.add("one");
values.add("two");
System.out.println(values); // [one, two]
values.addFirst("zero");
System.out.println(values); // [zero, one, two]
values.addFirst("one");
System.out.println(values); // [one, zero, two]
values.addLast("three");
System.out.println(values); // [one, zero, two, three]
values.removeFirst();
System.out.println(values); // [zero, two, three]
SequencedSet<String> reversedSet = values.reversed();
System.out.println(reversedSet); // [three, two, zero]
boolean isEqual = values.equals(reversedSet);
System.out.println(isEqual); // true
System.out.println(values.hashCode()); // 612888
System.out.println(reversedSet.hashCode()); // 612888
System.out.println(values.hashCode() == reversedSet.hashCode()); // true
SequencedMap
If you want to use the new methods defined in SequencedMap, you’ll need to use Map implementations such as LinkedHashMap or a Map that implements SortedMap.
HashMap does not take advantage of Sequenced Collections, as it doesn’t have a defined encounter order.
interface SequencedMap<K,V> extends Map<K,V> {
SequencedMap<K,V> reversed();
SequencedSet<K> sequencedKeySet();
SequencedCollection<V> sequencedValues();
SequencedSet<Entry<K,V>> sequencedEntrySet();
V putFirst(K, V);
V putLast(K, V);
Entry<K, V> firstEntry();
Entry<K, V> lastEntry();
Entry<K, V> pollFirstEntry();
Entry<K, V> pollLastEntry();
}
In below example, as you can see we can access the first and last elements via firstEntry() and lastEntry() methods.
pollFirstEntry() method will remove and return the first key-value element, or null if the map is empty.
Besides, calling reversed(), will only compare elements, and it doesn’t depend on the order of them.
SequencedMap<String, Integer> myMap = new LinkedHashMap<>();
myMap.put("one", 1);
myMap.put("two", 2);
System.out.println(myMap); // {one=1, two=2}
Entry<String, Integer> firstEntry = myMap.firstEntry();
System.out.println(firstEntry); // one=1
Entry<String, Integer> lastEntry = myMap.lastEntry();
System.out.println(lastEntry); // two=2
myMap.putFirst("zero", 0);
System.out.println(myMap); // {zero=0, one=1, two=2}
myMap.putFirst("one", -1);
System.out.println(myMap); // {one=-1, zero=0, two=2}
Entry<String, Integer> polledFirstEntry = myMap.pollFirstEntry();
System.out.println(polledFirstEntry); // one=-1
System.out.println(myMap); // {zero=0, two=2}
SequencedMap<String, Integer> reversedMap = myMap.reversed();
System.out.println(reversedMap); // {two=2, zero=0}
boolean isEqual = myMap.equals(reversedMap);
System.out.println(isEqual); // true
System.out.println(myMap.hashCode()); // 692224
System.out.println(reversedMap.hashCode()); // 692224
System.out.println(myMap.hashCode() == reversedMap.hashCode()); // true
String Templates
It is a preview feature and disabled by default, we need to use
--enable-preview
to enable string templates.
First, I’ll explore some techniques which we have for compositing strings before diving into String Templates.
The + (plus) operator: The biggest downside is that a new String gets created each time we use the + operator.
StringBuffer and StringBuilder: StringBuffer is thread-safe, whereas StringBuilder was added in Java 5 and more performant but not thread-safe alternative.
Their major downside is their verbosity, especially for simpler Strings:
var greeting = new StringBuilder()
.append("Hello, welcome ")
.append(name)
.toString();
String::format and String::formatter: They allow for reusable templates, but they require us to specify format and provide the variables in the correct order.
var format = "Good morning %s, It's a beautiful day!";
var text = String.format(format, name);
// Java 15+
var text = format.formatter(name);
Even though we save on the number of String allocations, now the JVM has to parse/validate the template String.
java.text.MessageFormat: The same as String format, but more verbose
var format = new MessageFormat("Good morning {0}, It's a beautiful day!");
var greeting = format.format(name);
Now we have String Templates to rescue
It is simple and concise and the new way to work with strings is called template expression. They can perform interpolation but also provide us with the flexibility to compose the Strings, and convert structured text into any object, not just a String.
There are three components to a template expression:
A template processor: Java provides two template processors for performing string interpolation: STR and FMT
A template containing wrapped expressions like \{name}
A dot (.) character
Here are some examples on how we can use String templates with template processors:
package com.mina.stringtemplates;
import static java.util.FormatProcessor.FMT;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class StringTemplateExamples {
public static String greeting(String firstName, String lastName) {
return STR."Hello! Good morning \{ firstName } \{ lastName }" ;
}
public static String multiplyWithArithmeticExpressions(int a, int b) {
return STR."\{ a } times \{ b } = \{ a * b }" ;
}
public static String multiplyWithJavaExpression(int a, int b) {
return STR."\{ a } times \{ b } = \{ Math.multiplyExact(a, b) }" ;
}
// multiplication with floating point numbers rounded to two decimal places using the FMT template processor
public static String multiplyFloatingNumbers(double a, double b) {
return FMT."%.2f\{ a } times %.2f\{ b } = %.2f\{ a * b }" ;
}
public static String getErrorResponse(int httpStatus, String errorMessage) {
return STR."""
{
"httpStatus": \{ httpStatus },
"errorMessage": "\{ errorMessage }"
}""" ;
}
public static String getCurrentDate() {
return STR."Today's date: \{
LocalDate.now().format(
DateTimeFormatter.ofPattern("yyyy-MM-dd")
) }" ;
}
}
Record Patterns
Record pattern matching is a way to match the Record’s type and access its components in a single step.
We use it to test whether a value is an instance of a record class type, if it is, perform pattern matching on its component values.
The following example tests whether transaction
is an instance of the Transaction
record with the record pattern Transaction(String type, double amount)
package com.mina.recordpattern;
public class RecordPatternExample {
// I'm using "_" for readability here, this won't compile
public static String getTransactionType(Transaction transaction) {
return switch (transaction) {
case null -> throw new IllegalArgumentException("Transaction can not be null.");
case Transaction(String type, double amount) when type.equals("Deposit") && amount > 0 -> "Deposit";
case Transaction(String type, _) when type.equals("Withdrawal") -> "Withdrawal";
default -> "Unknown transaction type";
};
}
record Transaction(String type, double amount) {
}
}
What happens if transaction is null
? You’re right – a NullPointerException
is thrown. This is also the case in Java 21, but now we can have the null
case explicitly by writing case null ->
– which lets you avoid the NullPointerException
.
Guarded patterns: It is also possible to guard specific cases. For example, we’d use the when keyword to check for equality
Pattern Matching for switch
Pattern Matching for switch
was introduced as a preview feature in Java 17, and became permanent in Java 21.
A switch
statement transfers control to one of several statements or expressions, depending on the value of its selector expression which can be of any type, and case
labels can have patterns.
It checks whether its selector expression matches a pattern, which is more readable and flexible compared to testing whether its selector expression is exactly equal to a constant.
package com.mina.switchpatternmatching;
import com.mina.switchpatternmatching.SwitchPatternMatchingExample.Transaction.Deposit;
import com.mina.switchpatternmatching.SwitchPatternMatchingExample.Transaction.Withdrawal;
public class SwitchPatternMatchingExample {
public static String getTransactionType(Transaction transaction) {
return switch (transaction) {
case null:
throw new IllegalArgumentException("Transaction can't be null.");
case Deposit deposit when deposit.getAmount() > 0: // Guarded pattern with when clause
yield "Deposit";
case Withdrawal withdrawal:
yield "Withdrawal";
default:
yield "Unknown transaction type";
};
}
sealed class Transaction permits Deposit, Withdrawal {
private double amount;
public Transaction(double amount) {
this.amount = amount;
}
public double getAmount() {
return amount;
}
final class Withdrawal extends Transaction {
public Withdrawal(double amount) {
super(amount);
}
}
final class Deposit extends Transaction {
public Deposit(double amount) {
super(amount);
}
}
}
}
I hope this post helped you to gain better understanding about new features of Java 21 and you can find more details and examples in my github repository: https://github.com/minarashidi/java-core