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.
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 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:
synchronized
methods or blocks. The remedy is to rewrite the offending code with java.util.concurrent
locks. Be sure that the providers of your framework, database driver, and so on, update their code to work well with virtual threads. Quite a few already did.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; }
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:
switch
expression that yields a valueswitch
is exhaustiveJSONString(var v)
binds the variable v
to the component of the recordwhen
clause restricts a match to a Boolean conditioncase JSONString _
, with a single underscore, to indicate that you do not need the variable binding. But that is still a preview feature.case
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.
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.
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.
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.
Oracle now has an online “playground” for testing Java snippets. Check it out!
Comments powered by Talkyard.