Java 20 🥱

The list of big new features that can be used in production with Java 20 is rather short: . (That was it already.) Pretty boring, these six-month releases. We really don't need to take a closer look ...

Two hands, each holding a large ballon in the shape of a digit, forming the number 20

We really don't need to take a closer look at Java 20, because there are just a few improvements to security and performance. And to observability and tools. Oh, and to regular expressions and Unicode. And the previews of virtual threads, structured concurrency, pattern matching, and the new foreign APIs for interacting with native code and off-heap memory have progressed as well. And let's not forget the new scoped values API, which partially replaces thread locals and interacts better with virtual threads - it just started incubating in Java 20.

Hm. Maybe it's worth taking a closer look after all.

Maybe it's worth taking a closer look after all

So let's get to it. First the obligatory part (finalized improvements in security, performance, observability, tools and more), then the fun part (updated previews of foreign APIs, pattern matching, virtual threads, and structured concurrency plus new scoped values API). Finally, I'll briefly talk about obstacles when updating to Java 20.

Security

Like every release, Java 20 adapts Java to the constantly evolving security landscape. DTLS 1.0 was disabled by default because the IETF depredated this version for lack of support for strong cipher suites. The remaining TLS_ECDH_ cipher suites have been disabled as well because they do not preserve forward secrecy. None of these algorithms should be used in practice, but you absolutely need to, you can enable them at your own risk with the security property jdk.tls.disabledAlgorithms.

The class javax.net.ssl.SSLParameters got two new methods getNamedGroups() and setNamedGroups(), which let you inspect and configure the key exchange algorithms used when creating a (D)TLS connection.

If you're using JNDI with LDAP or RMI, check out the new security properties jdk.jndi.ldap.object.factoriesFilter and jdk.jndi.rmi.object.factoriesFilter. They configure which classes are allowed to instantiate Java objects from JNDI/LDAP and JNDI/RMI contexts, respectively. If you have previously used your own object factories for this, you must now explicitly allow them with these properties.

For more information on security improvements in Java 19 and 20, I recommend Ana's Inside Java Newscast #42: From Java Security With Love.

Always embed videos

(and give me a cookie to remember - privacy policy)

Watch on YouTube

Performance

Just like with security, Java's excellent performance rests not only on good fundamentals but also on constant improvements from one release to the next. In this respect, Java 20's steps in these areas are certainly unspectacular when viewed individually but in the overall context exactly what Java needs: steady progress.

Steady progress

More Intrinsic Hash Functions

Java source code is converted to bytecode by the compiler and then, if necessary, translated into platform-specific machine code (and optimized in the process) by the just-in-time (JIT) compiler. However, a clever programmer can often write even more performant native code, which is done for methods that are particularly relevant to run time. Such platform-specific code is then stored as a so-called intrinsic function and can be used by the JIT compiler.

In Java 20, intrinsic implementations of the Poly1305 family hash functions have been added for x86_64 platforms. These implementations use the AVX512 extended vector instruction set, making them faster and more energy efficient. Intrinsic functions for the x86_64 and aarch64 platforms were also created for the ChaCha20 encryption algorithm.

G1 Improvements

A major refactoring of concurrent refinement thread handling in G1 should reduce the activity spikes of these threads and handle write barriers more efficiently. As a result, the following options no longer have meaning - they generate warnings and will be removed in a future release:

  • -XX:-G1UseAdaptiveConcRefinement
  • -XX:G1ConcRefinementGreenZone=buffer-count
  • -XX:G1ConcRefinementYellowZone=buffer-count
  • -XX:G1ConcRefinementRedZone=buffer-count
  • -XX:G1ConcRefinementThresholdStep=buffer-count
  • -XX:G1ConcRefinementServiceIntervalMillis=msec

G1's preventive garbage collections, introduced in Java 17, were intended to avoid expensive evacuation failures due to abrupt mass allocations. However, they themselves create additional work and it has been found that in most practical cases they do more harm than good to performance. In Java 20, they are disabled by default and can be re-enabled with -XX:+UnlockDiagnosticVMOptions -XX:+G1UsePreventiveGC.

Observability With JFR And JMX

A central property of the JVM and a major strength of its ecosystem is the runtime's transparency. Hardly any other platform can be observed and analyzed in such detail and with such little overhead. An essential tool for this is the Java Flight Recorder (JFR), a profiler with deep insight into the JVM and low overhead (with default settings less than 1% for long-lived applications). If you don't know JFR, you should definitely read up on it - Billy published a good tutorial on the Java YouTube channel.

Always embed videos

(and give me a cookie to remember - privacy policy)

Watch on YouTube

Starting with Java 20, JFR fires two new events:

Something has also happened for JMX: The G1 Garbage Collector got the GarbageCollectorMXBean, which reports the occurrence and duration of remark and cleanup pauses.

Compiler And jmod

The compiler tries to protect us from all sorts of errors, for example when we mix up numeric types. The Java Language Specification (JLS) dictates that for assignments the numeric types on both sides must be assignemnt compatible. For example, double and long are not:

// Error - incompatible types:
// possible lossy conversion from double to long
long a = 1L + 0.1 * 3L;

In the case of compound assignments, however, a cast is inserted, i.e. these statements compile:

long a = 1L;
a += 0.1 * 3L;

While each specification makes sense in its context, the inconsistency is annoying. Java 20 mitigates this by letting the compiler emit a warning for the second variant when the new linter option lossy-conversions is enabled:

warning: [lossy-conversions] implicit cast from double
to long in compound assignment is possibly lossy
                 a += 0.1 * 3L;
                          ^
1 warning

Those who use the jmod command line tool to create JMOD archives will be pleased to know that the --compress option has been added. It accepts as value zip-$N where $N is a numeric value between 0 and 9 - 0 means no compression, 9 means strongest ZIP compression (default is zip-6).

Miscellaneous

Here are three more changes that don't fit into any of the other categories.

Named Group In Regular Expressions

Regular expressions aren't exactly known for their readability. You can improve this a bit by giving groups names:

var noNameMatcher = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
var namingMatcher = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})");

Not only is the regular expression itself more self-explanatory with group names, you can later query the groups not only via index (e.g. matcher.group(2)) but also via their name (matcher.group("month")), which is much more readable. All this has been possible since Java 1.7.

New in Java 20 is better support for groups with names. First, Matcher and Pattern now provide a mapping of group names to their indices with the namedGroups() method. Then, the MatchResult interface, which Matcher implements, has been extended by some of Matchers group-name related methods (by default implementation):

  • end(String groupName)
  • group(String groupName)
  • namedGroups()
  • start(String groupName)

In other news, Matcher and MatchResult acquired the new method hasMatch(), which indicates whether there's currently a match - basically, it returns the same as the last find() call but without changing the matcher's state.

Idle HTTP Connection Timeouts

The default timeout for idle HTTP/1.1 and HTTP/2 connections has been reduced - see Migration Challenges for more information. Starting with Java 20, the timeouts can be configured globally via system properties:

  • jdk.httpclient.keepalivetimeout sets the timeouts for HTTP/1.1 and HTTP/2 (in seconds)
  • jdk.httpclient.keepalivetimeout.h2 sets the timeouts for HTTP/2 (in seconds)

Unicode 15.0

Java 20 supports Unicode 15.0. That means 4,489 new characters for java.lang.character, bringing the total to 149,186. Java has character! (Sorry.)

Java has character!

Refinements Of Preview Features

From dealing with native code to pattern matching, from scalability to maintainability with virtual threads - Java is previewing solutions to some complicated challenges. Unfortunately, there is not enough space here to discuss the problems and their solutions in detail, which is why both are only summarized. In each section, however, the latest JEP for each proposal is linked and the changes in Java 20 are summarized.

Foreign Function & Memory API

Calling native code from Java is not that easy: The Java Native Interface (JNI) requires a number of artifacts and often non-trivial tool chains are used to create them. Especially when the native API is developing rapidly, adapting it for Java can be very tedious. And then there's memory management. Because passing Java objects with JNI is slow, many developers use Unsafe to allocate off-heap memory and then just pass the memory address. Of course, this makes the Java code very fragile.

The Foreign Function API and the Foreign Memory API (collectively FFM APIs) came about to solve these problems. Calls into native code are implemented by method handles (introduced in Java 7), which makes interaction with it much easier. For this purpose the classes Linker, FunctionDescriptor and SymbolLookup as well as the tool jextract (which lives outside the JDK) were introduced. Management of off-heap memory is represented by another set of new types:

  • MemorySegment and SegmentAllocator to allocate memory
  • MemoryLayout and VarHandle to access them in a structured way
  • SegmentScope and Arena to control (de)allocation

Taken together, this can look like the following example, where an array of Java strings is sorted using the C function radixsort:

// 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.openConfined()) {
	// 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
}

For more advanced experiments with the Foreign Memory API, I recommend Per Minborg's articles Colossal Sparse Memory Segments and An Almost Infinite Memory Segment Allocator.

The FFM APIs incubated for a few releases and see their second preview in Java 20. The implementation is very stable, but there are some surface-level changes to the API over Java 19:

  • The Arena and SegmentScope types have evolved from the removed MemorySession.
  • MemorySegment has incorporated MemoryAddress.
  • Improved sealed inheritance hierarchy of MemoryLayout for better interaction with pattern matching.

Speaking of pattern matching...

Pattern Matching

In Java, polymorphism (i.e. behavior that differs by type) is primarily implemented by overriding methods within an inheritance hierarchy. The Collection interface defines the add method and each collection - from ArrayList to HashSet - implements it according to its internal data structure.

However, sometimes it is undesirable or even impossible to implement new functionality as a method in an inheritance hierarchy. Whether that's because you don't want to overload core domain types with too many responsibilities or because the types in question aren't under your own control, there are situations where you have to implement polymorphism "from the outside". The design pattern for this is the visitor pattern, but that doesn't exactly impress with simplicity and readability.

Java is developing a better alternative to this, or more generally to the need to split program flow by types and object properties. For example, if you don't want to implement the computation of an area of a Shape as a Shape::area method, but "from the outside", you can do it like this:

static double area(Shape shape) {
	return switch (shape) {
		case Circle(var radius) -> radius * radius * Math.PI;
		case Rectangle(var width, var height) -> width * height;
	};
}

A few things stand out:

  • First of all, the switch that applies pattern matching to objects. Type patterns have been supported in instanceof since Java 16 and in Java 20 there is the fourth preview for it in switch.
  • These are not actually types patterns but record patterns, which are in their second preview. They allow records to be broken down into their constituent components.
  • Finally, notice that the switch is undefined for Shape instances that are neither a Circle nor a Rectangle. This is possible if Shape is a sealed interface, which only allows these two classes as implementations.

In order for the switch to work like this, Shape, Circle and Rectangle must be defined as follows:

sealed interface Shape permits Circle, Rectangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }

In Java 20, these two preview features were polished around the edges:

  • If by extending a sealed type a switch is no longer exhaustive (e.g. by adding Triangle extends Shape to the above example) and this is not caught by the compiler (because the switch is not compiled together with Shape), a MatchException will now be thrown instead of an IncompatibleClassChangeError.
  • Generic type inference works (better) in switch and record patterns, so fewer parametric types need to be present in the code.
  • Record Patterns can also be used in loops:
    List<Circle> circles = // ...
    for (Circle(var radius) : circles)
    	// use `radius`
  • For the time being, named patterns are out, i.e. while in Java 19 you could write case Circle(var r) c to also declare the variable Circle c, this is no longer possible in Java 20 because it has led to an ambiguous grammar.

So there's still some movement in these proposals, but I hope that at least pattern matching in switch is now at a point where there doesn't need to be another (fifth!) preview. This would have the pleasant side effect that the feature will be finalized and then usable in practice in Java 21 - the next LTS version.

Virtual Threads

Code that blocks an operating system (OS) thread while waiting for requests to external systems (e.g. the file system or the database) to return is easy to write, debug, and profile, but by not letting that OS thread do other things in the meantime it is wasting a limited resources. Depending on the application's load profile, this resource can become the constraining factor for scaling and the only reason for starting another server is not that the others have run out of CPU time or memory for Java objects, but out of OS threads.

You can replace this evil with another and implement the application reactively. For this purpose, you'd make extensive use of types like CompletableFuture or of reactive streams, such as those provided by RxJava. Then your app only uses OS threads when they are really needed - otherwise it waits (almost) for free. This makes the code much more scalable, but also more difficult to write and, in particular, more confusing to debug and profile.

Virtual threads combine the best qualities of these two approaches: You can use them to write, debug, and profile blocking code as usual, while under the hood the JVM ensures that the virtual thread running your code only occupies an OS thread when it actually needs it and not when it is waiting for an external system. (While it's waiting, the OS thread can execute another virtual thread.) So you can have orders of magnitude more virtual than OS threads and even a laptop can keep millions of virtual threads waiting without problems.

Virtual threads combine the best qualities of these two approaches

Java 19 introduced virtual threads as a preview feature and Java 20 gives them a second round of review. There are almost no changes compared to Java 19 - only a few small extensions of existing APIs (such as new methods on Thread and Future) are no longer part of the preview because they are useful independent of virtual threads and have been finalized in Java 20.

// finalized methods on `Thread`:
boolean join(Duration);
static void sleep(Duration);
long threadId();

// finalized methods on `Future`
V resultNow();
Throwable exceptionNow();
Future.State state();

// finalized new type
enum Future.State {
	CANCELLED, FAILED, RUNNING, SUCCESS
}

// finalized new type relationship
interface ExecutorService extends AutoCloseable

API extensions to create virtual threads are also part of this preview, but these won't play a major role in your day-to-day life:

  • In web applications, the app server or the web framework creates the threads that execute each web request. In order for these to be virtual threads, the servers/frameworks have to be updated and we developers will probably simply activate them via configuration.
  • For concurrency within the application, e.g. when sending requests to external services, it is better to use the structured variant. And we'll look into that next.

Structured Concurrency

Because virtual threads are so resource-friendly, you don't have to worry about when and where in the code they are created. On the contrary, it's perfectly fine to start virtual threads at every point where tasks should be performed concurrently.

In order for this type of concurrency to remain readable, Java recommends implementing it in a structured manner and letting (virtual) threads start, wait, and end in the same scope. A new API was incubated for this in Java 19: the StructuredTaskScope. Here is an example usage where a series of tasks (in the form of Callable<T>) should be executed but after successful completion of the quickest the others can be canceled and there is a deadline at which all to be are canceled:

public <T> T race(List<Callable<T>> tasks, Instant deadline)
		throws ExecutionException {
	try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
		// launch each task (implicitly in one virtual thread per task)
		for (var task : tasks)
			scope.fork(task);

		// wait for tasks to finish
		scope.joinUntil(deadline);

		// return the single result
		// (throws if no fork completed successfully)
		return scope.result();
	}
}

In this example shows two strengths of this API:

  • Concurrency is limited to one method and is thus easier to understand and predict.
  • Coordinating tasks (in this example "Shutdown on Success" but there are other strategies) is easy.

Not quite as obvious, but extremely helpful for debugging and profiling is the parent-child relationship that is implicitly established between threads. A thread executes the race method and waits in joinUntil while the forks it creates complete their respective tasks. During this time, the waiting thread is the parent and the forks are its children. This is not only a conceptual interpretation, but is also understood by the JVM because the StructuredTaskScope ensures that the child threads know the ID of the parent thread.

In practical terms, this means that in a breakpoint or thread dump you not only see each thread's stack, but can also navigate to the parent threads and their ancestors via the parent-child relationship. For example, if one of the tasks in the example above is in a breakpoint, you can see that it is the child of the thread that is currently waiting in race and also analyze its state. This will be a huge improvement for debugging and profiling concurrent applications, which so far often end up in the uninformative stack elements of a thread pool.

In Java 20, StructuredTaskScope was not changed.

Scoped Values

An API that correctly interacts with virtual threads, but is neither particularly efficiently nor resource-efficiently, is thread locals. They are used to store thread-specific information, usually in static final variables, which can then be queried from anywhere that variable is visible. In the following example, the Server::serve method is responsible for forwarding a request to the application, but first puts a Principal in a ThreadLocal so that other code that sees Server can use the principal (without passing it as a parameter):

class Server {

	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);
		Application.handle(request, response);
	}

}

ThreadLocal has a few shortcomings:

  • Anyone with access to PRINCIPAL can not only read the Principal but also set a new one.
  • Values stored in ThreadLocal can be inherited from one thread to another. In order 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 ("millions of virtual threads").
  • 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 incubates the Scoped Values API (for the first time). With it, the above example can be implemented as follows:

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.where(PRINCIPAL, principal)
			.run(() -> Application.handle(request, response));
	}

}

Here, too, different information is stored per thread, but there are some crucial differences to thread locals:

  • After a value has been bound with where, no other can be set.
  • Accordingly, no copies need to be created when inheriting, which significantly improves scalability.
  • As the name implies, a scoped value is only visible within the defined scope, i.e. within the run method - after that the bound value is automatically removed and so cannot accidentally "leak". In the example, only code that is called directly or indirectly from the lambda passed to run can see the principal in PRINCIPAL.

Migration Challenges

Java continues to evolve in many small and large steps. But after more than 25 years, this evolution also includes reversing old decisions that no longer stand the test of time, and so some technologies and APIs are being carefully removed:

  • Applet API
  • Security manager
  • Constructors of value-based classes
  • Finalization
  • some methods on Thread and ThreadGroup

For the background and current status of these deprecations for removal, I recommend Inside Java Newscast #41 - Future Java, Prepare Your Codebase Now!. A list of final deprecations can also be found in javadoc.

Always embed videos

(and give me a cookie to remember - privacy policy)

Watch on YouTube

In Java 20, only the removal of the methods on Thread is progressing: suspend(), resume() and stop() have had their implementation hollowed out and now throw an UnsupportedOperationException.

In addition, there are often small changes that have to be taken into account during a migration. In Java 20 this includes:

  • The necessity described above to allow custom object factories with the system properties jdk.jndi.ldap.object.factoriesFilter and jdk.jndi.rmi.object.factoriesFilter.
  • The G1 options listed above now generate warnings and should no longer be used.
  • When converting extremely large stylesheets to Java objects with XSLT an "Internal XSLTC error" may now occur, which can be bypassed by splitting the stylesheets.
  • The default timeout for idle HTTP/1.1 and HTTP/2 connections created with the java.net.http.HttpClient has been reduced from 1200 to 30 seconds.
  • IdentityHashMap's implementation of the methods remove(key, value) and replace(key, oldValue, newValue) incorrectly compared values (i.e. value, not key) by equality (equals) instead of identity (==) - this is now fixed.
  • Constructors of the URL class now check the passed strings more strictly to see whether they are valid URLs and thus more often throw a MalformedURLException. Before this change, some malformed URLs were only detected when the connection was opened and the exception was thrown then - this behavior can be restored by setting the system property jdk.net.url.delayParsing.

Summary

As boring as Java 20 may seem on the surface without major finalized features, releases like this are critical to Java's continued success. Whether security or performance, observability or tooling, existing APIs or upcoming features - Version 20 advances Java on all fronts. And, in all honesty, a little rest between groundbreaking changes is welcome - who wants another Java 9 every six months?