Seit die Java-Plattform einen sechmonatlichen Veröffentlichungszyklus eingeführt hat, sind wir über die ewigen Fragen wie „Wird Java in diesem Jahr sterben?“ oder „Lohnt sich die Migration auf die neue Version?“ hinweg. Trotz 28 Jahren seit der ersten Veröffentlichung gedeiht Java weiterhin und bleibt eine beliebte Wahl als primäre Programmiersprache für viele neue Projekte.
Java 17 war ein bedeutender Meilenstein, aber Java 21 hat nun den Platz von 17 als nächste Long-Term Support-Version (LTS) eingenommen. Es ist für Java-Entwickler wichtig, über die Änderungen und neuen Funktionen, die diese Version mit sich bringt, informiert zu bleiben. Inspiriert von meinem Kollegen Darek, der die Java 17-Funktionen in seinem Artikel detailliert beschrieben hat, habe ich beschlossen, auch über JDK 21 in ähnlicher Weise zu berichten (ich habe auch die Java 23-Funktionen in einem Folgeartikel analysiert, also schaut euch das ebenfalls an).
JDK 21 umfasst insgesamt 15 JEPs (Java Development Kit Enhancement Proposals). Ihr könnt die vollständige Liste auf der offiziellen Java-Seite einsehen. In diesem Artikel werde ich mehrere Java 21 JEPs hervorheben, die ich für besonders bemerkenswert halte. Nämlich:
- String-Templates
- Sequenzierte Sammlungen
- Mustererkennung für
switch
und Datensatzmuster - Virtuelle Threads
Ohne weitere Verzögerung, lassen Sie uns in den Code eintauchen und diese Updates erkunden.
String-Vorlagen (Vorschau)
Die Spring-Vorlagenfunktion befindet sich noch im Vorschau-Modus. Um sie zu nutzen, müssen Sie das --enable-preview
Flag zu Ihren Compiler-Argumenten hinzufügen. Dennoch habe ich beschlossen, es trotz seines Vorschau-Status zu erwähnen. Warum? Weil ich jedes Mal sehr irritiert bin, wenn ich eine Protokollnachricht oder SQL-Anweisung schreiben muss, die viele Argumente enthält oder entschlüsseln muss, welcher Platzhalter mit einem bestimmten Argument ersetzt wird. Und Spring-Vorlagen versprechen, mir (und Ihnen) dabei zu helfen.
Wie die JEP-Dokumentation sagt, ist der Zweck von Spring-Vorlagen, “das Schreiben von Java-Programmen zu vereinfachen, indem es einfach gemacht wird, Strings auszudrücken, die Werte enthalten, die zur Laufzeit berechnet werden.”
Überprüfen wir, ob es wirklich einfacher ist.
Der “alte Weg” wäre, die formatted()
Methode auf einem String-Objekt zu verwenden:
var msg = "Log message param1: %s, pram2: %s".formatted(p1, p2);
Jetzt sieht es mit StringTemplate.Processor
(STR) so aus:
var interpolated = STR."Log message param1: \{p1}, param2: \{p2}";
Bei einem kurzen Text wie dem oben mag der Vorteil nicht so offensichtlich sein – aber glauben Sie mir, wenn es um große Textblöcke (JSONs, SQL-Anweisungen usw.) geht, werden benannte Parameter Ihnen sehr helfen.
Sequenzierte Sammlungen
Java 21 hat eine neue Java-Sammlungshierarchie eingeführt. Schauen Sie sich das Diagramm unten an und vergleichen Sie es mit dem, was Sie wahrscheinlich während Ihres Programmierunterrichts gelernt haben. Sie werden feststellen, dass drei neue Strukturen hinzugefügt wurden (hervorgehoben durch die grüne Farbe).
Sequenzierte Sammlungen führen eine neue integrierte Java-API ein, die die Operationen auf geordneten Datensätzen verbessert. Diese API ermöglicht nicht nur einen bequemen Zugriff auf die ersten und letzten Elemente einer Sammlung, sondern ermöglicht auch eine effiziente Durchquerung, das Einfügen an bestimmten Positionen und das Abrufen von Teilsequenzen. Diese Verbesserungen machen Operationen, die von der Reihenfolge der Elemente abhängen, einfacher und intuitiver, verbessern sowohl die Leistung als auch die Lesbarkeit des Codes, wenn mit Listen und ähnlichen Datenstrukturen gearbeitet wird.
Dies ist die vollständige Auflistung des SequencedCollection
-Interfaces:
public interface SequencedCollection<E> extends Collection<E> {
SequencedCollection<E> reversed();
default void addFirst(E e) {
throw new UnsupportedOperationException();
}
default void addLast(E e) {
throw new UnsupportedOperationException();
}
default E getFirst() {
return this.iterator().next();
}
default E getLast() {
return this.reversed().iterator().next();
}
default E removeFirst() {
var it = this.iterator();
E e = it.next();
it.remove();
return e;
}
default E removeLast() {
var it = this.reversed().iterator();
E e = it.next();
it.remove();
return e;
}
}
Statt:
var first = myList.stream().findFirst().get();
var anotherFirst = myList.get(0);
var last = myList.get(myList.size() - 1);
können wir einfach schreiben:
var first = sequencedCollection.getFirst();
var last = sequencedCollection.getLast();
var reversed = sequencedCollection.reversed();
Dies ist eine kleine Änderung, aber meiner Meinung nach ist es eine so praktische und nützliche Funktion.
Mustererkennung und Datensatzmuster
Wegen der Ähnlichkeit zwischen Pattern Matching für switch
und Record Patterns werde ich sie zusammen beschreiben. Record Patterns sind ein neues Feature: Sie wurden in Java 19 (als Vorschau) eingeführt. Auf der anderen Seite ist Pattern Matching für switch
gewissermaßen eine Fortführung des erweiterten instanceof
Ausdrucks. Es bringt eine neue mögliche Syntax für switch
Anweisungen mit sich, die es Ihnen ermöglicht, komplexe datengestützte Abfragen leichter auszudrücken.
Lassen Sie uns der Einfachheit halber die Grundlagen der OOP vergessen und das Mitarbeiterobjekt manuell dekonstruiert (employee
ist eine POJO-Klasse).
Vor Java 21 sah es so aus:
if (employee instanceof Manager e) {
System.out.printf("I’m dealing with manager of %s department%n", e.department);
} else if (employee instanceof Engineer e) {
System.out.printf("I’m dealing with %s engineer.%n", e.speciality);
} else {
throw new IllegalStateException("Unexpected value: " + employee);
}
Was wäre, wenn wir das hässliche instanceof
loswerden könnten? Nun, das können wir jetzt, dank der Kraft des Pattern Matching aus Java 21:
switch (employee) {
case Manager m -> printf("Manager of %s department%n", m.department);
case Engineer e -> printf("I%s engineer.%n", e.speciality);
default -> throw new IllegalStateException("Unexpected value: " + employee);
}
Während wir über die switch
Anweisung sprechen, können wir auch das Feature der Record Patterns diskutieren. Bei der Arbeit mit einem Java Record erlaubt es uns, viel mehr zu tun als mit einer Standard-Java-Klasse:
switch (shape) { // shape is a record
case Rectangle(int a, int b) -> System.out.printf("Area of rectangle [%d, %d] is: %d.%n", a, b, shape.calculateArea());
case Square(int a) -> System.out.printf("Area of square [%d] is: %d.%n", a, shape.calculateArea());
default -> throw new IllegalStateException("Unexpected value: " + shape);
}
Wie der Code zeigt, sind mit dieser Syntax die Record-Felder leicht zugänglich. Darüber hinaus können wir zusätzliche Logik in unsere Fallanweisungen einfügen:
switch (shape) {
case Rectangle(int a, int b) when a < 0 || b < 0 -> System.out.printf("Incorrect values for rectangle [%d, %d].%n", a, b);
case Square(int a) when a < 0 -> System.out.printf("Incorrect values for square [%d].%n", a);
default -> System.out.println("Created shape is correct.%n");
}
Wir können eine ähnliche Syntax für die if
Anweisungen verwenden. Außerdem sehen wir im folgenden Beispiel, dass Record Patterns auch für verschachtelte Records funktionieren:
if (r instanceof Rectangle(ColoredPoint(Point p, Color c),
ColoredPoint lr)) {
//sth
}
Virtuelle Threads
Das Feature der virtuellen Threads ist wahrscheinlich das heißeste unter all den Java 21-Funktionen – oder zumindest eines, auf das die Java-Entwickler am meisten gewartet haben. Wie die JEP-Dokumentation (im vorherigen Satz verlinkt) sagt, war eines der Ziele der virtuellen Threads, “Serveranwendungen, die im einfachen Thread-per-Request-Stil geschrieben sind, zu ermöglichen, mit nahezu optimaler Hardwareauslastung zu skalieren”. Bedeutet das jedoch, dass wir unseren gesamten Code, der java.lang.Thread
verwendet, migrieren sollten?
Zuerst lassen Sie uns das Problem mit dem Ansatz untersuchen, der vor Java 21 existierte (tatsächlich seit der ersten Veröffentlichung von Java). Wir können approximieren, dass ein java.lang.Thread
(je nach Betriebssystem und Konfiguration) etwa 2 bis 8 MB Speicher verbraucht. Wichtig ist jedoch, dass ein Java-Thread 1:1 einem Kernel-Thread zugeordnet ist. Für einfache Webanwendungen, die einen „einen Thread pro Anfrage“-Ansatz verwenden, können wir leicht berechnen, dass entweder unsere Maschine „getötet“ wird, wenn der Verkehr zunimmt (sie wird die Last nicht bewältigen können), oder wir gezwungen sind, ein Gerät mit mehr RAM zu kaufen, und unsere AWS-Rechnungen dadurch steigen.
Natürlich sind virtuelle Threads nicht der einzige Weg, dieses Problem zu lösen. Wir haben asynchrone Programmierung (Frameworks wie WebFlux oder die native Java-API wie CompletableFuture
). Aus irgendeinem Grund – vielleicht wegen der “unfreundlichen API” oder der hohen Einstiegshürde – sind diese Lösungen jedoch nicht so beliebt.
Virtuelle Threads werden nicht vom Betriebssystem überwacht oder geplant. Stattdessen wird ihre Planung von der JVM übernommen. Während echte Aufgaben in einem Plattform-Thread ausgeführt werden müssen, verwendet die JVM sogenannte Träger-Threads — im Grunde Plattform-Threads — um jeden virtuellen Thread zu „tragen“, wenn er zur Ausführung ansteht. Virtuelle Threads sind so konzipiert, dass sie leichtgewichtig sind und viel weniger Speicher als Standard-Plattform-Threads benötigen.
Das Diagramm unten zeigt, wie virtuelle Threads mit Plattform- und Betriebssystem-Threads verbunden sind:
Um zu sehen, wie virtuelle Threads von Plattform-Threads verwendet werden, lassen Sie uns Code ausführen, der (1 + Anzahl der CPUs, die die Maschine hat, in meinem Fall 8 Kerne) virtuelle Threads startet.
var numberOfCores = 8; //
final ThreadFactory factory = Thread.ofVirtual().name("vt-", 0).factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
IntStream.range(0, numberOfCores + 1)
.forEach(i -> executor.submit(() -> {
var thread = Thread.currentThread();
System.out.println(STR."[\{thread}] VT number: \{i}");
try {
sleep(Duration.ofSeconds(1L));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
}
Die Ausgabe sieht folgendermaßen aus:
[VirtualThread[#29,vt-6]/runnable@ForkJoinPool-1-worker-7] VT number: 6
[VirtualThread[#26,vt-4]/runnable@ForkJoinPool-1-worker-5] VT number: 4
[VirtualThread[#30,vt-7]/runnable@ForkJoinPool-1-worker-8] VT number: 7
[VirtualThread[#24,vt-2]/runnable@ForkJoinPool-1-worker-3] VT number: 2
[VirtualThread[#23,vt-1]/runnable@ForkJoinPool-1-worker-2] VT number: 1
[VirtualThread[#27,vt-5]/runnable@ForkJoinPool-1-worker-6] VT number: 5
[VirtualThread[#31,vt-8]/runnable@ForkJoinPool-1-worker-6] VT number: 8
[VirtualThread[#25,vt-3]/runnable@ForkJoinPool-1-worker-4] VT number: 3
[VirtualThread[#21,vt-0]/runnable@ForkJoinPool-1-worker-1] VT number: 0
Also, ForkJonPool-1-worker-X
Plattform-Threads sind unsere Träger-Threads, die unsere virtuellen Threads verwalten. Wir beobachten, dass die virtuellen Threads Nummer 5 und 8 denselben Träger-Thread Nummer 6 verwenden.
Das Letzte, was ich Ihnen über virtuelle Threads zeigen möchte, ist, wie sie Ihnen bei den blockierenden I/O-Operationen helfen können.
Wann immer ein virtueller Thread auf eine blockierende Operation stößt, wie z.B. I/O-Aufgaben, trennt die JVM ihn effizient vom zugrunde liegenden physischen Thread (dem Träger-Thread). Diese Trennung ist entscheidend, da sie den Träger-Thread freigibt, um andere virtuelle Threads auszuführen, anstatt untätig zu sein und auf den Abschluss der blockierenden Operation zu warten. Infolgedessen kann ein einzelner Träger-Thread viele virtuelle Threads multiplexieren, die je nach verfügbarem Speicher und Art der durchgeführten Aufgaben in die Tausende oder sogar Millionen gehen können.
Lass uns versuchen, dieses Verhalten zu simulieren. Dazu werden wir unseren Code zwingen, nur einen CPU-Kern zu verwenden, mit nur 2 virtuellen Threads – zur besseren Klarheit.
System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");
System.setProperty("jdk.virtualThreadScheduler.minRunnable", "1");
Thread 1:
Thread v1 = Thread.ofVirtual().name("long-running-thread").start(
() -> {
var thread = Thread.currentThread();
while (true) {
try {
Thread.sleep(250L);
System.out.println(STR."[\{thread}] - Handling http request ....");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
);
Thread 2:
Thread v2 = Thread.ofVirtual().name("entertainment-thread").start(
() -> {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
var thread = Thread.currentThread();
System.out.println(STR."[\{thread}] - Executing when 'http-thread' hit 'sleep' function");
}
);
Ausführung:
v1.join(); v2.join();
Ergebnis:
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#23,entertainment-thread]/runnable@ForkJoinPool-1-worker-1] - Executing when 'http-thread' hit 'sleep' function
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
Wir beobachten, dass beide virtuellen Threads (long-running-thread
und entertainment-thread
) nur von einem Plattform-Thread getragen werden, nämlich ForkJoinPool-1-worker-1
.
Zusammenfassend lässt sich sagen, dass dieses Modell Java-Anwendungen ermöglicht, hohe Ebenen von Parallelität und Skalierbarkeit mit viel geringeren Overheads als traditionelle Thread-Modelle zu erreichen, bei denen jeder Thread direkt einem einzelnen Betriebssystem-Thread zugeordnet ist. Es ist erwähnenswert, dass virtuelle Threads ein umfangreiches Thema sind und das, was ich beschrieben habe, nur einen kleinen Bruchteil davon ausmacht. Ich empfehle dringend, mehr über die Planung, feste Threads und die Interna der virtuellen Threads zu lernen.
Zusammenfassung: Die Zukunft der Programmiersprache Java
Die oben beschriebenen Funktionen sind die, die ich für die wichtigsten in Java 21 halte. Die meisten von ihnen sind nicht so bahnbrechend wie einige der Dinge, die in JDK 17 eingeführt wurden, aber sie sind trotzdem sehr nützlich und stellen angenehme QOL (Quality of Life) Änderungen dar.
Sie sollten jedoch auch die anderen Verbesserungen von JDK 21 nicht außer Acht lassen — ich ermutige Sie, die vollständige Liste zu analysieren und alle Funktionen weiter zu erkunden. Zum Beispiel halte ich die Vector API für besonders bemerkenswert, die vektorielle Berechnungen auf einigen unterstützten CPU-Architekturen ermöglicht — was zuvor nicht möglich war. Derzeit befindet sie sich noch im Inkubatorstatus/experimentellen Phase (weshalb ich sie hier nicht näher hervorgehoben habe), aber sie verspricht eine große Zukunft für Java.
Insgesamt signalisiert der Fortschritt, den Java in verschiedenen Bereichen gemacht hat, das fortwährende Engagement des Teams zur Verbesserung der Effizienz und Leistung in Anwendungen mit hoher Nachfrage.
Source:
https://dzone.com/articles/java-21-features-a-detailed-look