Mit der Nummer 19 erschien Ende September die neueste Auflage der weitverbreiteten Programmiersprache Java. Das Feature Release bringt wieder eine ganze Reihe Neuerungen in Sprache und Laufzeitumgebung.
Zwei neue Java-Versionen pro Jahr – dieses Versprechen hat das Entwicklungsteam um Mark Reinhold auch diesmal halten können. Seit Ende September steht Java 19 [1] zur allgemeinen Verwendung bereit, etwa zur Halbzeit zwischen dem letzten Langzeit-Release Java 17 und dem für Java 21 geplanten nächsten.
Java 19 bringt sieben neue Features mit, mit kleineren Änderungen zusammen sind es insgesamt 18. Das Release steht unter Linux für sieben verschiedene Plattformen zur Verfügung [2]. Mit dem Port auf die RISC-V-Architektur (JEP 422) unterstützt die Sprache nun auch den aktuell besten Kandidaten für freie und offene Hardware. Anders als bei x64- oder ARM-basierten Rechnern vom Raspberry Pi bis zum Supercomputer besteht bei RISC-V eine realistische Chance, offene Software auf offener Hardware betreiben zu können: Die an der University of California entwickelte Architektur hat es sich zum Ziel gesetzt, alles von der CPU über das BIOS bis hin zu den Treibern als offene und freie Spezifikation zur Verfügung zu stellen [3].
Fremde Federn
Im Projekt Panama arbeiten die Entwickler seit Java 14 an einer neuen Brücke zwischen Java und C-basierten Sprachen. Mit JEP 424 wechselt die Foreign Function & Memory API aus dem Incubator- in den Preview-Status und lässt sich damit für experimentellen Code nutzen. Ein einfaches Beispiel findet sich in Listing 1, wo die Funktion »strlen« aus der C-Bibliothek aufgerufen wird.
Zeile 2 beschreibt die gesuchte Funktion passend zur Angabe im Header mit ihrem Rückgabewert (hier »JAVA_LONG«) und allen Eingabeparametern (hier ein Pointer). Das in Zeile 3 angefragte Objekt »SymbolLookup« dient in Zeile 4 dazu, ein »MethodHandle« als Referenz zu der Funktion zu erstellen. Neben dem Namen kommt dazu die vorher erstellte »FunctionDescription« zum Einsatz.
Vor dem Aufruf der C-Funktion gilt es, die Eingabewerte aus der JVM in einen separaten Speicherbereich zu kopieren, was der »SegmentAllocator« in Zeile 5 übernimmt. Zeile 6 ruft schließlich die C-Funktion mittels des Handles auf. Der komplette Code steht unter [6] zum Download bereit, wie stets bei Preview-Features müssen Sie den Java-Compiler und die JVM mit der Kommandozeilenoption »–enable-preview« starten.
Listing 1
Foreign Function & Memory API
// Header size_t strlen(const char *str)
FunctionDescriptor header = FunctionDescriptor.of(JAVA_LONG, ADDRESS);
SymbolLookup lookup = Linker.nativeLinker().defaultLookup();
MethodHandle strlenHandle = Linker.nativeLinker().downcallHandle(lookup.lookup("strlen").orElseThrow(),header);
MemorySegment cstring = SegmentAllocator.implicitAllocator().allocateUtf8String("Linux Magazin");
long len = (long) strlenHandle.invoke(cstring);
Mit der Foreign Function & Memory API lassen sich anders als mit dem alten Java Native Interface (JNI) C-Funktionen ausführen, ohne C-Code zu schreiben und zu kompilieren. Auch ein Crash in der C-Welt reißt die JVM nicht gleich mit. Damit bleibt die Definition der »MethodHandles« als Aufgabe für die Entwickler. Da alle dafür benötigten Informationen bereits in den C-Header-Files vorliegen, lässt sich diese Aufgabe mit dem Werkzeug Jextract [4] automatisieren. Es nutzt den CLANG Compiler [5] zum Parsen der Header-Files und erzeugt für jede C-Funktion das Äquivalent zu den Zeilen 2 bis 4 aus Listing 1. Das funktioniert auch für komplexe APIs wie OpenGL komplett automatisch und erspart viel Handarbeit. Allerdings ist Jextract kein Teil des JDKs, Sie müssen es separat herunterladen und kompilieren.
Noch näher an der CPU operiert die Vector-API (JEP 426). Über sie lassen sich die in den meisten modernen CPUs enthaltenen Befehle für Berechnungen auf Arrays direkt aus Java heraus ansprechen. Das kann die CPU wesentlich zügiger abarbeiten, als die Operationen Stück für Stück in einer höheren Programmiersprache auszuführen.
Viel Aufmerksamkeit widmen die Developer diesem Spezialthema allerdings offenbar nicht, denn die Entwicklung läuft schon seit Java 16 und steckt noch immer im Incubator-Status. Das ist neuer Rekord – kein anderes Thema hat bisher vier Incubator-Runden benötigt. Im aktuellen JEP 426 zeigt sich die Vector-API besser mit der Foreign Function & Memory API abgestimmt, und es kamen ein paar zusätzliche Operationen beispielsweise für bitweise Operationen hinzu.
Alltag
Aber genug von den Arbeiten im Java-VM-Keller; kommen wir zu den Sprachänderungen, die Otto Normalentwickler nutzen wird. Hier finden sich vor allem Änderungen an der Syntax, die schöneren und unproblematischeren Code versprechen.
Ganz oben auf der List steht das dritte (und hoffentlich letzte) Preview des Switch-Statements (JEP 427). In den letzten Java-Versionen wurde der Arrow-Operator (»->«) zusätzlich zum altehrwürdigen Doppelpunkt eingeführt, um das gefürchtete Durchfallen zum nächsten Wert zu unterbinden. In Java 18 kam die Typprüfung hinzu. Sie erhält nun etwas Feinschliff wie die Behandlung von »null«-Werten und die kombinierte Typ- und Wertprüfung.
Wie Listing 2 zeigt, kann man nun explizit nach »null« suchen (Zeile 2) beziehungsweise ein oder mehrere passenden Werte als Kriterium angeben (Zeile 3). Darüber hinaus kann man wie in Zeile 9 und 10 den Wert nach einer Typprüfung gleich zuweisen lassen und mit dem neuen Statement »when« auch weitere Prüfungen vornehmen. Dabei gehören »case« und »when« paarweise zusammen, man darf nicht mehrere »when«-Statements mit einem »case« kombinieren.
Eine weitere Syntaxänderung bringt Record Patterns (JEP 405). Der Operator »instanceof« kann nicht nur den Record selbst bereitstellen (Listing 3, Zeile 7), sondern auch auf direkte (Zeile 10) oder geschachtelte Werte zugreifen (Zeile 13).
Listing 2
Switch Statement
switch (testString) {
case null -> System.out.println(testString + ": ???");
case "Harburg", "Marmstorf" -> System.out.println(testString + ": Fall 1");
case "Heimfeld" -> System.out.println(testString + ": Fall 2");
default -> System.out.println(testString + ": Default");
}
switch (testObject) {
case null -> System.out.println(testObject + ": ???");
case LocalDate date
when date.getMonthValue() ==1 -> System.out.println(date + ": im Januar");
case LocalDate date -> System.out.println(date + ": im Monat Nr. " + date.getMonthValue());
case Double d -> System.out.println(d + ": Double");
default -> System.out.println(testObject + ": Default");
}
Listing 3
Record Pattern
record Punkt(double x, double y) {}
record Hülle (Punkt min, Punkt max) {}
Punkt p1 = new Punkt(1,0);
Punkt p2 = new Punkt(2,4);
Hülle h = new Hülle(p1,p2);
for( Object o : new Object[] {p1, h}) {
if ( o instanceof Punkt p) {
System.out.println("Punkt: " + p);
}
if (o instanceof Punkt(double x, double y)) {
System.out.println("Punkt x:"+ x + ", y:" + y);
}
if (o instanceof Hülle(Punkt i, Punkt a)) {
System.out.println("Hülle min:" + i + ", max:" + a);
}
}
Nebenläufig
Die Programmierung mit Threads zählte von Anfang zu den Kern-Features von Java, Programmierer nebenläufiger Prozesse nutzen in der Regel dieses Konstrukt. So bekommt beispielsweise jede Anfrage an einen Server einen eigenen Thread, der auch gleich Informationen zu anfragenden Usern, dem Login-Status und anderem mehr eine Heimat bietet.
Allerdings wird jeder Java-Thread direkt auf einen Thread im Betriebssystem abgebildet, womit man schon bei wenigen Hundert Threads in Probleme läuft. Das schränkt den Durchsatz des Servers ziemlich ein: Bei 100 Threads mit einer Bearbeitungszeit von je einer halben Sekunde lassen sich nur 200 Anfragen pro Sekunde beantworten. Dabei könnte die Hardware in der Regel viel mehr, da viele Anfragen ja keine durchgehenden Berechnungen erfordern, sondern viel Zeit beim Warten auf andere Dienste, Datenbanken oder Dateizugriffe verbringen.
Hier sollen virtuelle Threads Abhilfe schaffen, von denen sich jeweils mehrere einen nativen Thread teilen. Muss einer von ihnen warten, bearbeitet der native Thread einfach den nächsten. Da die virtuellen Threads nur in der JVM vorliegen, die dann viel weniger native Threads beim Betriebssystem anfragt, können nun mehrere Zehntausend Threads auf einem normalen Server laufen. Das beschleunigt zwar die Bearbeitung selbst nicht, denn externe Wartezeiten und CPU-Belastung bleiben ja gleich. Man kann aber jeder Anfrage sofort einen Thread zuordnen und die Anzahl der bearbeiteten Anfragen erhöhen.
Damit die Verteilung der virtuellen auf die nativen Threads gelingt, waren Änderungen quer durch die ganze JVM notwendig. Sie umfassten neben den virtuellen Threads auch die Netzwerk- und I/O-APIs, die Debugger-Schnittstellen und JMX. Deshalb wurde dieses Feature auch komplett parallel zum normalen OpenJDK entwickelt und steht nach dem Merge für JEP 428 gleich im Preview-Status bereit. Der Lohn der Mühe: Die virtuellen Threads unterscheiden sich in der Verwendung nicht wesentlich von den bisherigen (nativen) Threads.
In Listing 4 gibt es eine Klasse »Aufgabe«, die als Callable einen Job erledigt und dabei mit »Thread.sleep« auf eine Antwort wartet. Zeile 4 übergibt in einem Rutsch eine Liste dieser Aufgaben an den normalen »ExecutorService«, der sie dann mit »numThreads« Threads bearbeitet und die Ergebnisse in der Schleife ab Zeile 5 abfragt. Die Durchlaufzeit beträgt bei hundert Aufgaben mit je einer Sekunde Wartezeit und zehn Threads insgesamt 10 Sekunden. Verwendet man dagegen wie in Zeile 9 einen »ExecutorService« mit virtuellen Threads, sinkt die Durchlaufzeit auf nur gut eine Sekunde (die Wartezeit), da alle hundert Aufgaben parallel laufen können.
Listing 4
Virtual Threads
class Aufgabe implements Callable<Integer> { ... }
List<Aufgabe> aufgaben = ...
ExecutorService nativeExecutor = Executors.newFixedThreadPool(numThreads);
List<Future<Integer>> futures = nativeExecutor.invokeAll(aufgaben);
long sum = 0;
for (Future<Integer> future : futures) {
sum += future.get();
}
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
// wie Zeile 5 bis 8
Zugegeben, das ist ein extremes Beispiel, da die Aufgabe nur aus Wartezeit besteht – gerade bei der Microservice-Architektur geht durch die Verteilung beim zuerst aufgerufenen Dienst viel Zeit beim Warten auf einen anderen Dienst verloren. Mit den virtuellen Threads können Clients nun aber viel mehr Anfragen entgegennehmen, ohne das Betriebssystem zu überlasten. Wer die Thread-Verwaltung in seiner Anwendung selbst umsetzt, kann das mit Java 19 bereits direkt angehen. Anwender von Apache Tomcat oder Spring Boot müssen sich noch eine Weile gedulden: Hier arbeiten die Projekte noch an der Unterstützung.
Je mehr man seine Anwendung in einzelne Threads unterteilt, desto eher läuft man freilich in Fehler. Noch im Incubator-Status befindet sich die Structured Concurrency API (JEP 428). Damit lassen sich einzelne Threads zu Gruppen zusammenfassen und mit einer gemeinsamen Fehlerbehandlung versehen. Schlägt etwa die Überprüfung eines Benutzerprofils fehl, ist es sinnlos, weiter auf die Ermittlung von Einkaufsvorschlägen zu warten.
Fazit
Alles in allem macht Java 19 einen guten Schritt vorwärts. Gerade die virtuellen Threads bieten das Potenzial, mehr mit weniger Hardware zu erledigen. Auch dank der Syntaxerweiterungen lässt sich mehr Funktionalität in weniger Code packen. Sie ermöglichen jedoch nichts, was sich nicht bereits mit anderen Sprachkonstrukten erreichen ließ. Die Foreign Function & Memory API ist jetzt reif genug, um damit zu experimentieren. Hinzu kommen Pflegeaktionen wie die Unterstützung für den neuen Unicode-Standard, zusätzliche Zeitformate oder die Verbesserung am HTTPS-Channel. Solange sich allerdings viele Features noch im Preview-Modus befinden, gibt es keinen zwingenden Grund, produktiven Code auf Java 19 umzustellen. (jlu)
Der Autor
Carsten Zerbst erstellt mit seinem Team Software für Ingenieure, vom einfachen Konverter bis hin zur unternehmenskritischen Integrationslösung. Zur weiteren Verstärkung sucht er im nächsten Jahr Verstärkung für abwechslungsreiche Tätigkeiten in Hamburg.
Infos
- OpenJDK 19: https://jdk.java.net/19/
- Adoptium: https://adoptium.net
- Kern-Technik: Eva-Katharina Kunst, Jürgen Quade, “Moderne Architektur”, LM 01/2022, S. 72, https://www.lm-online.de/44748
- Jextract: https://github.com/openjdk/jextract
- CLANG: https://clang.llvm.org
- Beispiel: https://github.com/skfcz/Java19





