In an exciting development for Java developers, this September 19th marked the release of JDK 21. This release contains many new capabilities that benefit the Java ecosystem, including virtual threads, record patterns, and sequenced collections. There are also some interesting features in the preview for JDK 21, such as string templates, scoped values, and structured concurrency. This article highlights six new features in this release.
Virtual threads
Java's traditional threading model can quickly become an expensive operation if the application creates more threads than the operating system (OS) can handle. Also, in cases where the thread lifecycle is not long, the cost of creating a thread is high.
Enter virtual threads, which solve this problem by mapping Java threads to carrier threads that manage (i.e., mount/unmount) thread operations to a carrier thread. In contrast, the carrier thread works with the OS thread. It is an abstraction that gives more flexibility and control for developers. See Figure 1.
The following is an example of virtual threads and a good contrast to OS/platform threads. The program uses the ExecutorService
to create 10,000 tasks and waits for all of them to be completed. Behind the scenes, the JDK will run this on a limited number of carrier and OS threads, providing you with the durability to write concurrent code with ease.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() is called implicitly, and waits
Structured concurrency (Preview)
Structured concurrency is closely tied to virtual threads, and it aims to eliminate common risks such as cancellation, shutdown, thread leaks, etc., by providing an API that enhances the developer experience. If a task splits into concurrent subtasks, then they should all return to the same place, i.e., the task's code block.
In Figure 2, findUser
and fetchOrder
both need to execute to get the data from different services and then use that data to compose results and send it back in a response to the consumer. Normally, these tasks could be done concurrently and could be error prone if findUser
didn't return; fetchOrder
would need to wait for it to complete, and then finally execute the Join operations.
Furthermore, the lifetime of the subtasks should not be more than the parent itself. Imagine a task operation that would compose results of multiple fast-running I/O operations concurrently if each operation is executed in a thread. The structured concurrency model brings thread programming closer to the ease of single-threaded code style by leveraging the virtual threads API and the StructuredTaskScope
.
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<String> user = scope.fork(() -> findUser());
Supplier<Integer> order = scope.fork(() -> fetchOrder());
scope.join() // Join both subtasks
.throwIfFailed(); // ... and propagate errors
// Here, both subtasks have succeeded, so compose their results
return new Response(user.get(), order.get());
}
}
Scoped values (Preview)
ScopeValue also brings in interesting changes for developer productivity when programming with Threads. Historically, Java developers have used ThreadLocals
to pass along data through the call chain in order for a thread to access data. However, changing that data through the call chain is also easier as the ThreadLocal
variable passes through. This makes it harder to program and sometimes more error prone, with risks to insecure code.
ScopeValue
aims to fix this by providing a model where threads can share data without the possibility of changing it during its scope. The data is immutable with the ScopeValue
and enables runtime optimization.
Multi-threaded applications that use security principals, transactions, and shared context will benefit from the scoped values. The example below shows the ScopeValue<String>
, which is created and used with the scope of runWhere
, a runnable method.
public class WithUserSession {
// Creates a new ScopedValue
private final static ScopedValue<String> USER_ID = new ScopedValue.newInstance();
public void processWithUser(String sessionUserId) {
// sessionUserId is bound to the ScopedValue USER_ID for the execution of the
// runWhere method, the runWhere method invokes the processRequest method.
ScopedValue.runWhere(USER_ID, sessionUserId, () -> processRequest());
}
// ...
}
Sequenced collections
In JDK 21, a new set of collection interfaces are introduced to enhance the experience of using collections (Figure 3). For example, if one needs to get a reverse order of elements from a collection, depending on which collection is in use, it can be tedious. There can be inconsistencies retrieving the encounter order depending on which collection is being used; for example, SortedSet
implements one, but HashSet
doesn't, making it cumbersome to achieve this on different data sets.
To fix this, the SequencedCollection interface aids the encounter order by adding a reverse method as well as the ability to get the first and the last elements. Furthermore, there are also SequencedMap
and SequencedSet
interfaces.
interface SequencedCollection<E> extends Collection<E> {
// new method
SequencedCollection<E> reversed();
// methods promoted from Deque
void addFirst(E);
void addLast(E);
E getFirst();
E getLast();
E removeFirst();
E removeLast();
}
So now, not only is it possible to get the encounter order, but you can also remove and add the first and last elements.
Record patterns
Records were introduced as a preview in Java 14, which also gave us Java enums. record
is another special type in Java, and its purpose is to ease the process of developing classes that act as data carriers only.
In JDK 21, record patterns and type patterns can be nested to enable a declarative and composable form of data navigation and processing.
// To create a record:
Public record Todo(String title, boolean completed){}
// To create an Object:
Todo t = new Todo(“Learn Java 21”, false);
Before JDK 21, the entire record would need to be deconstructed to retrieve accessors.. However, now it is much more simplified to get the values. For example:
static void printTodo(Object obj) {
if (obj instanceof Todo(String title, boolean completed)) {
System.out.print(title);
System.out.print(completed);
}
}
The other advantage of record patterns is also nested records and accessing them. An example from the JEP definition itself shows the ability to get to the Point
values, which are part of ColoredPoint
, which is nested in a Rectangle
. This makes it way more useful than before, when all the records needed to be deconstructed every time.
// As of Java 21
static void printColorOfUpperLeftPoint(Rectangle r) {
if (r instanceof Rectangle(ColoredPoint(Point p, Color c),
ColoredPoint lr)) {
System.out.println(c);
}
}
String templates
String templates are a preview feature in JDK 21. However, it attempts to bring more reliability and better experience to String
manipulation to avoid common pitfalls that can sometimes lead to undesirable results, such as injections. Now you can write template expressions and render them out in a String
.
// As of Java 21
String name = "Shaaf"
String greeting = "Hello \{name}";
System.out.println(greeting);
In this case, the second line is the expression, and upon invoking, it should render Hello Shaaf
. Furthermore, in cases where there is a chance of illegal String
s—for example, SQL statements or HTML that can cause security issues—the template rules only allow escaped quotes and no illegal entities in HTML documents.
Get support for Java
Support for OpenJDK and Eclipse Temurin is available to Red Hat customers through a subscription to Red Hat Runtimes, Red Hat Enterprise Linux, and Red Hat OpenShift. Contact your local Red Hat representative or Red Hat sales for more details. You can expect support for Java and other runtimes as described under the Red Hat Product Update and Support Lifecycle.