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.
▚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.
Starting with Java 20, JFR fires two new events:
jdk.InitialSecurityProperty
reports the initial configuration loaded byjava.security.Security
(enabled by default)jdk.SecurityProviderService
reports details of calls tojava.security.Provider.getService(String type, String algorithm)
(disabled by default)
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 Matcher
s 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
andSegmentAllocator
to allocate memoryMemoryLayout
andVarHandle
to access them in a structured waySegmentScope
andArena
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
andSegmentScope
types have evolved from the removedMemorySession
. MemorySegment
has incorporatedMemoryAddress
.- 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 ininstanceof
since Java 16 and in Java 20 there is the fourth preview for it inswitch
. - 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 forShape
instances that are neither aCircle
nor aRectangle
. This is possible ifShape
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 addingTriangle extends Shape
to the above example) and this is not caught by the compiler (because theswitch
is not compiled together withShape
), aMatchException
will now be thrown instead of anIncompatibleClassChangeError
. - 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 variableCircle 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 thePrincipal
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 torun
can see theprincipal
inPRINCIPAL
.
▚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
andThreadGroup
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.
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
andjdk.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 methodsremove(key, value)
andreplace(key, oldValue, newValue)
incorrectly compared values (i.e.value
, notkey
) 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 aMalformedURLException
. 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 propertyjdk.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?