Final features, continuing previews, and brand-new treats—with video links
Are you ready for all the new technology in Java 21? This article will take you on a tour of many of the changes, small and large, covering final JEPs, a progressing preview, and something entirely new for the platform.
Final features
Virtual threads. Let’s start with the big one: After two rounds of preview with barely any changes,
virtual threads are final in Java 21. Now web frameworks are off to the races because they need to let you easily configure using virtual threads instead of platform threads to handle requests.
Configuring virtual threads has the potential to let your app handle way more concurrent connections than before. But keep in mind that virtual threads aren’t performance pixie dust, so keep expectations realistic. Then again, if you don’t see the results you’re hoping for, there may be some easy code changes you can do that get you there. Watch episode 23 of the
Inside Java Newscast for more on that and some virtual thread guidelines.
Sequenced collections. Many collections in Java have a stable iteration order (all lists and some sets, for example) but don’t necessarily allow indexed access to them (all lists do, but sets usually don’t). Java 21 steps up its collections game and
introduces a set of new interfaces that capture this concept and offer related functionality.
At the core of these new interfaces is SequencedCollection, which extends Collection and is ultimately implemented by all lists, some sets, and a few other data structures. It offers the addFirst, addLast, getFirst, getLast, removeFirst, and removeLast methods, which do what you’d expect.
// getting first and last elements from a list
// (sequenced by order of addition)
var letters = List.of("c", "b", "a");
"c".equals(letters.getFirst());
"a".equals(letters.getLast());
// same but from a sorted set
// (sequenced by natural ordering)
var letterSet = new TreeSet<>(letters);
"a".equals(letters.getFirst());
"c".equals(letters.getLast());
There’s also a new method called reversed that returns a SequencedCollection that is a view on the underlying collection but in reverse order, which makes it super easy to iterate or stream over the collection.
var letters = new ArrayList<>(List.of("a", "b", "c"));
var reversedLetters = letters.reversed();
letters.addLast("d");
reversedLetters.forEach(System.out::print);
// ~> dcba
reversedLetters.addFirst("e");
letters.forEach(System.out::print);
// ~> abcde
If you want to learn more about that, the companion interfaces SequencedSet and SequencedMap, and a few odds and ends, check out episode 25 of the
Inside Java Newscast.
Generational low-pause garbage collection. Garbage collection is also taking big steps forward. The Z Garbage Collector (ZGC) has a strong focus on ultralow pause times, which can lead to a higher memory footprint or higher CPU usage than other garbage collectors. Starting with Java 21, both of these metrics will be improved on many workloads when
ZGC becomes generational, meaning it will maintain separate generations for young objects, which tend to die young, and old objects, which tend to be around for some time.
Preliminary benchmarks show very promising results: In a probably not-representative case, Cassandra 4 showed
◉ Four times the throughput on generational ZGC compared to ZGC with a fixed heap
◉ A quarter of the heap size on generational ZGC compared to ZGC with stable throughput
If you want to give generational ZGC a try on your workload, download a Java 21 early access build and launch it with -XX:+UseZGC -XX:+ZGenerational
Pattern matching. To effectively use pattern matching, you need three things.
◉ A capable switch that allows the application of patterns
◉ The ability to enforce limited inheritance so the switch can check exhaustiveness
◉ An easy way to aggregate and deconstruct data
var shape = loadShape();
var area = switch(shape) {
case Circle(var r) -> r * r * Math.PI;
case Square(var l) -> l * l;
// no default needed
}
sealed interface Shape permits Circle, Square { }
record Circle(double radius) { }
record Square(double length) { }
There are other features that come in really handy (and they are being worked on and one even previews in Java 21—more on that later), but these are the basics, and Java 21 finalizes the last two pieces:
pattern matching for switch and
record patterns. With these features, you can use this powerful idiom in your projects—be it in a small or large way—if you use a functional or data-oriented approach. To see how these features play together to achieve that, check out episode 29 of the
Inside Java Newscast.
Key encapsulation mechanism API. Do you know the Diffie-Hellman key exchange encapsulation (DHKEM) algorithm? If you don’t, you should definitely look into it. On the face of it, the algorithm sounds impossible. It lets two parties compute an encryption key, which is a number, while preventing an observer, who sees every exchanged message, from feasibly redoing the computation, ensuring that the key is a secret that only the two parties know. As you can imagine, that’s very helpful when you need to exchange encrypted information between parties that have no prior knowledge of each other. Hence, the DHKEM algorithm is widely used, for example, to provide forward secrecy in TLS.
Like all key encapsulation mechanisms, DHKEM is a building block of hybrid public key encryption (HPKE) and will be an important tool for defending against quantum attacks. Starting with Java 21,
Java has an API to represent key encapsulation mechanisms in a natural way.
Now you’re probably wondering what the API looks like. It’s all described in episode 54 of the
Inside Java Newscast with Ana-Maria Mihalceanu.
New view command for JDK Flight Recorder. The JDK Flight Recorder is an amazing piece of tech, and it’s getting better with every JDK release. JDK 21 added the view command, which displays aggregated event data on the terminal. This way, you can view information about an application without the need to dump a recording file or open up JDK Mission Control. Billy Korando explains all in episode 53 of the Inside Java Newscast.
API improvements
Java 21 comes with a number of small additions to existing APIs. Let’s quickly go over them, so you’re aware of where the JDK can do your work for you.
Emoji. The Character class gained a few static checks that let you identify emojis; first and foremost is isEmoji.
var codePoint = Character.codePointAt("😃", 0);
var isEmoji = Character.isEmoji(codePoint);
// prints "😃 is an emoji: true"
System.out.println("😃 is an emoji: " + isEmoji);
Math. The Math class got static clamp methods that take a value, a minimum, and a maximum and return a value that is forced into the [min, max] interval. There are four overloads for the four numerical primitives.
double number = 83.32;
double clamped = Math.clamp(number, 0.0, 42.0);
// prints "42.0"
System.out.println(clamped);
Repeat methods. StringBuilder and StringBuffer gained repeat methods, which allow you to add a character sequence or a code point multiple times to a string that is being built.
var builder = new StringBuilder();
builder.append("Hello");
builder.append(", ");
builder.repeat("World", 3);
builder.append("!");
// prints "Hello, WorldWorldWorld!"
System.out.println(builder);
String. String’s indexOf methods gain overloads that take a maxIndex, as follows:
var hello = "Hello, World";
var earlyCommaIndex = hello.indexOf(",", 0, 3);
// prints "-1"
System.out.println(earlyCommaIndex);
Also, string’s
new splitWithDelimiters method behaves like the split method but includes the delimiters in the returned array. The same splitWithDelimiters method was added to Pattern, by the way.
var hello = "Hello; World";
var semiColonSplit = hello.splitWithDelimiters(";", 0);
//prints [Hello, ;, World]
System.out.println(Arrays.toString(semiColonSplit));
List shuffles. Need to shuffle a List in place with a RandomGenerator? Then that’s your reason to update to Java 21! Once you do, you can pass the list and a RandomGenerator to
Collections::shuffle, and it’ll shuffle the list.
var words = new ArrayList<>(List.of("Hello", "new", "Collections", "shuffle", "method"));
var randomizer = RandomGenerator.getDefault();
// using this API makes way more sense when you’re not using the default generator
Collections.shuffle(words, randomizer);
// prints the words above but with a 99.17% chance of a different order
System.out.println(words);
HttpClient. An HttpClient can now be instructed to close, to shut down, or to await termination, but those are best-effort implementations that can have adverse interactions with open request or response body streams.
var httpClient = HttpClient.newHttpClient();
// use the client
httpClient.close();
// or call shutdown and awaitTermination
// yourself for more control:
var httpClient = HttpClient.newHttpClient();
// use the client
httpClient.shutdown();
httpClient.awaitTermination(Duration.ofMinutes(1));
// it also implements AutoCloseable
try (var httpClient = HttpClient.newHttpClient()) {
// use the client
}
Locales. The Locale.availableLocales() method returns a stream of all available locales.
var locales = Locale
.availableLocales()
.map(Locale::toString)
.filter(locale -> !locale.isBlank())
.sorted()
.collect(Collectors.joining(", "));
// prints af, af_NA, af_ZA, af_ZA_#Latn, agq, ...
System.out.println(locales);
Case-folded tags. And because you’ve all asked for case-folded IETF BCP 47 language tags (don’t pretend that you didn’t), Locale gained the
caseFoldLanguageTag method.
var lang = Locale.caseFoldLanguageTag("fi-fi");
// prints "fi-FI" (note the RFC5646-correct case)
System.out.println(lang);
Continued evolution
Now it’s time to transition from finalized features to previews, incubators, and experiments. To use a preview feature, you need to add the command-line flag --enable-preview to javac and java, and you also need to specify the Java version for javac, preferably with --release 21.
Structured concurrency. Once you get abundant virtual threads and start creating one virtual thread for every little concurrent task you have, an interesting opportunity arises: You can treat threads that you created for a set of tasks as if they are executing a single unit of work, and you can see them as children of the thread that created them.
An API that capitalizes on that would streamline error handling and cancellation, improve reliability, and enhance observability. And it would make it easy and helpful to start and end that single unit of work in the same scope, defining a unique entry and exit point for handling concurrent code. It would do for concurrency what structured programming did for control flow: add much-needed structure.
// create task scope with desired
// error handling strategy
// (custom strategies are possible)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// fork subtasks
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Integer> order = scope.fork(() -> fetchOrder());
scope
// wait for both subtasks
.join()
// propagate potential errors
.throwIfFailed();
// both subtasks have succeeded
// ~> compose their results
// (these calls are nonblocking)
return new Response(user.get(), order.get());
} // task scope gets shut down
The Structured Concurrency API was incubating in Java 20 and is
upgraded to a preview in Java 21. Beyond moving to a proper package, namely java.util.concurrent, the only change has been that StructuredTaskScope’s fork method now returns the new type Subtask. In Java 20, the fork method returned a Future, but that offered degrees of freedom (such as calling the blocking getmethod) that are counterproductive in structured concurrency and was overall too evocative of asynchronous programming, which is exactly what structured concurrency isn’t.
José Paumard has a video tutorial on all this; to see it, check out episode 13 of JEP Café.
Scoped values. The ThreadLocal API is used to store thread-specific information, usually in static final fields, which can then be queried from anywhere those variables are visible. That’s useful, for example, when a container, such as a web server, needs to make information accessible to other parts of its code that it doesn’t call directly. It’s also useful when it doesn’t want to pass that information on explicitly, either for convenience or integrity reasons.
class Server {
// Principal needs to be visible to other code...
final static ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
void serve(Request request, Response response) {
var level = request.isAuthorized() ? ADMIN : GUEST;
var principal = new Principal(level);
PRINCIPAL.set(principal);
// ... but not the application
Application.handle(request, response);
}
}
However, ThreadLocal has a few shortcomings:
◉ Anyone with access to the ThreadLocal field can read its value and also set a new one.
◉ Values stored in ThreadLocal can be inherited from one thread to another. To prevent the other threads from reading an updated value (which the API should explicitly prevent; it’s thread local, after all), the inheriting thread must create copies. These drive up memory use, especially when there are many threads—you know, the whole “millions of virtual threads” thing.
◉ Once set, values must be explicitly removed (using the ThreadLocal::remove method) or they will leak beyond their intended use and continue to occupy memory.
To solve these problems, Java 20 incubated and Java 21 previews the Scoped Values API, which works by binding a value to the ScopedValue instance and passing the code that is allowed to read that value as a lambda—that’s the scope.
class Server {
final static ScopedValue<Principal> PRINCIPAL = new ScopedValue<>();
void serve(Request request, Response response) {
var level = request.isAdmin() ? ADMIN : GUEST;
var principal = new Principal(level);
ScopedValue
// binds principal to PRINCIPAL, but...
.where(PRINCIPAL, principal)
// ... only in the scope that is defined by this lambda
.run(() -> Application.handle(request, response));
}
}
The Scoped Values API addresses the following ThreadLocal issues:
◉ Within the scope, the bound value is immutable.
◉ Accordingly, no copies need to be created when inheriting, which significantly improves scalability.
◉ As the name implies, a scoped value is visible only within the defined scope; after that, the value is automatically removed, so it cannot accidentally leak.
To see scoped values in practice, watch episode 16 of
JEP Café. In it, José Paumard talks about the early version of the API as it was in Java 20 and about what changes in Java 21; besides moving to java.lang, the changes mean the scope (the lambda) can now be a Callable, Runnable, and Supplier.
Foreign Function and Memory API. By efficiently invoking code outside the JVM (foreign functions) and by safely accessing memory not managed by the JVM (foreign memory), the Foreign Function and Memory API enables Java programs to call native libraries and process native data without the brittleness and danger of the Java Native Interface (JNI).
One of the main drivers of this API is to provide safe and timely deallocation in a programming language whose main staple is automatic deallocation (thanks, garbage collector).
Finding the right primitive to express this capability in a way that is harmonious with the rest of the Java programming model triggered a round of API changes in Java 20 and again in Java 21, which is why
the API will take another round of previewing.
// 1. find foreign function on the C library path
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle radixsort = linker.downcallHandle(stdlib.find("radixsort"), ...);
// 2. allocate on-heap memory to store four strings
String[] words = { "mouse", "cat", "dog", "car" };
// 3. use try-with-resources to manage the lifetime of off-heap memory
try (Arena offHeap = Arena.ofConfined()) {
// 4. allocate a region of off-heap memory to store four pointers
MemorySegment pointers = offHeap
.allocateArray(ValueLayout.ADDRESS, words.length);
// 5. copy the strings from on-heap to off-heap
for (int i = 0; i < words.length; i++) {
MemorySegment cString = offHeap.allocateUtf8String(words[i]);
pointers.setAtIndex(ValueLayout.ADDRESS, i, cString);
}
// 6. sort the off-heap data by calling the foreign function
radixsort.invoke(pointers, words.length, MemorySegment.NULL, '\0');
// 7. copy the (reordered) strings from off-heap to on-heap
for (int i = 0; i < words.length; i++) {
MemorySegment cString = pointers.getAtIndex(ValueLayout.ADDRESS, i);
words[i] = cString.getUtf8String(0);
}
// 8. all off-heap memory is deallocated at the end of the try-with-resources block
}
On that note, the quality of feedback during the preview phase from projects adopting the API has been excellent and very important for its evolution. If you want to help move Java forward, the easiest way to do that is to experiment with preview features and report back to the respective mailing lists.
Another addition in Java 21 has been the so-called fallback linker, which offers a way for platforms to be compliant with Java SE without too much work by using libffi instead of fully implementing the Linker API.
Goodbye 32-bit Windows port. Microsoft Windows 10 was the last 32-bit version of Windows, and it reaches the end of its lifecycle in October 2025. As no surprise, the Java port for 32-bit Windows isn’t heavily maintained anymore. For example, its implementation of virtual threads isn’t virtual at all—the threads fall back to platform threads. So, I guess it was to be expected that the port
got deprecated for removal, which Java 21 does.
Brand-new previews
In Java 21 there are three brand-new preview features that I just can’t skip—and I love how diverse they are! They span from improving a Java workhorse to refining a programming paradigm to changing how beginners learn the language.
Unnamed classes and instance main methods. Java 21 allows for much
simpler entry points into a Java program. The main method no longer needs to be public or static, nor does it need the args array. And the whole surrounding class becomes optional, too, making void main the smallest possible Java program.
// content of file Hello.java
void main() {
System.out.println("Hello, World!");
}
You can watch me demonstrate this in episode 49 of the
Inside Java Newscast. Let me briefly clarify two points that I didn’t explain very well in that video.
◉ This is a preview feature, so if you use it in a single source–file program, where it clearly shines, you need to add --enable-preview --source 21 to the java command as follows:
java --enable-preview --source 21 Hello.java
◉ There are plans to shorten System.out.println to just println and to also offer a more succinct way to read from the terminal, but neither of those are part of Java 21.
Unnamed variables and patterns. Unused variables are annoying but bearable. Unused patterns during deconstruction, on the other hand, are really cumbersome and clutter code because they make you want to deconstruct less.
String pageName = switch (page) {
case ErrorPage(var url, var ex)
-> "💥 ERROR: " + url.getHost();
case ExternalPage(var url, var content)
-> "💤 EXTERNAL: " + url.getHost();
case GitHubIssuePage(var url, var content, var links, int issueNumber)
-> "🐈 ISSUE #" + issueNumber;
case GitHubPrPage(var url, var content, var links, int prNumber)
-> "🐙 PR #" + prNumber;
};
String pageName = switch (page) {
case ErrorPage(var url, _)
-> "💥 ERROR: " + url.getHost();
case ExternalPage(var url, _)
-> "💤 EXTERNAL: " + url.getHost();
case GitHubIssuePage(_, _, _, int issueNumber)
-> "🐈 ISSUE #" + issueNumber;
case GitHubPrPage(_, _, _, int prNumber)
-> "🐙 PR #" + prNumber;
};
The change makes code more clearly readable and reduces IDE and compiler warnings. Best of all, though, it makes switching over sealed types more maintainable by allowing you to easily combine default handling of different types into a single branch while avoiding an outright default branch.
String pageEmoji = switch (page) {
case GitHubIssuePage _ -> "🐈";
case GitHubPrPage _ -> "🐙";
// explicitly list remaining types to avoid default
// branch (only possible with unnamed patterns)
case ErrorPage _, ExternalPage _ -> "n.a.";
};
If you want to better understand why that’s important and how exactly unnamed variables and patterns work, watch episode 46 of the
Inside Java Newscast.
String templates. The practice of embedding variables or simple expressions into strings isn’t popular. One reason is that it’s a bit cumbersome and the code is not perfectly readable. But more importantly, if the embedded content comes from the user, there’s the risk of injection attacks. And generally, unless you’re creating text for humans to read, there’s probably syntax and escaping to consider.
// a cumbersome and dangerous example
String property = "last_name";
String value = "Doe";
String query = "SELECT * FROM Person p WHERE p."
+ property + " = '" + value + "'";
String templates solve those problems. They make it easy to embed expressions in string literals or text blocks by encasing them between an opening backslash followed by an opening curly brace and a closing curly brace. They also enforce processing of such string templates by domain-specific string processors.
Such processors receive the string portions and the variables separately and return instances of any type.
The obvious processor simply concatenates and returns a String, but there are other possibilities out there. A SQL processor could validate and parse a statement’s syntax and return a java.sql.Statement, and a JSON processor could return a JsonNode.
// a safe and readable example
String property = "last_name";
String value = "Doe";
Statement query = SQL."""
SELECT * FROM Person p
WHERE p.\{property} = '\{value}'
""";
If you want to dig deeper, check out episode 47 of the
Inside Java Newscast by Ana-Maria Mihalceanu.
Source: oracle.com