Java 21: The Nice, The Meh, and the ... Momentous

When Java 17 was released in 2021 as a “long term support” version, I wrote an article dissecting its features and came to the conclusion that it had a few nice features, but none that were compelling reasons to upgrade. Except one: tens of thousands of bug fixes.

Java 21 was released today, as another “long term support” release. How does it rate on the momentousness scale? Read on for an unbiased opinion.

.jpeg

The Momentousness Ratings

Every six months, there is a new Java release. Ever so often (currently, every two years), Oracle labels a release as “long term support”, and Java users wonder whether they should upgrade. In theory, other JDK distributors could offer “long term support” for other releases, but it seems everyone is following Oracle's lead.

Should you upgrade?

Here are the major features of Java 21. I omit preview and incubator features (which you are surely not going to use in production), JVM internals, highly specialized features such as this one, and deprecations.

Feature Example Momentousness rating Why care?
Pattern matching for switch
Employee e = . . .;
String description = switch (e) {
   case Executive exec when exec.getTitle().length() >= 20 ->
      "An executive with an impressive title";
   case Executive __ -> "An executive";
   case Manager m -> {
      m.setBonus(10000);
      yield "A manager who just got a bonus";
   }
   default -> "A lowly employee with a salary of " + e.getSalary();
};
Nice It's better than chains of if/else/else with instanceof. Do you do that often? The JDK source has over 5 million LOC with about a thousand instanceof preceded by else.
Record Patterns
String description = switch (p)
   {
      case Point(var x, var y) when x == 0 && y == 0 -> "origin";
      case Point(var x, var __) when x == 0 -> "on x-axis";
      case Point(var __, var y) when y == 0 -> "on y-axis";
      default -> "not on either axis";
   };
Nice How many records are in your codebase? (The Java 21 API has two.)
Sequenced Collections
List<String> words = ...;
String lastWord = words.getLast();
for (String word : words.reversed()) System.out.println(word);
Nice Good to have, but you wouldn't upgrade for that.
Virtual threads
try {
   var response = client.send(request, HttpResponse.BodyHandlers.ofString()); 
   for (URL url : getImageURLs(response.body())) {
      saveImage(getImage(url));
   }
}
catch (...) { ... }
Momentous No more async gobbledygook!
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
   .thenApply(HttpResponse::body)
   .thenApply(this::getImageURLs)
   .thenCompose(this::getImages)
   .thenAccept(this::saveImages)
   .exceptionally(this::ohNoes);
Miscellaneous new methods
"Hello, World!".splitWithDelimiters
      ("\\pP\\s*", -1)
  // ["Hello", ", ", "World", "!", ""]
Meh Good that the API keeps evolving in small ways, but the changes are pretty minor.
Over 10,000 bug fixes Bug JDK-8054022 HttpURLConnection timeouts with Expect: 100-Continue and no chunking Count me in! Unless you are sure that none of them might impact you, shouldn't you upgrade?

Let's look at these features in more detail.

Virtual Threads

Virtual threads are a big deal. Similar to generics, lambda expressions, and modules, they solve a major problem for which the language has otherwise no good alternative. If you have the problem that they are designed to solve, you will have a powerful motivation to upgrade.

Here is the problem. If you write applications that process many more concurrent requests than available platform threads, you currently have two unappealing choices:

What is wrong with an asynchronous programming style? You have to structure your program as chunks of callbacks. You need library support for sequencing, branches, loops, and exception handling, instead of using the features that are built into Java. Debugging is more challenging since the debugger cannot show you a complete execution history when it stops at a breakpoint. Not convinced? Make one of your junior programmers read through the documentation of Project Reactor and then assign a simple task, such as loading a web page and then loading all images in it.

Of course, virtual threads are not appropriate for all concurrent programming. They only work for tasks that spend most of their time waiting for network I/O. This is the situation in many business applications where much of the request processing consists of calls to the database and external services.

Interestingly, there is very little to learn in order to use virtual threads. You just use them like regular threads. In most scenarios, you simply configure your application framework to invoke your business logic on virtual threads, and watch throughput increase.

One idiom is worth learning. To run multiple tasks in parallel, use a local instance of ExecutorService:

try (var service = Executors.newVirtualThreadPerTaskExecutor()) {
   Future<T1> f1 = service.submit(callable1);
   Future<T2> f2 = service.submit(callable2);
   result = combine(f1.get(), f2.get());
}

Obtaining the result with get is a blocking call, but so what, blocking is cheap with virtual threads.

Structured Concurrency, a preview feature in Java 21, simplifies error handling and makes it easier to harvest the results of multiple concurrent requests.

There are a few caveats:

Pattern Matching

Many functional languages have some form of pattern matching that makes it convenient to work with “algebraic data types”, which in Java are implemented with sealed hierarchies and record classes.

Java has chosen to extend the syntax for instanceof and switch for pattern matching, in order to leverage existing programmer knowledge. These extensions have been in preview until Java 20 and are now in their final form.

Are you using sealed hierarchies and records in your code base? Then pattern matching is appealing. Here is an example, a simple JSON hierarchy:

sealed interface JSONValue permits JSONArray, JSONObject, JSONPrimitive {}

final class JSONArray extends ArrayList<JSONValue> implements JSONValue {}

final class JSONObject extends HashMap<String, JSONValue> implements JSONValue {}

sealed interface JSONPrimitive extends JSONValue
   permits JSONNumber, JSONString, JSONBoolean, JSONNull {}

final record JSONNumber(double value) implements JSONPrimitive {}

final record JSONString(String value) implements JSONPrimitive {}

enum JSONBoolean implements JSONPrimitive {
   FALSE, TRUE;
}

enum JSONNull implements JSONPrimitive {
   INSTANCE;
}

.png

Now you can process JSON values like this:

JSONPrimitive p = . . .;
double value = switch (p) {
   case JSONString(var v) when v.matches("-?(0|[1-9]\\d*)(\\.\\d+)?([eE][+-]?\\d+)?") ->
      Double.parseDouble(v);
   case JSONString __ -> Double.NaN;
   case JSONNumber(var v) -> v;
   case JSONBoolean.TRUE -> 1;
   case JSONBoolean.FALSE, JSONNull.INSTANCE -> 0;
}

Note the following:

All this is certainly nicer than the instanceof and casting that one might do right now with Jackson. But you might want to hold off switching to a new JSON hierarchy until Java gives us value classes.

In general, pattern matching is more useful in contexts that are designed for pattern matching. Today's use cases are perhaps not all that compelling, but it is an investment in the future.

Sequenced Collections

When you have a Collection, how do you get the first element? With a List, it's list.get(0), but in general, you'd call collection.iterator().next(). Except with a stack or queue it is peek, with a deque getFirst, and the SortedSet interface has first. And what about the last element?

And how do you visit the elements in reverse order? Deque and NavigableSet have a handy descendingIterator. For lists, you iterate backwards, starting from the last element.

JEP 431 cleans up this situation with a SequencedCollection interface. It has these methods:

E getFirst();
E getLast();
void addFirst(E);
void addLast(E);
E removeFirst();
E removeLast();
SequencedCollection<E> reversed();

The first six methods are the same as in the Deque interface, which is now a subinterface.

There is also a SequencedSet, where reversed yiels a set, and a SequencedMap, with methods to get and put the first and last entry, and with sequenced views for the keys, values, and entries.

This figure, by Stuart Marks, shows the change in the collections hierarchy.

.png

TL;DR Reverse iteration over a list, deque, tree set, or tree map is now more uniform. Getting the first and laste element too. That's nice. Obviously not momentous.

Should You Upgrade?

When Java 17 was released, I opined that none of its features were momentous enough to warrant upgrading, and one was downright ugly. Still, upgrading was a no-brainer: tens of thousands of bug fixes.

Of course you should upgrade again to Java 21. Because, lots of bug fixes.

And this time there is a truly momentous feature: virtual threads. If you are contemplating the use of reactive programming, or you are already unhappily doing so, you definitely want to check them out.

Also Nice

Oracle now has an online “playground” for testing Java snippets. Check it out!

Comments powered by Talkyard.