Java 20 – So langweilig ist es wirklich

Die Liste der großen neuen Features, die man mit Java 20 in der Praxis verwenden kann, ist eher kurz:
- ...
Das war sie bereits. Ziemlich langweilig, diese sechsmonatigen Releases. Da braucht man eigentlich gar nicht genauer hinzuschauen, denn es gibt nur ein paar Verbesserungen in den Bereichen Sicherheit und Performance. Und bei Beobachtbarkeit, Tools, bei regulären Ausdrücken und Unicode. Na gut, auch bei den Vorschauen zu virtuellen Threads, Structured Concurrency, Pattern Matching und den neuen Foreign APIs für Interaktion mit nativem Code und Off-Heap Memory. Ach ja, und eine neue API namens Scoped Values, die Thread Locals teilweise ablöst und besser mit virtuellen Threads interagiert, gibt es auch als Vorschau [1]. Vielleicht lohnt es sich doch, genauer hinzuschauen.
Also der Reihe nach: Erst die Pflicht (finalisierte Verbesserungen in den Bereichen Sicherheit, Performance, Beobachtbarkeit, Tools und Sonstigem), dann die Kür (aktualisierte Vorschauen zu Foreign APIs, Pattern Matching, virtuellen Threads, Structured Concurrency und Scoped Values). Zum Ende komme ich noch auf Hindernisse bei einem Update auf Java 20 zu sprechen.
Sicherheit
Wie jedes Release bringt auch Java 20 Änderungen, die Java an die sich stetig weiterentwickelnde Sicherheitslandschaft anpassen. So wurde DTLS 1.0 standardmäßig deaktiviert, weil die IETF diese Version mangels Unterstützung starker Cipher Suites depredated hat [2]. Ebenso wurden die verbleibenden TLS_ECDH_* Cipher Suites deaktiviert [3], weil diese keine Perfect Forward Secrecy besitzen [4]. Keiner dieser Algorithmen sollte in der Praxis verwendet werden, aber bei Bedarf können sie auf eigenes Risiko mit der Security Property jdk.tls.disabledAlgorithms aktiviert werden.
Die Klasse javax.net.ssl.SSLParameters hat zwei neue Methoden getNamedGroups() und setNamedGroups() erhalten, mit denen Entwickler die Algorithmen für den Austausch von Schlüsseln beim Aufbau von (D)TLS-Verbindungen einsehen und konfigurieren können [5].
Wer JNDI mit LDAP oder RMI verwendet, sollte sich die neuen Security Properties jdk.jndi.ldap.object.factoriesFilter und jdk.jndi.rmi.object.factoriesFilter anschauen [6]. Mit ihnen kann konfiguriert werden, welche Klassen Java-Objekte von JNDI/LDAP- bzw. JNDI/RMI-Kontexten instantiieren dürfen. Wer dazu bisher eigene Object Factories verwendet hat, muss diese mit den neuen Properties ab Java 20 explizit freischalten.
Für mehr Informationen zu Sicherheitsverbesserungen in Java 19 und 20 empfehle ich den Inside Java Newscast #42: From Java Security With Love meiner Kollegin Ana-Maria Mihalceanu [7].
Performance
Ähnlich wie bei der Sicherheit ist Java auch im Bereich der Performance nicht nur wegen guter Grundlagenentscheidungen ausgezeichnet, sondern insbesondere auch weil von Release zu Release immer gearbeitet und verbessert wird. Insofern sind die Schritte von Java 20 Schritte in diesen Bereichen individuell betrachtet sicherlich unspektakulär, aber im Gesamtzusammenhang betrachtet genau das was Java braucht: stetiger Fortschritt.
Mehr intrinsische Hashfunktionen
Java-Quellcode wird vom Compiler in Bytecode umgewandelt und dann bei Bedarf vom Just-in-time (JIT) Compiler in plattformspezifischen Maschinencode übersetzt und dabei optimiert. Oft kann ein gewiefter Programmierer aber noch performanteren nativen Code schreiben und für besonders laufzeitrelevante Methoden geschieht das auch. Solcher plattformspezifischer Code wird dann als sogenannte "intrinsische Funktion" hinterlegt und kann vom JIT Compiler verwendet werden.
Für Java 20 wurden intrinsische Implementierungen der Hashfunktionen der Poly1305-Familie auf x86_64-Plattformen erstellt [8]. Diese Implementierungen nutzen den erweiterten Vektor-Befehlssatz AVX512 und sind so schneller und energiesparender. Für den Verschlüsselungsalgorithmus ChaCha20 wurden intrinsische Funktionen für die Plattformen x86_64 und aarch64 erstellt [9].
Besseres Concurrent Refinement und weniger präventive Collections bei G1
Durch ein umfangreiches Refactoring des Umgangs mit Concurrent Refinement Threads sollten sich bei G1 die Aktivitäts-Spikes dieser Threads reduzieren und Write Barriers effizienter gehandhabt werden [10]. Als Folge haben die folgenden Optionen keine Bedeutung mehr – sie erzeugen Warnungen und werden in einem zukünftigen Release entfernt werden:
- -XX:-G1UseAdaptiveConcRefinement
- -XX:G1ConcRefinementGreenZone=buffer-count
- -XX:G1ConcRefinementYellowZone=buffer-count
- -XX:G1ConcRefinementRedZone=buffer-count
- -XX:G1ConcRefinementThresholdStep=buffer-count
- -XX:G1ConcRefinementServiceIntervalMillis=msec
Die in Java 17 für G1 eingeführten "präventiven Garbage Collections" sollen teure Evacuation Failures durch abrupte massenhafte Allokationen vermeiden. Sie erzeugen dabei aber selber zusätzlichen Aufwand und es hat sich herausgestellt, dass sie in den meisten Fällen der Performance mehr schaden als nutzen. In Java 20 werden sie standardmäßig deaktiviert und können durch -XX:+UnlockDiagnosticVMOptions -XX:+G1UsePreventiveGC wieder aktiviert werden [11].
Beobachtbarkeit mit JFR und JMX
Eine zentrale Eigenschaft der JVM und eine große Stärke des JVM-Ökosystems ist die Transparenz der Laufzeitumgebung. Kaum eine andere Plattform kann so detailliert und mit so geringem Overhead beobachtet und analysiert werden. Ein essentielles Werkzeug dazu ist der Java Flight Recorder (JFR), ein Profiler mit tiefem Einblick in die JVM und geringem Overhead (mit Standardeinstellungen weniger als ein Prozent bei langlebigen Anwendungen) [12]. Wer JFR nicht kennt, sollte sich damit unbedingt auseinandersetzen – ein gutes Tutorial gibt es auf dem Java YouTube-Kanal [13].
Ab Java 20 feuert JFR zwei neue Events:
- jdk.InitialSecurityProperty berichtet die initiale Konfiguration, die von java.security.Security geladen wird (standardmäßig aktiviert) [14].
- jdk.SecurityProviderService berichtet Details von Aufrufen von java.security.Provider.getService(String type, String algorithm) (standardmäßig deaktiviert) [15].
Bei JMX hat sich auch etwas getan: Der G1 Garbage Collector hat in Java 20 die GarbageCollectorMXBean erhalten, welche das Auftreten und die Dauer von Remark- und Cleanup-Pausen berichtet [16].
Tools
Der Compiler versucht, uns vor allerlei Fehlern zu bewahren, zum Beispiel wenn wir numerische Typen durcheinanderwerfen. Die Java Language Specification (JLS) schreibt vor, dass bei Zuweisungen die numerischen Typen auf beiden Seiten zuweisungskompatibel ("assignment compatible") sein müssen [17]. Zum Beispiel double und long sind dies nicht:
// Error - incompatible types:
// possible lossy conversion from double to long
long a = 1L + 0.1 * 3L;
Bei zusammengesetzten Zuweisungen wird allerdings ein Cast eingefügt, das heißt diese Befehlsfolge kompiliert [18]:
long a = 1L;
a += 0.1 * 3L;
Das ergibt im jeweiligen Kontext zwar durchaus Sinn, die Inkonsistenz ist aber dennoch störend. In Java 20 wird sie dadurch abgeschwächt, dass der Compiler bei Aktivierung der neuen Linter-Option lossy-conversions bei der zweiten Variante zumindest eine Warnung erzeugt [19]:
warning: [lossy-conversions] implicit cast from double
to long in compound assignment is possibly lossy
a += 0.1 * 3L;
^
1 warning
Wer das Kommandozeilenwerkzeug jmod benutzt, um JMOD-Archive zu erstellen, wird sich freuen, zu erfahren, dass die Option --compress hinzugefügt wurde [20]. Sie akzeptiert als Wert zip-$N wobei $N ein numerischer Wert zwischen 0 und 9 ist – 0 steht für keine Kompression, 9 für die stärkste ZIP-Kompression (Standard ist zip-6).
Verschiedenes
Hier sind noch drei Änderungen, die in keine der anderen Kategorien passen.
Named Group in regulären Ausdrücken
Reguläre Ausdrücke sind nicht gerade für ihre Lesbarkeit bekannt. Ein bisschen verbessern kann man diese, wenn man Gruppen mit Namen versieht:
var noNameMatcher = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
var namingMatcher = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})");
Nicht nur ist der reguläre Ausdruck mit Gruppennamen selbsterklärender, man kann die Gruppen später außer über den Index (also z. B. matcher.group(2)) dann auch über ihren Namen abfragen (matcher.group("month")), was deutlich lesbarer ist. Das alles geht seit Java 1.7.
Neu in Java 20 ist die bessere Unterstützung für Gruppen mit Namen. Zum einen stellen Matcher und Pattern mit der Methode namedGroups() nun eine Abbildung von Gruppennamen auf ihre Indizes zur Verfügung. Zum anderen wurde das von Matcher implementierte Interface MatchResult um einige von Matchers auf Gruppennamen bezogene Methoden (per Default-Implementierung) erweitert:
- end(String)
- group(String)
- namedGroups()
- start(String)
Unabhängig davon wurden Matcher und MatchResult außerdem um eine Methode hasMatch() erweitert, die angibt, ob gerade ein Match vorliegt – sie gibt quasi das gleiche wie der letzte find()-Aufruf zurück, aber ohne den Zustand des Matchers zu verändern.
System Property für inaktive HTTP-Verbindungen
Der Standard-Timeout für inaktive HTTP/1.1- und HTTP/2-Verbindungen wurde reduziert – mehr dazu im Abschnitt "Migrationsgerausforderungen". Er kann seit Java 20 aber auch global über System Properties konfiguriert werden:
- jdk.httpclient.keepalivetimeout legt die Timeouts für HTTP/1.1 und HTTP/2 fest (in Sekunden).
- jdk.httpclient.keepalivetimeout.h2 legt die Timeouts für HTTP/2 fest (in Sekunden).
Unicode 15.0
Java 20 unterstützt Unicode 15.0 [21]. Das bedeutet, 4.489 neue Zeichen für java.lang.character, was die Gesamtzahl auf 149.186 bringt. Java hat halt Charakter! (Sorry.)
Verfeinerungen von Preview Features
Vom Umgang mit nativem Code zu Pattern Matching, von Skalierbarkeit zur Struturierung mit virtuellen Threads – Java hat gerade Lösungen für einige komplizierte Aufgaben in der Vorschau. Leider reicht an dieser Stelle nicht der Raum, um die Problemstellungen und ihre Lösungen im Detail zu besprechen, weswegen beide nur zusammengefasst werden. Es ist in jedem Abschnitt aber jeweils das neuste JDK Enhancement Proposal (JEP) zu dem besprochenen Feature verlinkt. Die Änderungen in Java 20 werden natürlich hier beschrieben.
Foreign Function & Memory API
Von Java aus nativen Code aufzurufen ist nicht ganz so einfach: Das Java Native Interface (JNI) erfordert eine Reihe von Artefakten und oft wird eine Kette von Werkzeugen eingesetzt, um diese zu erstellen. Gerade wenn sich die native API zügig weiterentwickelt, kann die Anbindung an Java sehr mühselig sein. Und dann wäre da noch die Speicherverwaltung. Weil Java-Objekte mit JNI zu übergeben langsam ist, verwenden viele Entwickler Unsafe, um Speicher außerhalb des Heaps zu reservieren, und übergeben dann nur die Speicheradresse. Das macht den Java-Code natürlich sehr anfällig.
Die "Foreign Function API" und die "Foreign Memory API" (zusammen "FFM APIs") sind angetreten, um diese Probleme zu lösen. Der Aufruf von nativem Code wird dabei durch die in Java 7 eingeführten Method Handles umgesetzt, was die Interaktion wesentlich einfacher macht. Dazu wurden die Klassen Linker, FunctionDescriptor und SymbolLookup sowie das Werkzeug jextract (das außerhalb des JDKs lebt [22]) eingeführt. Die Verwaltung von Off-Heap-Speicher wird durch eine ganze Reihe neuer Typen abgebildet:
- MemorySegment und SegmentAllocator um Speicher zu allokieren
- MemoryLayout und VarHandle um strukturiert darauf zuzugreifen
- SegmentScope und Arena um die (De-)Allokation zu kontrollieren
Zusammen kann das dann so aussehen wie im folgenden Beispiel. Hier wird ein Array von Java-Strings mit der C-Funktion radixsort sortiert:
// 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 end of try-with-resources block
}
Für fortgeschrittene Experimente mit der Foreign Memory API empfehle ich zwei Artikel meines Kollegen Per Minborg [23;24]. Die FFM APIs gab es erst als Inkubator und in Java 20 zum zweiten Mal als Vorschau [25]. Die Struktur ist mittlerweile sehr stabil, aber an der Oberfläche wurde gegenüber Java 19 einiges verschoben:
- Die Typen Arena und SegmentScope sind aus der entfernten MemorySession hervorgegangen.
- MemorySegment hat sich die Abstraktion MemoryAddress einverleibt.
- Die verschlossene ("sealed") Vererbungshierarchy von MemoryLayout wurde verbessert, um besser mit Pattern Matching zu interagieren.
Apropos Pattern Matching...
Pattern Matching
Polymorphie, also unterschiedliches Verhalten je nach Typ, implementiert man in Java in erster Linie durch das Überschreiben von Methoden innerhalb einer Vererbungshierarchie. Das Interface Collection definiert die Methode add und jede Collection – von ArrayList zu HashSet – implementiert es passend zur eigenen Datenstruktur. Manchmal aber ist es unerwünscht oder sogar unmöglich neue Funktionalität als Methode auf einer Vererbungshierarchie zu implementieren. Ob das daran liegt, dass man zentrale Domänentypen nicht überladen will oder die infragekommenden Typen gar nicht unter eigener Kontrolle hat, manchmal muss man Polymorphie "von außen" implementieren. Das Entwurfsmuster dazu ist das Visitor Pattern, aber das besticht nicht gerade durch Einfachheit und Lesbarkeit.
Java entwickelt gerade eine bessere Alternative dazu, bzw. ganz allgemein für die Anforderung, den Programmfluss nach Typen und Objekteigenschaften aufzuteilen. Will man also z. B. die Fläche eines Shapes nicht als Methode Shape::area implementieren, sondern "von außen", ginge das so:
public static double area(Shape shape) {
return switch (shape) {
case Circle(var radius) -> radius * radius * Math.PI;
case Rectangle(var width, var height) -> width * height;
};
}
Hier kommen verschiedene Dinge zusammen:
- Zu allererst der switch, der Pattern Matching auf Objekte anwendet. Type Patterns werden seit Java 16 in instanceof unterstützt und in Java 20 gibt es den mittlerweile vierten Preview dafür in switch [26].
- Genauer werden hier allerdings nicht Type Patterns sondern Record Patterns verwendet, die sich in Java 20 im zweiten Preview befinden [27]. Sie erlauben, Records in ihre konstituierenden Komponenten zu zerlegen.
- Zuletzt fällt auf, dass der switch für Shape-Instanzen, die weder ein Circle noch ein Rectangle sind nicht definiert ist. Das ist möglich, wenn Shape ein "sealed"-Interface ist, das nur diese beiden Klassen als Implementierungen zulässt.
Damit das alles so funktionieren kann, müssen Shape, Circle und Rectangle also wie folgt definiert sein:
sealed interface Shape { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
In Java 20 wurde an diesen beiden Vorschau-Features weitestgehend an den Randfällen gearbeitet:
- Sollte durch Erweiterung eines sealed-Typen ein switch nicht mehr erschöpfend sein (im obigen Beispiel z. B. durch Triangle extends Shape) und dies nicht vom Compiler abgefangen werden (weil der switch nicht zusammen mit Shape kompiliert wird), gibt es statt eines IncompatibleClassChangeError jetzt eine MatchException.
- In switch und Record Patterns wird der generische Typ abgeleitet und muss nicht mehr explizit angegeben werden.
- Record Patterns können auch in Schleifen verwendet werden:
List<Circle> circles = // ...
for (Circle(var radius) : circles)
// use `radius`
- Named Patterns sind erstmal wieder raus, d. h. während man in Java 19 z. B. noch case Circle(var r) c schreiben konnte, um auch die Variable Circle c zu deklarieren, geht das in Java 20 nicht mehr, weil das zu einer missverständlichen Grammatik geführt hat [28].
Da ist also noch etwas Bewegung drin, aber ich hoffe, dass zumindest Pattern Matching in switch jetzt soweit ist, dass es keine weitere (fünfte!) Vorschau geben muss. Das hätte auch den angenehmen Vorteil, dass das Feature in Java 21 – der nächsten LTS-Version – finalisiert und dann in der Praxis nutzbar ist.
Virtuelle Threads
Code, der bei blockenden Anfragen an externe System, z. B. das Dateisystem oder die Datenbank, einen Betriebssystem-Thread (OS-Thread) blockiert, ist einfach zu schreiben, debuggen und profilen, aber er hält mit dem OS-Thread während dieser Anfragen eine beschränkte Ressource fest. Je nach Anforderungsprofil der Anwendung kann dann genau diese Ressource der beschränkende Faktor für die Skalierung werden und der einzige Grund für den Start eines weiteren Servers – nicht, dass den anderen die CPU-Zeit oder der Speicher für Java-Objekte ausgegangen ist, sondern die OS-Threads.
Diesen Teufel kann man mit dem Beelzebub austreiben und die Anwendung reaktiv implementieren. Dazu werden großflächig Typen wie CompletableFuture oder gleich Reactive Streams, wie sie z. B. RxJava zur Verfügung stellt, verwendet. Diese nutzen OS-Threads nur dann, wenn sie auch wirklich benötigt werden – ansonsten warten sie (beinahe) umsonst. Das macht den Code wesentlich skalierbarer, allerdings auch schwieriger zu schreiben und insbesondere unübersichtlicher beim Debuggen und Profilen.
Virtuelle Threads vereinen die besten Eigenschaften dieser beiden Ansätze: Mit ihnen kann man wie gewohnt blockierenden Code schreiben, debuggen und profilen, während die JVM unter der Haube sicherstellt, dass der virtuelle Thread nur dann einen OS-Thread belegt, wenn er ihn auch benötigt und nicht, wenn er auf ein externes System wartet – in der Zeit kann der OS-Thread einen anderen virtuellen Thread ausführen. So kann man um Größenordnungen mehr virtuelle als "echte" Threads haben und selbst ein Laptop kann ohne Probleme Millionen von virtuellen Threads warten lassen.
Java 19 hat virtuelle Threads als Vorschau-Feature eingeführt und in Java 20 gibt es davon eine zweite Runde [29]. Änderungen gegenüber der ersten Vorschau gibt es quasi keine – nur einige kleine Erweiterungen von existierenden APIs (wie z. B. neuen Methoden auf Thread und Future) sind nicht mehr Teil der Vorschau. Sie sind unabhängig von virtuellen Threads nützlich und wurden, nachdem sie in Java 19 vorgestellt wurden, in Java 20 finalisiert.
Teil dieser Vorschau sind auch API-Erweiterungen, um virtuelle Threads zu erstellen, aber diese werden im Entwickleralltag keine große Rolle spielen:
- In Web-Anwendungen erstellt der App-Server bzw. das Web-Framework die Threads, welche die Web-Requests ausführen. Damit dies in Zukunft virtuelle Threads sind, müssen die Server/Frameworks angepasst werden und wir Entwickler werden sie vermutlich einfach per Konfiguration aktivieren.
- Für Gleichzeitigkeit innerhalb der Anwendung, z. B. beim Absetzen von Anfragen an externe Services, verwendet man besser die strukturierte Variante. Und mit der geht es jetzt weiter...
Strukturierte Gleichzeitigkeit
Dadurch, dass virtuelle Threads so ressourcenschonend sind, braucht man sich keine Gedanken darüber zu machen, wann und wo im Code sie erstellt werden. Im Gegenteil ist es absolut in Ordnung, an jeder Stelle, an der Aufgaben gleichzeitig durchgeführt werden sollen, virtuelle Threads zu starten.
Damit diese Art von Concurrency lesbar bleibt, empfiehlt Java, sie strukturiert zu implementieren und das Starten, Abwarten und Beenden von (virtuellen) Threads im gleichen Scope stattfinden zu lassen. Dafür wurde in Java 19 eine neue API inkubiert: der StructuredTaskScope. Hier eine beispielhafte Verwendung, bei der eine Reihe von Aufgaben (in Form von Callable<T>) ausgeführt werden sollen, wobei nach erfolgreichem Abschluss der schnellsten Aufgabe die anderen abgebrochen werden können und es eine Deadline gibt, bei der alle abgebrochen werden:
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 diesem Beispiel sieht man gleiche zwei Stärken dieser API:
- Die Gleichzeitigkeit ist auf eine Methode beschränkt und so unmittelbar nachvollziehbarer.
- Die Koordinierung der Aufgaben (in diesem Beispiel "Shutdown on Success", aber es gibt andere) ist einfach.
Nicht ganz so offensichtlich, aber extrem hilfreich beim Debuggen und Profilen, ist die Eltern-Kind-Beziehung, die hier implizit hergestellt wird. Ein Thread führt die Methode race aus und wartet in joinUntil, während die von ihm erstellten Forks ihre jeweilige Aufgabe erledigen. In dieser Zeit ist der wartende Thread das Elternteil und die Forks sind seine Kinder. Dies ist nicht nur konzeptionell so, sondern wird auch von der JVM so verstanden, denn der StructuredTaskScope sorgt dafür, dass die Kinder-Threads die ID des Eltern-Threads kennen.
Ganz praktisch bedeutet das, dass man in einem Breakpoint oder Thread Dump bei jedem so gestarteten Thread nicht nur dessen Stack sieht, sondern über die Eltern-Kind-Beziehung auch zu den Eltern-Threads und deren Vorfahren navigieren kann.
Steht also z. B. eine der Aufgaben im obigen Beispiel in einem Breakpoint, kann man sehen, dass sie das Kind des Threads ist, der gerade in race wartet und auch dessen Zustand analysieren. Für das Debuggen und Profilen von gleichzeitigen Anwendungen, das sonst oft in den uninformativen Stack Elements eines Thread Pools endete, wird das eine riesige Erleichterung sein!
In Java 20 wurde StructuredTaskScope unverändert übernommen [30].
Scoped Values
Eine API die zwar korrekt, aber nicht besonders performant oder ressourcenschonend mit virtuellen Threads interagiert ist Thread Locals. Sie wird verwendet, um thread-spezifische Informationen zu hinterlegen, üblicherweise in static final-Variablen, die dann von überall, wo diese Variable sichtbar ist, abgefragt werden kann. Im folgenden Beispiel ist die Methode Server::serve dafür zuständig, einen Request zur Beantwortung an die Anwendung weiterzuleiten, hinterlegt aber vorher einen Principal in einem ThreadLocal, so dass dieser von anderem Code, der Server sieht, abgefragt werden kann, ohne ihn als Parameter durchzureichen:
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);
}
}
Die Schwächen von ThreadLocal sind:
- Jeder mit Zugriff auf PRINCIPAL kann nicht nur den Principal lesen, sondern auch einen neuen setzen.
- Werte, die in ThreadLocal hinterlegt wurden, können von einem Thread an andere vererbt werden. Um dann zu verhindern, dass beim Setzen eines neuen Wertes in einem Thread die anderen diesen lesen (was die API explizit unterbinden soll, da Thread "Local"), muss der erbende Thread Kopien anlegen. Die treiben den Speicherbedarf insbesondere bei vielen Threads ("Millionen von virtuellen Threads") in die Höhe.
- Einmal gesetzte Werte müssen explizit wieder entfernt werden (mit der Methode ThreadLocal::remove) oder sie "leaken" über ihre intendierte Verwendung hinaus und belegen weiterhin Speicher.
Um diese Probleme zu lösen, wird in Java 20 erstmals die Scoped Values API inkubiert [31].
Das obige Beispiel lässt sich mit ihr wie folgt implementieren:
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));
}
}
Auch hier wird per Thread eine andere Information hinterlegt, aber es gibt einige entscheidende Unterschiede zu Thread Locals:
- Nachdem ein Wert mit where gebunden wurde, kann kein anderer gesetzt werden.
- Dementsprechend müssen beim Vererben keine Kopien angelegt werden, was die Skalierbarkeit deutlich verbessert.
- Wie der Name impliziert, ist ein Scoped Value nur innerhalb des definierten Scopes, d. h. innerhalb der run-Methode, sichtbar – danach wird der gebundene Wert automatisch entfernt und kann so nicht versehentlich "leaken". Im Beispiel kann also nur solcher Code den principal in PRINCIPAL sehen, der vom an run übergebenen Lambda aus direkt oder indirekt aufgerufen wird.
Migrationsherausforderungen
Java entwickelt sich also in vielen kleinen und großen Schritten weiter. Nach über 25 Jahren gehört zu dieser Weiterentwicklung aber auch, alte Entscheidungen, die nicht mehr in die Zeit passen, rückgängig zu machen und so werden einige Technologien und APIs behutsam entfernt. Dies betrifft:
- Applet API
- Security Manager
- Konstruktoren von value-based Classes
- Finalization
- einige Methoden auf Thread und ThreadGroup
Für die Hintergründe und den aktuellen Stand dieser Deprecations for Removal empfehle ich eines meiner Videos [32]. Eine Liste der endgültigen Deprecations findet man auch im Javadoc [33].
In Java 20 schreitet nur die Entfernung der Methoden auf Thread voran: Bei suspend(), resume() [34] und stop() [35] wurde die Implementierung ausgehöhlt – sie werfen jetzt eine UnsupportedOperationException. Darüber hinaus gibt es aber oft auch kleine Änderungen, die bei einer Migration beachtet werden müssen. In Java 20 umfasst dies:
- Die oben beschriebene Notwendigkeit, mit den System Properties jdk.jndi.ldap.object.factoriesFilter und jdk.jndi.rmi.object.factoriesFilter eigene Object Factories zu erlauben.
- Die G1-Optionen, die oben aufgelistet sind, erzeugen jetzt Warnungen und sollten nicht mehr verwendet werden.
- Beim Konvertieren extrem großer Stylesheets zu Java-Objekten mit XSLT kann neuerdings ein "Internal XSLTC error" auftreten, der durch das Aufsplitten der Stylesheets umgangen werden kann [36].
- Der standardmäßige Timeout für inaktive HTTP/1.1- und HTTP/2-Verbindungen, die mit dem java.net.http.HttpClient erstellt wurden, ist von 1200 auf 30 Sekunden reduziert worden [37].
- IdentityHashMap hat bei der Implementierung der Methoden remove(key, value) und replace(key, oldValue, newValue) Vergleiche von Werten (also value, nicht key) fälschlicherweise auf Basis von Gleichheit (equals) statt Identität (==) durchgeführt – dies ist jetzt nicht mehr der Fall [38].
- Konstruktoren der Klasse URL prüfen die übergebenen Strings jetzt strikter darauf, ob es sich um valide URLs handelt, und werfen dementsprechend öfter eine MalformedURLException [39]. Vor dieser Änderung wurden manche fehlerhaften URLs erst beim Öffnen der Verbindung entdeckt und die Excpetion dann geworfen – dieses Verhalten kann durch Setzen der System Property jdk.net.url.delayParsing wiederhergestellt werden.
Zusammenfassung
So langweilig Java 20 ohne große finalisierte Features an der Oberfläche wirken mag, so wichtig sind Releases wie dieses für Javas fortgesetzten Erfolg. Ob Sicherheit oder Performance, Beobachtbarkeit oder Tooling, existierende APIs oder kommende Features – Version 20 entwickelt Java an allen Fronten weiter. Und – ganz ehrlich – ein bisschen Ruhe zwischen bahnbrechenden Veränderungen tut auch ganz gut. Wer will schon alle sechs Monate ein neues Java 9?
[1] jdk-20-ea-release-notes; [2] jdk-8256660; [3] jdk-8279164; [4] Wikipedia: Perfect Forward Secrecy; [5] jdk-8281236; [6] jdk-8290368; [7] Youtube: From Java Security with Love; [8] jdk-8288047; [9] jdk-8247645; [10] jdk-8137022; [11] jdk-8293861; [12] Oracle: About Java Flight Recorder; [13] Youtube: Programmer's Guide to JDK Flight Recorder; [14] jdk-8292177; [15] jdk-8254711; [16] jdk-8297247; [17] Oracle: Simple Assignment Operator =; [18] Oracle: Compound Assignment Operators; [19] jdk-8244681; [20] jdk-8293499; [21] jdk-8284842; [22] Github: jextract; [23] Java 20: Colossal Sparse Memory Segments; [24] Java 20: An Almost Infinite Memory Segment Allocator; [25] jep-434; [26] jep-433; [27] jep-432; [28] Draft JEPs: Pattern Matching for switch and Record Patterns; [29] jep-436; [30] jep-437; [31] jep-429; [32] Youtube: Future Java - Prepare Your Codebase Now!; [33] Oracle: Terminally Deprecated Elements; [34] jdk-8249627; [35] jdk-8289610; [36] jdk-8290347; [37] jdk-8297030; [38] jdk-8178355; [39] jdk-8293590;